pushEventAdapter.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321
  1. import { createHash } from 'node:crypto'
  2. import type { UpstreamServiceSession } from './localAuth.js'
  3. import {
  4. type ChatPushJob,
  5. type ChatPushObservation,
  6. type GroupWatchAnchor,
  7. type GroupWatchReset,
  8. type PushEventCoordinator,
  9. type PushNotificationCandidate,
  10. } from './pushEvents.js'
  11. import { PushCoordinator } from './pushCoordinator.js'
  12. type JsonRecord = Record<string, unknown>
  13. function record(value: unknown): JsonRecord {
  14. return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
  15. }
  16. function values(value: unknown): unknown[] {
  17. return Array.isArray(value) ? value : []
  18. }
  19. function text(value: unknown): string {
  20. return typeof value === 'string' ? value.trim() : ''
  21. }
  22. function number(value: unknown): number | undefined {
  23. const result = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN
  24. return Number.isFinite(result) ? result : undefined
  25. }
  26. function timestampMilliseconds(value: unknown): number | undefined {
  27. const numeric = number(value)
  28. if (numeric !== undefined) return numeric < 10_000_000_000 ? numeric * 1_000 : numeric
  29. if (typeof value !== 'string') return undefined
  30. const parsed = Date.parse(value)
  31. return Number.isNaN(parsed) ? undefined : parsed
  32. }
  33. function summary(value: unknown, fallback: string): string {
  34. const normalized = text(value).replace(/\s+/g, ' ')
  35. if (!normalized) return fallback
  36. return Array.from(normalized).length > 180 ? `${Array.from(normalized).slice(0, 179).join('')}…` : normalized
  37. }
  38. function collapseID(prefix: string, value: string): string {
  39. return `${prefix}:${createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 32)}`
  40. }
  41. function parseResponse(body: Buffer): JsonRecord {
  42. try { return record(JSON.parse(body.toString('utf8'))) } catch { throw new Error('Hermes returned invalid group reconciliation JSON') }
  43. }
  44. interface ReconciliationSubscription {
  45. userId: string
  46. roomId: string
  47. startedAt: number
  48. lastMessageSeq?: number
  49. }
  50. /**
  51. * Adapts the durable push store to the realtime observers. Cursor resets first
  52. * reconcile every active room from REST, then move to the server-provided
  53. * latest anchor. Existing processed event IDs make the replay idempotent.
  54. */
  55. export class PushCoordinatorEventAdapter implements PushEventCoordinator {
  56. constructor(
  57. readonly push: PushCoordinator,
  58. readonly upstreamSession: UpstreamServiceSession,
  59. ) {}
  60. observeChat(_observation: ChatPushObservation): void {}
  61. saveChatJob(job: ChatPushJob): void {
  62. if (this.push.isAnyProviderEnabled()) this.push.saveChatJob(job)
  63. }
  64. completeChatJob(jobID: string): void { this.push.completeChatJob(jobID) }
  65. pendingChatJobs(): readonly ChatPushJob[] {
  66. return this.push.isAnyProviderEnabled() ? this.push.pendingChatJobs() : []
  67. }
  68. promptDigest(localUserID: string, prompt: string): string {
  69. return this.push.promptDigest(localUserID, prompt)
  70. }
  71. canRecoverChatJob(job: ChatPushJob): boolean { return this.push.chatJobRecoveryAllowed(job) }
  72. enqueueNotification(candidate: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
  73. if (!this.push.isAnyProviderEnabled()) return 'ignored'
  74. if ('roomID' in candidate) {
  75. if (candidate.messageSequence !== undefined) {
  76. const lastSequence = this.push.groupSubscriptionLastMessageSeq(candidate.localUserID, candidate.roomID)
  77. if (lastSequence !== undefined && candidate.messageSequence <= lastSequence) return 'ignored'
  78. const result = this.push.enqueueNotification(candidate)
  79. this.push.advanceGroupSubscriptionMessageSeq(
  80. candidate.localUserID,
  81. candidate.roomID,
  82. candidate.messageSequence,
  83. )
  84. return result
  85. } else {
  86. const startedAt = this.push.groupSubscriptionStartedAt(candidate.localUserID, candidate.roomID)
  87. if (startedAt === undefined || candidate.occurredAt === undefined || candidate.occurredAt + 2_000 < startedAt) {
  88. return 'ignored'
  89. }
  90. }
  91. }
  92. return this.push.enqueueNotification(candidate)
  93. }
  94. groupWatchAnchor(): GroupWatchAnchor | undefined {
  95. const anchor = this.push.groupWatchAnchor()
  96. return anchor ? { epoch: anchor.epoch, cursor: anchor.cursor } : undefined
  97. }
  98. groupSubscribers(roomID: string): readonly string[] { return this.push.groupSubscribers(roomID) }
  99. advanceGroupCursor(anchor: GroupWatchAnchor): void { this.push.advanceGroupCursor(anchor) }
  100. async resetGroupCursor(reset: GroupWatchReset): Promise<GroupWatchAnchor> {
  101. if (reset.reason !== 'initial') await this.reconcileGroups()
  102. const anchor = this.push.resetGroupCursor(reset)
  103. return { epoch: anchor.epoch, cursor: anchor.cursor }
  104. }
  105. async reconcileGroups(): Promise<void> {
  106. const subscriptions = this.push.activeGroupSubscriptions().flatMap(item => {
  107. const startedAt = Date.parse(item.updatedAt)
  108. return Number.isNaN(startedAt) ? [] : [{
  109. userId: item.userId,
  110. roomId: item.roomId,
  111. startedAt,
  112. ...(item.lastMessageSeq !== undefined ? { lastMessageSeq: item.lastMessageSeq } : {}),
  113. }]
  114. })
  115. const byRoom = new Map<string, ReconciliationSubscription[]>()
  116. for (const subscription of subscriptions) {
  117. byRoom.set(subscription.roomId, [...(byRoom.get(subscription.roomId) ?? []), subscription])
  118. }
  119. for (const [roomID, roomSubscriptions] of byRoom) {
  120. await this.reconcileRoom(roomID, roomSubscriptions)
  121. }
  122. }
  123. private async reconcileRoom(roomID: string, subscriptions: ReconciliationSubscription[]): Promise<void> {
  124. const detailResponse = await this.upstreamSession.request(
  125. `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}`,
  126. )
  127. if (detailResponse.status < 200 || detailResponse.status >= 300) {
  128. if (detailResponse.status === 404) {
  129. for (const subscription of subscriptions) {
  130. this.push.setGroupSubscription(subscription.userId, roomID, false)
  131. }
  132. return
  133. }
  134. throw new Error(`Unable to reconcile group ${roomID} (${detailResponse.status})`)
  135. }
  136. const detailPayload = parseResponse(detailResponse.body)
  137. const detailData = record(detailPayload.data)
  138. const room = record(detailPayload.room ?? detailData.room ?? detailPayload.data ?? detailPayload)
  139. const roomName = text(room.name) || '团队'
  140. await this.reconcileMessages(roomID, roomName, subscriptions)
  141. await this.reconcileInteractions(roomID, roomName, subscriptions, room)
  142. }
  143. private async reconcileMessages(
  144. roomID: string,
  145. roomName: string,
  146. subscriptions: ReconciliationSubscription[],
  147. ): Promise<void> {
  148. if (subscriptions.some(item => item.lastMessageSeq === undefined)) {
  149. const baselineResponse = await this.upstreamSession.request(
  150. `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
  151. { search: new URLSearchParams({ limit: '1' }) },
  152. )
  153. if (baselineResponse.status < 200 || baselineResponse.status >= 300) {
  154. throw new Error(`Unable to establish group baseline for ${roomID} (${baselineResponse.status})`)
  155. }
  156. const baselinePayload = parseResponse(baselineResponse.body)
  157. const baselineData = record(baselinePayload.data)
  158. const baseline = values(
  159. baselinePayload.items ?? baselinePayload.messages ?? baselineData.items ?? baselineData.messages,
  160. ).map(record).reduce((maximum, message) => {
  161. const sequence = number(message.seq ?? message.sequence)
  162. return sequence !== undefined && Number.isSafeInteger(sequence) ? Math.max(maximum, sequence) : maximum
  163. }, 0)
  164. for (const subscription of subscriptions) {
  165. if (subscription.lastMessageSeq !== undefined) continue
  166. this.push.advanceGroupSubscriptionMessageSeq(subscription.userId, roomID, baseline)
  167. subscription.lastMessageSeq = baseline
  168. }
  169. }
  170. let afterSequence = Math.min(...subscriptions.map(item => item.lastMessageSeq ?? 0))
  171. if (afterSequence === 0) {
  172. const collected: JsonRecord[] = []
  173. let beforeSequence: number | undefined
  174. let complete = false
  175. for (let page = 0; page < 200; page += 1) {
  176. const search = new URLSearchParams({ limit: '100' })
  177. if (beforeSequence !== undefined) search.set('beforeSeq', String(beforeSequence))
  178. const messages = await this.fetchGroupMessages(roomID, search)
  179. if (!messages.length) { complete = true; break }
  180. collected.push(...messages)
  181. const sequences = messages
  182. .map(message => number(message.seq ?? message.sequence))
  183. .filter((value): value is number => value !== undefined && Number.isSafeInteger(value))
  184. if (messages.length < 100 || !sequences.length) { complete = true; break }
  185. const nextBefore = Math.min(...sequences)
  186. if (beforeSequence !== undefined && nextBefore >= beforeSequence) break
  187. beforeSequence = nextBefore
  188. }
  189. if (!complete) throw new Error(`Group reconciliation exceeded the safe page limit for ${roomID}`)
  190. const ordered = [...new Map(collected.map(message => [String(message.id ?? message.seq), message])).values()]
  191. .sort((left, right) => Number(left.seq ?? left.sequence) - Number(right.seq ?? right.sequence))
  192. this.processGroupMessages(roomID, roomName, subscriptions, ordered, 0)
  193. return
  194. }
  195. for (let page = 0; page < 50; page += 1) {
  196. const search = new URLSearchParams({ limit: '100' })
  197. search.set('afterSeq', String(afterSequence))
  198. const messages = await this.fetchGroupMessages(roomID, search)
  199. if (!messages.length) return
  200. const greatestSequence = this.processGroupMessages(
  201. roomID, roomName, subscriptions, messages, afterSequence,
  202. )
  203. if (messages.length < 100 || greatestSequence <= afterSequence) return
  204. afterSequence = greatestSequence
  205. }
  206. throw new Error(`Group reconciliation exceeded the safe page limit for ${roomID}`)
  207. }
  208. private async fetchGroupMessages(roomID: string, search: URLSearchParams): Promise<JsonRecord[]> {
  209. const response = await this.upstreamSession.request(
  210. `/api/plugins/yaoyao/v1/rooms/${encodeURIComponent(roomID)}/messages`,
  211. { search },
  212. )
  213. if (response.status < 200 || response.status >= 300) {
  214. throw new Error(`Unable to reconcile group messages for ${roomID} (${response.status})`)
  215. }
  216. const payload = parseResponse(response.body)
  217. const data = record(payload.data)
  218. return values(payload.items ?? payload.messages ?? data.items ?? data.messages).map(record)
  219. }
  220. private processGroupMessages(
  221. roomID: string,
  222. roomName: string,
  223. subscriptions: ReconciliationSubscription[],
  224. messages: JsonRecord[],
  225. afterSequence: number,
  226. ): number {
  227. let greatestSequence = afterSequence
  228. for (const message of messages) {
  229. const sequence = number(message.seq ?? message.sequence)
  230. const updatedAt = timestampMilliseconds(message.updatedAt ?? message.updated_at ?? message.createdAt ?? message.created_at)
  231. if (sequence === undefined || !Number.isSafeInteger(sequence) || sequence <= afterSequence) continue
  232. greatestSequence = Math.max(greatestSequence, sequence)
  233. const senderKind = text(message.senderKind ?? message.sender_kind).toLowerCase()
  234. const status = text(message.status).toLowerCase()
  235. const messageID = text(message.id)
  236. if (senderKind !== 'agent' || !messageID || (status !== 'completed' && status !== 'failed')) continue
  237. for (const subscription of subscriptions) {
  238. if (sequence <= (subscription.lastMessageSeq ?? 0)) continue
  239. const kind = status === 'completed' ? 'group.message.completed' as const : 'group.message.failed' as const
  240. const eventID = `group-message:${messageID}:${status}`
  241. const sender = text(message.senderName ?? message.sender_name) || 'Agent'
  242. const topicID = text(message.topicId ?? message.topic_id)
  243. this.enqueueNotification({
  244. eventID,
  245. localUserID: subscription.userId,
  246. kind,
  247. title: `${roomName} · ${sender}`,
  248. body: summary(message.error ?? message.content, status === 'completed' ? '打开夭夭查看团队消息' : '打开夭夭查看失败详情'),
  249. collapseID: collapseID('group-message', messageID),
  250. roomID,
  251. messageSequence: sequence,
  252. occurredAt: updatedAt,
  253. ...(topicID ? { topicID } : {}),
  254. data: {
  255. version: '1', eventId: eventID, kind, roomId: roomID,
  256. ...(topicID ? { topicId: topicID } : {}), messageId: messageID,
  257. },
  258. })
  259. }
  260. }
  261. return greatestSequence
  262. }
  263. private async reconcileInteractions(
  264. roomID: string,
  265. roomName: string,
  266. subscriptions: ReconciliationSubscription[],
  267. room: JsonRecord,
  268. ): Promise<void> {
  269. const interactions = values(room.pendingInteractions ?? room.pending_interactions)
  270. .map(record)
  271. for (const interaction of interactions) {
  272. const status = text(interaction.status).toLowerCase()
  273. const interactionID = text(interaction.id)
  274. const updatedAt = timestampMilliseconds(
  275. interaction.updatedAt ?? interaction.updated_at ?? interaction.createdAt ?? interaction.created_at,
  276. )
  277. if (!interactionID || status && status !== 'pending' || updatedAt === undefined) continue
  278. const interactionKind = text(interaction.kind).toLowerCase()
  279. const clarification = interactionKind === 'clarification'
  280. const payload = record(interaction.payload)
  281. const topicID = text(interaction.topicId ?? interaction.topic_id)
  282. const eventID = `group-interaction:${interactionID}`
  283. for (const subscription of subscriptions) {
  284. if (updatedAt + 2_000 < subscription.startedAt) continue
  285. this.push.enqueueNotification({
  286. eventID,
  287. localUserID: subscription.userId,
  288. kind: 'group.interaction.requested',
  289. title: clarification ? `${roomName} · 需要补充信息` : `${roomName} · 请求审批`,
  290. body: summary(payload.question ?? payload.message ?? payload.prompt, '请打开夭夭继续处理'),
  291. collapseID: collapseID('group-interaction', interactionID),
  292. roomID,
  293. ...(topicID ? { topicID } : {}),
  294. data: {
  295. version: '1', eventId: eventID, kind: 'group.interaction.requested', roomId: roomID,
  296. ...(topicID ? { topicId: topicID } : {}), interactionId: interactionID,
  297. },
  298. })
  299. }
  300. }
  301. }
  302. }