pushCoordinator.ts 64 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634
  1. import { createHash, createHmac, randomBytes, randomUUID } from 'node:crypto'
  2. import {
  3. chmodSync,
  4. closeSync,
  5. existsSync,
  6. fsyncSync,
  7. mkdirSync,
  8. openSync,
  9. readFileSync,
  10. renameSync,
  11. unlinkSync,
  12. writeFileSync,
  13. } from 'node:fs'
  14. import { dirname, join } from 'node:path'
  15. import {
  16. APNsProvider,
  17. type APNsEnvironment,
  18. type APNsRequest,
  19. type APNsSendResult,
  20. } from './apns.js'
  21. import { FCMProvider, type FCMRequest } from './fcm.js'
  22. import {
  23. DEFAULT_APNS_TOPIC,
  24. type APNsProviderConfig,
  25. type FCMProviderConfig,
  26. } from './config.js'
  27. import { DEFAULT_FCM_PACKAGE_NAME } from './fcmConfiguration.js'
  28. import { notificationPlainText } from './notificationText.js'
  29. export const PUSH_EVENT_KINDS = [
  30. 'chat.completed',
  31. 'chat.failed',
  32. 'chat.approval.requested',
  33. 'chat.clarification.requested',
  34. 'group.message.completed',
  35. 'group.message.failed',
  36. 'group.interaction.requested',
  37. ] as const
  38. export type PushEventKind = typeof PUSH_EVENT_KINDS[number]
  39. export type PushPlatform = 'ios' | 'android'
  40. export interface AndroidPushCapabilities {
  41. enabled: boolean
  42. provider: 'fcm'
  43. packageName: string
  44. previewMode: 'title-and-summary'
  45. events: readonly PushEventKind[]
  46. maxSummaryCharacters: 180
  47. maximumSummaryCharacters: 180
  48. configurationError?: string
  49. }
  50. export interface PushCapabilities {
  51. protocolVersion: 1
  52. enabled: boolean
  53. topic: string
  54. environments: readonly APNsEnvironment[]
  55. previewMode: 'title-and-summary'
  56. events: readonly PushEventKind[]
  57. maxSummaryCharacters: 180
  58. maximumSummaryCharacters: 180
  59. configurationError?: string
  60. platforms: {
  61. ios: {
  62. enabled: boolean
  63. provider: 'apns'
  64. topic: string
  65. environments: readonly APNsEnvironment[]
  66. }
  67. android: AndroidPushCapabilities
  68. }
  69. }
  70. export interface PushStatus {
  71. configured: boolean
  72. healthy: boolean
  73. topic: string
  74. environments: readonly APNsEnvironment[]
  75. registrationCount: number
  76. pendingCount: number
  77. subscriptionCount: number
  78. lastSuccessAt?: string
  79. lastError?: string
  80. lastErrorAt?: string
  81. }
  82. export interface FCMPushStatus {
  83. configured: boolean
  84. healthy: boolean
  85. provider: 'fcm'
  86. packageName: string
  87. projectId?: string
  88. registrationCount: number
  89. pendingCount: number
  90. subscriptionCount: number
  91. lastSuccessAt?: string
  92. lastError?: string
  93. lastErrorAt?: string
  94. }
  95. interface InstallationRegistrationBase {
  96. userId: string
  97. installationId: string
  98. clientAccountId: string
  99. appVersion?: string
  100. authorizationVersion?: number
  101. }
  102. export type InstallationRegistration = InstallationRegistrationBase & (
  103. | { platform?: 'ios'; deviceToken: string; environment: APNsEnvironment; fid?: never }
  104. | { platform: 'android'; fid: string; deviceToken?: never; environment?: never }
  105. )
  106. export interface PushInstallation {
  107. installationId: string
  108. clientAccountId: string
  109. platform: PushPlatform
  110. environment?: APNsEnvironment
  111. appVersion?: string
  112. updatedAt: string
  113. }
  114. export interface PushGroupSubscription {
  115. roomId: string
  116. enabled: boolean
  117. updatedAt: string
  118. lastMessageSeq?: number
  119. }
  120. export interface PushNotification {
  121. eventId: string
  122. userId: string
  123. kind: PushEventKind
  124. title: string
  125. body: string
  126. clientAccountId?: string
  127. data?: Record<string, unknown>
  128. collapseId?: string
  129. threadId?: string
  130. roomId?: string
  131. expiresAt?: number
  132. }
  133. export interface PushGroupWatchAnchor {
  134. epoch: string
  135. cursor: number
  136. updatedAt?: string
  137. reason?: string
  138. }
  139. export interface PushChatJob {
  140. id: string
  141. localUserID: string
  142. profile?: string
  143. runtimeSessionID: string
  144. storedSessionID?: string
  145. requestID: string
  146. queued: boolean
  147. phase: 'submitted' | 'accepted' | 'watching'
  148. submittedAt: number
  149. expiresAt: number
  150. metadata?: Record<string, unknown>
  151. }
  152. export interface PushNotificationCandidate {
  153. eventID: string
  154. localUserID: string
  155. kind: PushEventKind
  156. title: string
  157. body: string
  158. collapseID: string
  159. data: Record<string, unknown>
  160. roomID?: string
  161. topicID?: string
  162. }
  163. export interface PushFlushResult {
  164. delivered: number
  165. retried: number
  166. removedRegistrations: number
  167. failed: number
  168. }
  169. export interface PushSender {
  170. send(request: APNsRequest): Promise<APNsSendResult>
  171. close?(): void
  172. }
  173. export interface FCMSender {
  174. send(request: FCMRequest): Promise<APNsSendResult>
  175. close?(): void
  176. }
  177. export interface PushCoordinatorOptions {
  178. home: string
  179. apns?: APNsProviderConfig
  180. apnsConfigurationError?: string
  181. provider?: PushSender
  182. providerFactory?: (config: APNsProviderConfig) => PushSender
  183. fcm?: FCMProviderConfig
  184. fcmConfigurationError?: string
  185. fcmProvider?: FCMSender
  186. fcmProviderFactory?: (config: FCMProviderConfig) => FCMSender
  187. now?: () => number
  188. autoFlush?: boolean
  189. baseRetryMilliseconds?: number
  190. maxRetryMilliseconds?: number
  191. maxAttempts?: number
  192. isUserActive?: (userId: string) => boolean
  193. userAuthorizationVersion?: (userId: string) => number | undefined
  194. }
  195. interface InstallationRecordBase extends PushInstallation {
  196. userId: string
  197. badge: number
  198. authorizationVersion?: number
  199. }
  200. interface IOSInstallationRecord extends InstallationRecordBase {
  201. platform: 'ios'
  202. deviceToken: string
  203. environment: APNsEnvironment
  204. }
  205. interface AndroidInstallationRecord extends InstallationRecordBase {
  206. platform: 'android'
  207. fid: string
  208. environment?: never
  209. }
  210. type InstallationRecord = IOSInstallationRecord | AndroidInstallationRecord
  211. interface GroupSubscriptionRecord extends PushGroupSubscription {
  212. userId: string
  213. }
  214. interface OutboxRecord {
  215. id: string
  216. eventId: string
  217. userId: string
  218. installationId: string
  219. clientAccountId: string
  220. platform: PushPlatform
  221. kind: PushEventKind
  222. title: string
  223. body: string
  224. data: Record<string, unknown>
  225. collapseId?: string
  226. threadId?: string
  227. roomId?: string
  228. createdAt: number
  229. expiresAt: number
  230. attempts: number
  231. nextAttemptAt: number
  232. }
  233. interface StoredStatus {
  234. lastSuccessAt?: string
  235. lastError?: string
  236. lastErrorAt?: string
  237. }
  238. interface PushState {
  239. schemaVersion: 2
  240. installations: InstallationRecord[]
  241. groupSubscriptions: GroupSubscriptionRecord[]
  242. outbox: OutboxRecord[]
  243. processedEvents: Record<string, number>
  244. groupWatch?: PushGroupWatchAnchor
  245. chatJobs: PushChatJob[]
  246. ambiguousChatSessions: Record<string, number>
  247. chatRecoveryDisabledUntil?: number
  248. providerStatus: Record<PushPlatform, StoredStatus>
  249. }
  250. const MAX_SUMMARY_CHARACTERS = 180
  251. const MAX_OUTBOX_ITEMS = 10_000
  252. const MAX_GROUP_SUBSCRIPTIONS_PER_USER = 512
  253. const MAX_INSTALLATIONS_PER_USER = 32
  254. const MAX_CHAT_JOBS_PER_USER = 256
  255. const MAX_PROCESSED_EVENTS = 50_000
  256. const MAX_AMBIGUOUS_CHAT_SESSIONS = 50_000
  257. const AMBIGUOUS_CHAT_SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1_000
  258. const DEFAULT_EVENT_LIFETIME_MS = 24 * 60 * 60 * 1_000
  259. const DEDUPE_LIFETIME_MS = 90 * 24 * 60 * 60 * 1_000
  260. function emptyState(): PushState {
  261. return {
  262. schemaVersion: 2,
  263. installations: [],
  264. groupSubscriptions: [],
  265. outbox: [],
  266. processedEvents: {},
  267. chatJobs: [],
  268. ambiguousChatSessions: {},
  269. providerStatus: { ios: {}, android: {} },
  270. }
  271. }
  272. function isObject(value: unknown): value is Record<string, unknown> {
  273. return typeof value === 'object' && value !== null && !Array.isArray(value)
  274. }
  275. function parseState(raw: string): PushState {
  276. const value = JSON.parse(raw) as unknown
  277. if (!isObject(value)
  278. || (value.schemaVersion !== 1 && value.schemaVersion !== 2)
  279. || !Array.isArray(value.installations)
  280. || !Array.isArray(value.groupSubscriptions)
  281. || !Array.isArray(value.outbox)
  282. || !isObject(value.processedEvents)
  283. || (value.schemaVersion === 1 && !isObject(value.status))
  284. || (value.schemaVersion === 2 && !isObject(value.providerStatus))) {
  285. throw new Error('Unsupported or malformed push state')
  286. }
  287. if (value.chatJobs !== undefined && !Array.isArray(value.chatJobs)) {
  288. throw new Error('Malformed push chat jobs')
  289. }
  290. if (value.ambiguousChatSessions !== undefined && !isObject(value.ambiguousChatSessions)) {
  291. throw new Error('Malformed ambiguous push chat sessions')
  292. }
  293. const legacyOwners = isObject(value.chatSessionOwners)
  294. ? value.chatSessionOwners as Record<string, unknown> : {}
  295. const migratedAmbiguous = Object.fromEntries(Object.entries(legacyOwners).flatMap(([sessionID, owners]) => (
  296. Array.isArray(owners) && new Set(owners.map(String)).size > 1 ? [[sessionID, Date.now()]] : []
  297. )))
  298. const installations = (value.installations as Array<Record<string, unknown>>).map(record => {
  299. if (value.schemaVersion === 1) return { ...record, platform: 'ios' }
  300. return record
  301. }) as unknown as InstallationRecord[]
  302. if (installations.some(record => record.platform !== 'ios' && record.platform !== 'android')) {
  303. throw new Error('Malformed push installation platform')
  304. }
  305. if (installations.some(record => record.platform === 'ios'
  306. ? typeof record.deviceToken !== 'string'
  307. || (record.environment !== 'development' && record.environment !== 'production')
  308. : typeof record.fid !== 'string')) {
  309. throw new Error('Malformed push installation target')
  310. }
  311. const outbox = (value.outbox as Array<Record<string, unknown>>).map(record => {
  312. if (value.schemaVersion === 1) return { ...record, platform: 'ios' }
  313. return record
  314. }) as unknown as OutboxRecord[]
  315. if (outbox.some(record => record.platform !== 'ios' && record.platform !== 'android')) {
  316. throw new Error('Malformed push outbox platform')
  317. }
  318. const providerStatus = value.schemaVersion === 1
  319. ? { ios: value.status as StoredStatus, android: {} }
  320. : value.providerStatus as Record<PushPlatform, StoredStatus>
  321. if (!isObject(providerStatus.ios) || !isObject(providerStatus.android)) {
  322. throw new Error('Malformed push provider status')
  323. }
  324. return {
  325. schemaVersion: 2,
  326. installations,
  327. groupSubscriptions: value.groupSubscriptions as GroupSubscriptionRecord[],
  328. outbox,
  329. processedEvents: value.processedEvents as Record<string, number>,
  330. ...(isObject(value.groupWatch) ? { groupWatch: value.groupWatch as unknown as PushGroupWatchAnchor } : {}),
  331. chatJobs: (value.chatJobs ?? []) as PushChatJob[],
  332. ambiguousChatSessions: {
  333. ...migratedAmbiguous,
  334. ...(value.ambiguousChatSessions ?? {}) as Record<string, number>,
  335. },
  336. ...(typeof value.chatRecoveryDisabledUntil === 'number'
  337. ? { chatRecoveryDisabledUntil: value.chatRecoveryDisabledUntil }
  338. : {}),
  339. providerStatus: {
  340. ios: providerStatus.ios,
  341. android: providerStatus.android,
  342. },
  343. }
  344. }
  345. function cloneState(state: PushState): PushState {
  346. return JSON.parse(JSON.stringify(state)) as PushState
  347. }
  348. class PushStateStore {
  349. private state = emptyState()
  350. readonly path: string
  351. readonly loadError?: string
  352. constructor(home: string) {
  353. this.path = join(home, 'push', 'state.json')
  354. if (!existsSync(this.path)) return
  355. try {
  356. chmodSync(this.path, 0o600)
  357. const raw = readFileSync(this.path, 'utf8')
  358. const parsed = JSON.parse(raw) as { schemaVersion?: unknown }
  359. this.state = parseState(raw)
  360. if (parsed.schemaVersion === 1) this.write(this.state)
  361. } catch {
  362. this.loadError = '推送状态文件无法读取;为避免覆盖现有数据,推送已停用'
  363. }
  364. }
  365. snapshot(): PushState {
  366. return cloneState(this.state)
  367. }
  368. mutate<T>(operation: (state: PushState) => T): T {
  369. if (this.loadError) throw new Error(this.loadError)
  370. const next = cloneState(this.state)
  371. const result = operation(next)
  372. this.write(next)
  373. this.state = next
  374. return result
  375. }
  376. private write(state: PushState): void {
  377. const directory = dirname(this.path)
  378. mkdirSync(directory, { recursive: true, mode: 0o700 })
  379. const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`
  380. let descriptor: number | undefined
  381. try {
  382. descriptor = openSync(temporary, 'wx', 0o600)
  383. writeFileSync(descriptor, `${JSON.stringify(state)}\n`, 'utf8')
  384. fsyncSync(descriptor)
  385. closeSync(descriptor)
  386. descriptor = undefined
  387. renameSync(temporary, this.path)
  388. chmodSync(this.path, 0o600)
  389. } catch (cause) {
  390. if (descriptor !== undefined) closeSync(descriptor)
  391. try { unlinkSync(temporary) } catch { /* Nothing to clean up. */ }
  392. throw cause
  393. }
  394. }
  395. }
  396. function requiredIdentifier(value: string, name: string, maximum = 256): string {
  397. const normalized = value.trim()
  398. if (!normalized || normalized.length > maximum || /[\u0000-\u001f\u007f]/.test(normalized)) {
  399. throw new Error(`${name} is invalid`)
  400. }
  401. return normalized
  402. }
  403. function normalizedToken(value: string): string {
  404. const normalized = value.trim().toLowerCase()
  405. if (!/^(?:[a-f0-9]{2}){16,256}$/.test(normalized)) throw new Error('deviceToken is invalid')
  406. return normalized
  407. }
  408. function normalizedFID(value: string): string {
  409. const normalized = value.trim()
  410. if (normalized.length < 16 || normalized.length > 4_096
  411. || !/^[A-Za-z0-9_:.\-]+$/.test(normalized)) throw new Error('fid is invalid')
  412. return normalized
  413. }
  414. function limitedText(value: string, maximum: number, name: string): string {
  415. const normalized = value.trim()
  416. if (!normalized) throw new Error(`${name} is required`)
  417. return Array.from(normalized).slice(0, maximum).join('')
  418. }
  419. function publicInstallation(record: InstallationRecord): PushInstallation {
  420. return {
  421. installationId: record.installationId,
  422. clientAccountId: record.clientAccountId,
  423. platform: record.platform,
  424. ...(record.platform === 'ios' ? { environment: record.environment } : {}),
  425. ...(record.appVersion ? { appVersion: record.appVersion } : {}),
  426. updatedAt: record.updatedAt,
  427. }
  428. }
  429. function dedupePrefix(userId: string): string {
  430. return `${Buffer.from(userId, 'utf8').toString('base64url')}.`
  431. }
  432. function dedupeKey(userId: string, eventId: string): string {
  433. const digest = createHash('sha256').update(eventId, 'utf8').digest('base64url')
  434. return `${dedupePrefix(userId)}${digest}`
  435. }
  436. function safeError(result: APNsSendResult, platform: PushPlatform = 'ios'): string {
  437. const reason = result.reason || `${platform === 'ios' ? 'APNs' : 'FCM'} HTTP ${result.status}`
  438. return reason
  439. .replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]')
  440. .replace(/[A-Za-z0-9_:.\-]{80,}/g, '[opaque-token]')
  441. .replace(/[\r\n]+/g, ' ')
  442. .slice(0, 500)
  443. }
  444. function safeProviderException(cause: unknown, installation: InstallationRecord): string {
  445. const target = installation.platform === 'ios' ? installation.deviceToken : installation.fid
  446. return (cause instanceof Error ? cause.message : String(cause))
  447. .split(target).join('[push-target]')
  448. .replace(/(?:[a-fA-F0-9]{2}){16,256}/g, '[device-token]')
  449. .replace(/[A-Za-z0-9_:.-]{80,}/g, '[opaque-token]')
  450. .replace(/[\r\n]+/g, ' ')
  451. .slice(0, 500) || `${installation.platform === 'ios' ? 'APNs' : 'FCM'} provider failed`
  452. }
  453. function canonicalGroupWatchAnchor(input: PushGroupWatchAnchor, now: number): PushGroupWatchAnchor {
  454. const epoch = requiredIdentifier(input.epoch, 'epoch', 128)
  455. if (!Number.isSafeInteger(input.cursor) || input.cursor < 0) throw new Error('cursor is invalid')
  456. return {
  457. epoch,
  458. cursor: input.cursor,
  459. updatedAt: new Date(now).toISOString(),
  460. ...(input.reason ? { reason: limitedText(input.reason, 200, 'reason') } : {}),
  461. }
  462. }
  463. function canonicalChatJob(input: PushChatJob): PushChatJob {
  464. const id = requiredIdentifier(input.id, 'job.id', 512)
  465. const localUserID = requiredIdentifier(input.localUserID, 'job.localUserID')
  466. const runtimeSessionID = requiredIdentifier(input.runtimeSessionID, 'job.runtimeSessionID', 512)
  467. const requestID = requiredIdentifier(input.requestID, 'job.requestID', 512)
  468. if (!['submitted', 'accepted', 'watching'].includes(input.phase)) throw new Error('job.phase is invalid')
  469. if (!Number.isFinite(input.submittedAt) || !Number.isFinite(input.expiresAt) || input.expiresAt <= input.submittedAt) {
  470. throw new Error('job timestamps are invalid')
  471. }
  472. let metadata: Record<string, unknown> | undefined
  473. if (input.metadata) metadata = JSON.parse(JSON.stringify(input.metadata)) as Record<string, unknown>
  474. return {
  475. id,
  476. localUserID,
  477. ...(input.profile ? { profile: requiredIdentifier(input.profile, 'job.profile') } : {}),
  478. runtimeSessionID,
  479. ...(input.storedSessionID
  480. ? { storedSessionID: requiredIdentifier(input.storedSessionID, 'job.storedSessionID', 512) }
  481. : {}),
  482. requestID,
  483. queued: Boolean(input.queued),
  484. phase: input.phase,
  485. submittedAt: input.submittedAt,
  486. expiresAt: input.expiresAt,
  487. ...(metadata ? { metadata } : {}),
  488. }
  489. }
  490. export class PushCoordinator {
  491. private readonly store: PushStateStore
  492. private provider?: PushSender
  493. private apns?: APNsProviderConfig
  494. private apnsConfigurationError?: string
  495. private readonly providerFactory: (config: APNsProviderConfig) => PushSender
  496. private fcmProvider?: FCMSender
  497. private fcm?: FCMProviderConfig
  498. private fcmConfigurationError?: string
  499. private readonly fcmProviderFactory: (config: FCMProviderConfig) => FCMSender
  500. private readonly now: () => number
  501. private readonly autoFlush: boolean
  502. private readonly baseRetryMilliseconds: number
  503. private readonly maxRetryMilliseconds: number
  504. private readonly maxAttempts: number
  505. private readonly isUserActive: (userId: string) => boolean
  506. private readonly userAuthorizationVersion?: (userId: string) => number | undefined
  507. private timer?: NodeJS.Timeout
  508. private activeFlush?: Promise<PushFlushResult>
  509. private closed = false
  510. private readonly runtimeErrors: Partial<Record<PushPlatform, { message: string; at: string }>> = {}
  511. private readonly flushFailureCount: Record<PushPlatform, number> = { ios: 0, android: 0 }
  512. private readonly flushBlockedUntil: Record<PushPlatform, number> = { ios: 0, android: 0 }
  513. private correlationSecret?: Buffer
  514. private correlationError?: string
  515. private readonly enabledListeners = new Set<(enabled: boolean) => void>()
  516. private configurationOperation: Promise<void> = Promise.resolve()
  517. private providerGeneration = 0
  518. constructor(readonly options: PushCoordinatorOptions) {
  519. this.store = new PushStateStore(options.home)
  520. this.apns = options.apns
  521. this.apnsConfigurationError = options.apnsConfigurationError
  522. this.providerFactory = options.providerFactory ?? (config => new APNsProvider(config))
  523. this.provider = options.provider ?? (options.apns ? this.providerFactory(options.apns) : undefined)
  524. this.fcm = options.fcm
  525. this.fcmConfigurationError = options.fcmConfigurationError
  526. this.fcmProviderFactory = options.fcmProviderFactory ?? (config => new FCMProvider(config))
  527. this.fcmProvider = options.fcmProvider ?? (options.fcm ? this.fcmProviderFactory(options.fcm) : undefined)
  528. this.now = options.now ?? Date.now
  529. this.autoFlush = options.autoFlush ?? true
  530. this.baseRetryMilliseconds = options.baseRetryMilliseconds ?? 1_000
  531. this.maxRetryMilliseconds = options.maxRetryMilliseconds ?? 60 * 60 * 1_000
  532. this.maxAttempts = options.maxAttempts ?? 36
  533. this.isUserActive = options.isUserActive ?? (() => true)
  534. this.userAuthorizationVersion = options.userAuthorizationVersion
  535. if (this.isAnyProviderEnabled()) {
  536. try { this.loadCorrelationSecret() } catch (cause) {
  537. this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
  538. }
  539. }
  540. this.markPersistedJobsDisconnected()
  541. this.scheduleNextFlush()
  542. }
  543. configureAPNs(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
  544. const operation = this.configurationOperation.then(() => this.applyAPNsConfiguration(apns, configurationError))
  545. this.configurationOperation = operation.then(() => undefined, () => undefined)
  546. return operation
  547. }
  548. private async applyAPNsConfiguration(apns?: APNsProviderConfig, configurationError?: string): Promise<void> {
  549. const wasEnabled = this.isAnyProviderEnabled()
  550. const previous = this.provider
  551. const next = apns && !configurationError ? this.providerFactory(apns) : undefined
  552. if (this.closed) {
  553. next?.close?.()
  554. return
  555. }
  556. this.providerGeneration += 1
  557. this.apns = apns
  558. this.apnsConfigurationError = configurationError
  559. this.provider = next
  560. delete this.runtimeErrors.ios
  561. this.flushFailureCount.ios = 0
  562. this.flushBlockedUntil.ios = 0
  563. if (this.isAnyProviderEnabled()) {
  564. this.correlationError = undefined
  565. try { this.loadCorrelationSecret() } catch (cause) {
  566. this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
  567. }
  568. }
  569. try {
  570. this.store.mutate(state => {
  571. delete state.providerStatus.ios.lastError
  572. delete state.providerStatus.ios.lastErrorAt
  573. })
  574. } catch { /* Existing push state errors remain authoritative. */ }
  575. if (previous && previous !== next) {
  576. const closePrevious = () => { try { previous.close?.() } catch { /* provider is already detached */ } }
  577. if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
  578. else closePrevious()
  579. }
  580. if (!this.isAnyProviderEnabled() && this.timer) {
  581. clearTimeout(this.timer)
  582. this.timer = undefined
  583. }
  584. this.scheduleNextFlush()
  585. const enabled = this.isAnyProviderEnabled()
  586. if (enabled !== wasEnabled) {
  587. for (const listener of this.enabledListeners) {
  588. try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
  589. }
  590. }
  591. }
  592. configureFCM(fcm?: FCMProviderConfig, configurationError?: string): Promise<void> {
  593. const operation = this.configurationOperation.then(() => this.applyFCMConfiguration(fcm, configurationError))
  594. this.configurationOperation = operation.then(() => undefined, () => undefined)
  595. return operation
  596. }
  597. private async applyFCMConfiguration(fcm?: FCMProviderConfig, configurationError?: string): Promise<void> {
  598. const wasEnabled = this.isAnyProviderEnabled()
  599. const previous = this.fcmProvider
  600. const next = fcm && !configurationError ? this.fcmProviderFactory(fcm) : undefined
  601. if (this.closed) {
  602. next?.close?.()
  603. return
  604. }
  605. this.providerGeneration += 1
  606. this.fcm = fcm
  607. this.fcmConfigurationError = configurationError
  608. this.fcmProvider = next
  609. delete this.runtimeErrors.android
  610. this.flushFailureCount.android = 0
  611. this.flushBlockedUntil.android = 0
  612. if (this.isAnyProviderEnabled()) {
  613. this.correlationError = undefined
  614. try { this.loadCorrelationSecret() } catch (cause) {
  615. this.correlationError = cause instanceof Error ? cause.message : 'Push correlation key is unavailable'
  616. }
  617. }
  618. try {
  619. this.store.mutate(state => {
  620. delete state.providerStatus.android.lastError
  621. delete state.providerStatus.android.lastErrorAt
  622. })
  623. } catch { /* Existing push state errors remain authoritative. */ }
  624. if (previous && previous !== next) {
  625. const closePrevious = () => { try { previous.close?.() } catch { /* provider is already detached */ } }
  626. if (this.activeFlush) void this.activeFlush.then(closePrevious, closePrevious)
  627. else closePrevious()
  628. }
  629. if (!this.isAnyProviderEnabled() && this.timer) {
  630. clearTimeout(this.timer)
  631. this.timer = undefined
  632. }
  633. this.scheduleNextFlush()
  634. const enabled = this.isAnyProviderEnabled()
  635. if (enabled !== wasEnabled) {
  636. for (const listener of this.enabledListeners) {
  637. try { listener(enabled) } catch { /* Runtime listeners cannot undo a committed configuration. */ }
  638. }
  639. }
  640. }
  641. onEnabledChange(listener: (enabled: boolean) => void): () => void {
  642. this.enabledListeners.add(listener)
  643. return () => { this.enabledListeners.delete(listener) }
  644. }
  645. capabilities(): PushCapabilities {
  646. const configurationError = this.apnsConfigurationError ?? this.store.loadError
  647. const fcmConfigurationError = this.fcmConfigurationError ?? this.store.loadError
  648. const iosEnabled = Boolean(this.apns && this.provider && !configurationError)
  649. const androidEnabled = Boolean(this.fcm && this.fcmProvider && !fcmConfigurationError)
  650. return {
  651. protocolVersion: 1,
  652. enabled: iosEnabled,
  653. topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
  654. environments: this.apns?.environments ?? ['development', 'production'],
  655. previewMode: 'title-and-summary',
  656. events: PUSH_EVENT_KINDS,
  657. maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
  658. maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
  659. ...(configurationError ? { configurationError } : {}),
  660. platforms: {
  661. ios: {
  662. enabled: iosEnabled,
  663. provider: 'apns',
  664. topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
  665. environments: this.apns?.environments ?? ['development', 'production'],
  666. },
  667. android: {
  668. enabled: androidEnabled,
  669. provider: 'fcm',
  670. packageName: this.fcm?.packageName ?? DEFAULT_FCM_PACKAGE_NAME,
  671. previewMode: 'title-and-summary',
  672. events: PUSH_EVENT_KINDS,
  673. maxSummaryCharacters: MAX_SUMMARY_CHARACTERS,
  674. maximumSummaryCharacters: MAX_SUMMARY_CHARACTERS,
  675. ...(fcmConfigurationError ? { configurationError: fcmConfigurationError } : {}),
  676. },
  677. },
  678. }
  679. }
  680. isAnyProviderEnabled(): boolean {
  681. const capabilities = this.capabilities()
  682. return capabilities.platforms.ios.enabled || capabilities.platforms.android.enabled
  683. }
  684. status(): PushStatus {
  685. const state = this.store.snapshot()
  686. const configurationError = this.apnsConfigurationError ?? this.store.loadError
  687. const stored = state.providerStatus.ios
  688. const runtimeError = this.runtimeErrors.ios
  689. const latestSuccess = stored.lastSuccessAt ? Date.parse(stored.lastSuccessAt) : 0
  690. const latestError = stored.lastErrorAt ? Date.parse(stored.lastErrorAt) : 0
  691. const configured = Boolean(this.apns && this.provider && !configurationError)
  692. return {
  693. configured,
  694. healthy: configured && !this.correlationError && !runtimeError && latestSuccess >= latestError,
  695. topic: this.apns?.topic ?? DEFAULT_APNS_TOPIC,
  696. environments: this.apns?.environments ?? ['development', 'production'],
  697. registrationCount: state.installations.filter(item => item.platform === 'ios').length,
  698. pendingCount: state.outbox.filter(item => item.platform === 'ios').length,
  699. subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
  700. ...(stored.lastSuccessAt ? { lastSuccessAt: stored.lastSuccessAt } : {}),
  701. ...(configurationError || this.correlationError || runtimeError?.message || stored.lastError
  702. ? { lastError: configurationError ?? this.correlationError ?? runtimeError?.message ?? stored.lastError }
  703. : {}),
  704. ...(runtimeError?.at || stored.lastErrorAt
  705. ? { lastErrorAt: runtimeError?.at ?? stored.lastErrorAt }
  706. : {}),
  707. }
  708. }
  709. fcmStatus(): FCMPushStatus {
  710. const state = this.store.snapshot()
  711. const configurationError = this.fcmConfigurationError ?? this.store.loadError
  712. const stored = state.providerStatus.android
  713. const runtimeError = this.runtimeErrors.android
  714. const latestSuccess = stored.lastSuccessAt ? Date.parse(stored.lastSuccessAt) : 0
  715. const latestError = stored.lastErrorAt ? Date.parse(stored.lastErrorAt) : 0
  716. const configured = Boolean(this.fcm && this.fcmProvider && !configurationError)
  717. return {
  718. configured,
  719. healthy: configured && !this.correlationError && !runtimeError && latestSuccess >= latestError,
  720. provider: 'fcm',
  721. packageName: this.fcm?.packageName ?? DEFAULT_FCM_PACKAGE_NAME,
  722. ...(this.fcm?.projectId ? { projectId: this.fcm.projectId } : {}),
  723. registrationCount: state.installations.filter(item => item.platform === 'android').length,
  724. pendingCount: state.outbox.filter(item => item.platform === 'android').length,
  725. subscriptionCount: state.groupSubscriptions.filter(item => item.enabled).length,
  726. ...(stored.lastSuccessAt ? { lastSuccessAt: stored.lastSuccessAt } : {}),
  727. ...(configurationError || this.correlationError || runtimeError?.message || stored.lastError
  728. ? { lastError: configurationError ?? this.correlationError ?? runtimeError?.message ?? stored.lastError }
  729. : {}),
  730. ...(runtimeError?.at || stored.lastErrorAt
  731. ? { lastErrorAt: runtimeError?.at ?? stored.lastErrorAt }
  732. : {}),
  733. }
  734. }
  735. registerInstallation(input: InstallationRegistration): PushInstallation {
  736. const userId = requiredIdentifier(input.userId, 'userId')
  737. const installationId = requiredIdentifier(input.installationId, 'installationId')
  738. const clientAccountId = requiredIdentifier(input.clientAccountId, 'clientAccountId')
  739. let target: Pick<AndroidInstallationRecord, 'platform' | 'fid'>
  740. | Pick<IOSInstallationRecord, 'platform' | 'deviceToken' | 'environment'>
  741. if (input.platform === 'android') {
  742. target = { platform: 'android', fid: normalizedFID(input.fid) }
  743. } else {
  744. if (input.environment !== 'development' && input.environment !== 'production') {
  745. throw new Error('environment is invalid')
  746. }
  747. target = {
  748. platform: 'ios',
  749. deviceToken: normalizedToken(input.deviceToken),
  750. environment: input.environment,
  751. }
  752. }
  753. const appVersion = input.appVersion
  754. ? requiredIdentifier(input.appVersion, 'appVersion', 64)
  755. : undefined
  756. const authorizationVersion = input.authorizationVersion
  757. ?? this.userAuthorizationVersion?.(userId)
  758. if (authorizationVersion !== undefined
  759. && (!Number.isSafeInteger(authorizationVersion) || authorizationVersion < 1)) {
  760. throw new Error('authorizationVersion is invalid')
  761. }
  762. const updatedAt = new Date(this.now()).toISOString()
  763. const installation = this.store.mutate(state => {
  764. const existingIndex = state.installations.findIndex(item => item.userId === userId
  765. && item.installationId === installationId
  766. && item.clientAccountId === clientAccountId)
  767. if (existingIndex >= 0) {
  768. const existing = state.installations[existingIndex]!
  769. const base = {
  770. userId,
  771. installationId,
  772. clientAccountId,
  773. ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
  774. ...(appVersion ? { appVersion } : {}),
  775. badge: existing.badge,
  776. updatedAt,
  777. }
  778. const next: InstallationRecord = target.platform === 'android'
  779. ? { ...base, ...target }
  780. : { ...base, ...target }
  781. state.installations[existingIndex] = next
  782. if (existing.platform !== next.platform) {
  783. state.outbox = state.outbox.filter(item => !(item.userId === userId
  784. && item.installationId === installationId
  785. && item.clientAccountId === clientAccountId))
  786. }
  787. return publicInstallation(next)
  788. }
  789. if (state.installations.filter(item => item.userId === userId).length >= MAX_INSTALLATIONS_PER_USER) {
  790. throw new Error('Push installation limit reached')
  791. }
  792. const base = {
  793. userId,
  794. installationId,
  795. clientAccountId,
  796. ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
  797. ...(appVersion ? { appVersion } : {}),
  798. badge: 0,
  799. updatedAt,
  800. }
  801. const record: InstallationRecord = target.platform === 'android'
  802. ? { ...base, ...target }
  803. : { ...base, ...target }
  804. state.installations.push(record)
  805. return publicInstallation(record)
  806. })
  807. this.scheduleNextFlush()
  808. return installation
  809. }
  810. unregisterInstallation(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): boolean {
  811. const userId = requiredIdentifier(userIdValue, 'userId')
  812. const installationId = requiredIdentifier(installationIdValue, 'installationId')
  813. const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
  814. return this.store.mutate(state => {
  815. const before = state.installations.length
  816. state.installations = state.installations.filter(item => !(item.userId === userId
  817. && item.installationId === installationId
  818. && item.clientAccountId === clientAccountId))
  819. state.outbox = state.outbox.filter(item => !(item.userId === userId
  820. && item.installationId === installationId
  821. && item.clientAccountId === clientAccountId))
  822. return state.installations.length !== before
  823. })
  824. }
  825. resetBadge(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): number {
  826. const userId = requiredIdentifier(userIdValue, 'userId')
  827. const installationId = requiredIdentifier(installationIdValue, 'installationId')
  828. const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
  829. return this.store.mutate(state => {
  830. const installation = state.installations.find(item => item.userId === userId
  831. && item.installationId === installationId
  832. && item.clientAccountId === clientAccountId)
  833. if (installation) installation.badge = 0
  834. return 0
  835. })
  836. }
  837. listGroupSubscriptions(userIdValue: string): PushGroupSubscription[] {
  838. const userId = requiredIdentifier(userIdValue, 'userId')
  839. return this.store.snapshot().groupSubscriptions
  840. .filter(item => item.userId === userId)
  841. .map(({ roomId, enabled, updatedAt, lastMessageSeq }) => ({
  842. roomId, enabled, updatedAt, ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
  843. }))
  844. .sort((left, right) => left.roomId.localeCompare(right.roomId))
  845. }
  846. setGroupSubscription(
  847. userIdValue: string,
  848. roomIdValue: string,
  849. enabled: boolean,
  850. baselineMessageSeq?: number,
  851. ): PushGroupSubscription {
  852. const userId = requiredIdentifier(userIdValue, 'userId')
  853. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  854. if (typeof enabled !== 'boolean') throw new Error('enabled must be a boolean')
  855. if (baselineMessageSeq !== undefined && (!Number.isSafeInteger(baselineMessageSeq) || baselineMessageSeq < 0)) {
  856. throw new Error('baselineMessageSeq is invalid')
  857. }
  858. const updatedAt = new Date(this.now()).toISOString()
  859. return this.store.mutate(state => {
  860. const existing = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)
  861. if (existing) {
  862. if (!enabled) {
  863. state.groupSubscriptions = state.groupSubscriptions.filter(item => !(item.userId === userId && item.roomId === roomId))
  864. state.outbox = state.outbox.filter(item => !(item.userId === userId && item.roomId === roomId))
  865. return { roomId, enabled: false, updatedAt }
  866. }
  867. if (existing.enabled === enabled) {
  868. return {
  869. roomId, enabled: existing.enabled, updatedAt: existing.updatedAt,
  870. ...(existing.lastMessageSeq !== undefined ? { lastMessageSeq: existing.lastMessageSeq } : {}),
  871. }
  872. }
  873. existing.enabled = enabled
  874. existing.updatedAt = updatedAt
  875. if (enabled && baselineMessageSeq !== undefined) existing.lastMessageSeq = baselineMessageSeq
  876. } else {
  877. if (!enabled) return { roomId, enabled: false, updatedAt }
  878. const count = state.groupSubscriptions.filter(item => item.userId === userId && item.enabled).length
  879. if (count >= MAX_GROUP_SUBSCRIPTIONS_PER_USER) throw new Error('Group push subscription limit reached')
  880. state.groupSubscriptions.push({
  881. userId, roomId, enabled, updatedAt,
  882. ...(enabled && baselineMessageSeq !== undefined ? { lastMessageSeq: baselineMessageSeq } : {}),
  883. })
  884. }
  885. const saved = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)!
  886. return {
  887. roomId, enabled, updatedAt,
  888. ...(saved.lastMessageSeq !== undefined ? { lastMessageSeq: saved.lastMessageSeq } : {}),
  889. }
  890. })
  891. }
  892. isGroupSubscribed(userIdValue: string, roomIdValue: string): boolean {
  893. const userId = requiredIdentifier(userIdValue, 'userId')
  894. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  895. return this.store.snapshot().groupSubscriptions.some(item => item.userId === userId
  896. && item.roomId === roomId
  897. && item.enabled)
  898. }
  899. groupSubscriptionStartedAt(userIdValue: string, roomIdValue: string): number | undefined {
  900. const userId = requiredIdentifier(userIdValue, 'userId')
  901. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  902. const subscription = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  903. && item.roomId === roomId
  904. && item.enabled)
  905. if (!subscription) return undefined
  906. const value = Date.parse(subscription.updatedAt)
  907. return Number.isNaN(value) ? undefined : value
  908. }
  909. groupSubscriptionLastMessageSeq(userIdValue: string, roomIdValue: string): number | undefined {
  910. const userId = requiredIdentifier(userIdValue, 'userId')
  911. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  912. return this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  913. && item.roomId === roomId
  914. && item.enabled)?.lastMessageSeq
  915. }
  916. advanceGroupSubscriptionMessageSeq(userIdValue: string, roomIdValue: string, sequence: number): void {
  917. const userId = requiredIdentifier(userIdValue, 'userId')
  918. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  919. if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error('message sequence is invalid')
  920. const snapshot = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  921. && item.roomId === roomId
  922. && item.enabled)
  923. if (!snapshot || (snapshot.lastMessageSeq ?? -1) >= sequence) return
  924. this.store.mutate(state => {
  925. const subscription = state.groupSubscriptions.find(item => item.userId === userId
  926. && item.roomId === roomId
  927. && item.enabled)
  928. if (subscription && (subscription.lastMessageSeq ?? -1) < sequence) subscription.lastMessageSeq = sequence
  929. })
  930. }
  931. subscribedUserIds(roomIdValue: string): string[] {
  932. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  933. return [...new Set(this.store.snapshot().groupSubscriptions
  934. .filter(item => item.roomId === roomId && item.enabled)
  935. .map(item => item.userId))]
  936. }
  937. activeGroupSubscriptions(): Array<PushGroupSubscription & { userId: string }> {
  938. return this.store.snapshot().groupSubscriptions
  939. .filter(item => item.enabled)
  940. .map(({ userId, roomId, enabled, updatedAt, lastMessageSeq }) => ({
  941. userId, roomId, enabled, updatedAt,
  942. ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
  943. }))
  944. .sort((left, right) => left.roomId.localeCompare(right.roomId) || left.userId.localeCompare(right.userId))
  945. }
  946. groupSubscribers(roomIdValue: string): readonly string[] {
  947. return this.subscribedUserIds(roomIdValue)
  948. }
  949. groupWatchAnchor(): PushGroupWatchAnchor | undefined {
  950. const anchor = this.store.snapshot().groupWatch
  951. return anchor ? { ...anchor } : undefined
  952. }
  953. resetGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  954. const next = canonicalGroupWatchAnchor(anchor, this.now())
  955. return this.store.mutate(state => {
  956. state.groupWatch = next
  957. return { ...next }
  958. })
  959. }
  960. resetGroupCursor(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  961. return this.resetGroupWatch(anchor)
  962. }
  963. advanceGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  964. const next = canonicalGroupWatchAnchor(anchor, this.now())
  965. return this.store.mutate(state => {
  966. if (state.groupWatch && state.groupWatch.epoch !== next.epoch) {
  967. throw new Error('group watch epoch changed without reset')
  968. }
  969. if (state.groupWatch && next.cursor < state.groupWatch.cursor) {
  970. throw new Error('group watch cursor cannot move backwards')
  971. }
  972. state.groupWatch = next
  973. return { ...next }
  974. })
  975. }
  976. advanceGroupCursor(anchor: PushGroupWatchAnchor): void {
  977. this.advanceGroupWatch(anchor)
  978. }
  979. upsertChatJob(input: PushChatJob): PushChatJob {
  980. const job = canonicalChatJob(input)
  981. return this.store.mutate(state => {
  982. const existingIndex = state.chatJobs.findIndex(item => item.id === job.id)
  983. if (existingIndex >= 0 && state.chatJobs[existingIndex]!.localUserID !== job.localUserID) {
  984. throw new Error('job.id is already owned by another user')
  985. }
  986. if (existingIndex >= 0) state.chatJobs[existingIndex] = job
  987. else {
  988. if (state.chatJobs.filter(item => item.localUserID === job.localUserID).length >= MAX_CHAT_JOBS_PER_USER) {
  989. throw new Error('Chat push job limit reached')
  990. }
  991. state.chatJobs.push(job)
  992. }
  993. if (job.storedSessionID) {
  994. for (const [sessionID, observedAt] of Object.entries(state.ambiguousChatSessions)) {
  995. if (!Number.isFinite(observedAt) || observedAt < this.now() - AMBIGUOUS_CHAT_SESSION_TTL_MS) {
  996. delete state.ambiguousChatSessions[sessionID]
  997. }
  998. }
  999. const owners = new Set(state.chatJobs
  1000. .filter(item => item.storedSessionID === job.storedSessionID)
  1001. .map(item => item.localUserID))
  1002. if (owners.size > 1) {
  1003. const entries = Object.entries(state.ambiguousChatSessions)
  1004. if (entries.length >= MAX_AMBIGUOUS_CHAT_SESSIONS
  1005. && state.ambiguousChatSessions[job.storedSessionID] === undefined) {
  1006. state.chatRecoveryDisabledUntil = this.now() + AMBIGUOUS_CHAT_SESSION_TTL_MS
  1007. } else {
  1008. state.ambiguousChatSessions[job.storedSessionID] = this.now()
  1009. }
  1010. }
  1011. }
  1012. return { ...job }
  1013. })
  1014. }
  1015. saveChatJob(input: PushChatJob): void {
  1016. this.upsertChatJob(input)
  1017. }
  1018. listChatJobs(userIdValue?: string): PushChatJob[] {
  1019. const userId = userIdValue ? requiredIdentifier(userIdValue, 'userId') : undefined
  1020. const now = this.now()
  1021. return this.store.snapshot().chatJobs
  1022. .filter(item => item.expiresAt > now && (!userId || item.localUserID === userId))
  1023. .sort((left, right) => left.submittedAt - right.submittedAt)
  1024. .map(item => ({ ...item, ...(item.metadata ? { metadata: { ...item.metadata } } : {}) }))
  1025. }
  1026. pendingChatJobs(): readonly PushChatJob[] {
  1027. const now = this.now()
  1028. const snapshot = this.store.snapshot()
  1029. if (snapshot.chatJobs.some(item => item.expiresAt <= now)) {
  1030. this.store.mutate(state => {
  1031. state.chatJobs = state.chatJobs.filter(item => item.expiresAt > now)
  1032. })
  1033. }
  1034. return this.listChatJobs()
  1035. }
  1036. chatJobRecoveryAllowed(job: PushChatJob): boolean {
  1037. if (!job.storedSessionID) return false
  1038. const state = this.store.snapshot()
  1039. if ((state.chatRecoveryDisabledUntil ?? 0) > this.now()) return false
  1040. if (state.ambiguousChatSessions[job.storedSessionID] !== undefined) return false
  1041. const owners = new Set(state.chatJobs
  1042. .filter(item => item.storedSessionID === job.storedSessionID)
  1043. .map(item => item.localUserID))
  1044. return owners.size === 1 && owners.has(job.localUserID)
  1045. }
  1046. promptDigest(userIdValue: string, prompt: string): string {
  1047. const userId = requiredIdentifier(userIdValue, 'userId')
  1048. const normalized = prompt.trim().replace(/\r\n/g, '\n')
  1049. if (!normalized || normalized.length > 200_000) throw new Error('prompt is invalid')
  1050. if (this.correlationError) throw new Error(this.correlationError)
  1051. return createHmac('sha256', this.loadCorrelationSecret())
  1052. .update(userId, 'utf8')
  1053. .update('\0', 'utf8')
  1054. .update(normalized, 'utf8')
  1055. .digest('base64url')
  1056. }
  1057. completeChatJob(jobId: string): void
  1058. completeChatJob(userId: string, jobId: string): void
  1059. completeChatJob(userOrJobId: string, optionalJobId?: string): void {
  1060. const jobId = requiredIdentifier(optionalJobId ?? userOrJobId, 'jobId', 512)
  1061. const userId = optionalJobId ? requiredIdentifier(userOrJobId, 'userId') : undefined
  1062. this.store.mutate(state => {
  1063. state.chatJobs = state.chatJobs.filter(item => !(item.id === jobId && (!userId || item.localUserID === userId)))
  1064. })
  1065. }
  1066. removeUser(userIdValue: string): { registrations: number; subscriptions: number; pending: number } {
  1067. const userId = requiredIdentifier(userIdValue, 'userId')
  1068. const prefix = dedupePrefix(userId)
  1069. return this.store.mutate(state => {
  1070. const registrations = state.installations.filter(item => item.userId === userId).length
  1071. const subscriptions = state.groupSubscriptions.filter(item => item.userId === userId).length
  1072. const pending = state.outbox.filter(item => item.userId === userId).length
  1073. state.installations = state.installations.filter(item => item.userId !== userId)
  1074. state.groupSubscriptions = state.groupSubscriptions.filter(item => item.userId !== userId)
  1075. state.outbox = state.outbox.filter(item => item.userId !== userId)
  1076. state.chatJobs = state.chatJobs.filter(item => item.localUserID !== userId)
  1077. for (const key of Object.keys(state.processedEvents)) {
  1078. if (key.startsWith(prefix)) delete state.processedEvents[key]
  1079. }
  1080. return { registrations, subscriptions, pending }
  1081. })
  1082. }
  1083. enqueueNotification(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
  1084. const userId = requiredIdentifier(input.localUserID, 'localUserID')
  1085. const eventId = requiredIdentifier(input.eventID, 'eventID', 512)
  1086. if (this.store.snapshot().processedEvents[dedupeKey(userId, eventId)] !== undefined) return 'duplicate'
  1087. const count = this.enqueue({
  1088. eventId,
  1089. userId,
  1090. kind: input.kind,
  1091. title: input.title,
  1092. body: input.body,
  1093. data: input.data,
  1094. collapseId: input.collapseID,
  1095. ...(input.roomID ? { roomId: input.roomID } : {}),
  1096. ...((input.topicID || input.roomID) ? { threadId: input.topicID ?? input.roomID } : {}),
  1097. })
  1098. return count > 0 ? 'enqueued' : 'ignored'
  1099. }
  1100. enqueueGroupEvent(input: PushNotificationCandidate & { roomID: string }): 'enqueued' | 'duplicate' | 'ignored' {
  1101. if (!this.isGroupSubscribed(input.localUserID, input.roomID)) return 'ignored'
  1102. return this.enqueueNotification(input)
  1103. }
  1104. enqueueChatCandidate(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
  1105. return this.enqueueNotification(input)
  1106. }
  1107. enqueue(input: PushNotification): number {
  1108. if (!this.isAnyProviderEnabled()) return 0
  1109. const eventId = requiredIdentifier(input.eventId, 'eventId', 512)
  1110. const userId = requiredIdentifier(input.userId, 'userId')
  1111. try {
  1112. if (!this.isUserActive(userId)) return 0
  1113. } catch {
  1114. return 0
  1115. }
  1116. if (!PUSH_EVENT_KINDS.includes(input.kind)) throw new Error('kind is invalid')
  1117. const title = notificationPlainText(input.title, { fallback: '新消息', maximum: 120 })
  1118. const body = notificationPlainText(input.body, {
  1119. fallback: '请打开夭夭查看详情', maximum: MAX_SUMMARY_CHARACTERS,
  1120. })
  1121. const clientAccountId = input.clientAccountId
  1122. ? requiredIdentifier(input.clientAccountId, 'clientAccountId')
  1123. : undefined
  1124. const roomId = input.roomId ? requiredIdentifier(input.roomId, 'roomId') : undefined
  1125. const collapseId = input.collapseId ? requiredIdentifier(input.collapseId, 'collapseId', 64) : undefined
  1126. if (collapseId && Buffer.byteLength(collapseId, 'utf8') > 64) throw new Error('collapseId is too long')
  1127. const threadId = input.threadId ? requiredIdentifier(input.threadId, 'threadId', 512) : undefined
  1128. let data: Record<string, unknown> = {}
  1129. if (input.data) {
  1130. const encoded = JSON.stringify(input.data)
  1131. if (encoded === undefined) throw new Error('data must be JSON serializable')
  1132. data = JSON.parse(encoded) as Record<string, unknown>
  1133. }
  1134. const now = this.now()
  1135. const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
  1136. if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
  1137. const capabilities = this.capabilities()
  1138. const allowedEnvironments = new Set(capabilities.environments)
  1139. const queued = this.store.mutate(state => {
  1140. for (const [key, processedAt] of Object.entries(state.processedEvents)) {
  1141. if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
  1142. }
  1143. const processed = Object.entries(state.processedEvents)
  1144. if (processed.length >= MAX_PROCESSED_EVENTS) {
  1145. processed.sort((left, right) => left[1] - right[1])
  1146. for (const [expiredKey] of processed.slice(0, processed.length - MAX_PROCESSED_EVENTS + 1)) {
  1147. delete state.processedEvents[expiredKey]
  1148. }
  1149. }
  1150. const key = dedupeKey(userId, eventId)
  1151. if (state.processedEvents[key] !== undefined) return 0
  1152. const targets = state.installations.filter(item => item.userId === userId
  1153. && this.registrationIsAuthorized(item)
  1154. && (item.platform === 'ios'
  1155. ? capabilities.platforms.ios.enabled && allowedEnvironments.has(item.environment)
  1156. : capabilities.platforms.android.enabled)
  1157. && (!clientAccountId || item.clientAccountId === clientAccountId))
  1158. if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
  1159. state.processedEvents[key] = now
  1160. for (const target of targets) {
  1161. target.badge += 1
  1162. state.outbox.push({
  1163. id: randomUUID(),
  1164. eventId,
  1165. userId,
  1166. installationId: target.installationId,
  1167. clientAccountId: target.clientAccountId,
  1168. platform: target.platform,
  1169. kind: input.kind,
  1170. title,
  1171. body,
  1172. data,
  1173. ...(collapseId ? { collapseId } : {}),
  1174. ...(threadId ? { threadId } : {}),
  1175. ...(roomId ? { roomId } : {}),
  1176. createdAt: now,
  1177. expiresAt,
  1178. attempts: 0,
  1179. nextAttemptAt: now,
  1180. })
  1181. }
  1182. return targets.length
  1183. })
  1184. this.scheduleNextFlush()
  1185. return queued
  1186. }
  1187. flushDue(): Promise<PushFlushResult> {
  1188. if (this.activeFlush) return this.activeFlush
  1189. if (this.closed || !this.isAnyProviderEnabled()) {
  1190. return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
  1191. }
  1192. const generation = this.providerGeneration
  1193. this.activeFlush = this.flushInternal(generation)
  1194. .then(result => {
  1195. if (generation === this.providerGeneration) {
  1196. for (const platform of ['ios', 'android'] as const) {
  1197. this.flushFailureCount[platform] = 0
  1198. this.flushBlockedUntil[platform] = 0
  1199. delete this.runtimeErrors[platform]
  1200. }
  1201. }
  1202. return result
  1203. })
  1204. .catch(cause => {
  1205. if (generation === this.providerGeneration) {
  1206. for (const platform of ['ios', 'android'] as const) {
  1207. if (!this.platformEnabled(platform)) continue
  1208. this.flushFailureCount[platform] += 1
  1209. const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount[platform] - 1, 6)))
  1210. this.flushBlockedUntil[platform] = this.now() + delay
  1211. this.recordRuntimeError(platform, cause)
  1212. }
  1213. }
  1214. throw cause
  1215. })
  1216. .finally(() => {
  1217. this.activeFlush = undefined
  1218. this.scheduleNextFlush()
  1219. })
  1220. return this.activeFlush
  1221. }
  1222. private async flushInternal(generation: number): Promise<PushFlushResult> {
  1223. const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
  1224. const due = this.store.snapshot().outbox
  1225. .filter(item => item.nextAttemptAt <= this.now()
  1226. && this.platformEnabled(item.platform)
  1227. && this.flushBlockedUntil[item.platform] <= this.now())
  1228. .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
  1229. .slice(0, 100)
  1230. for (const candidate of due) {
  1231. if (this.closed) break
  1232. const state = this.store.snapshot()
  1233. const item = state.outbox.find(current => current.id === candidate.id)
  1234. if (!item) continue
  1235. let userActive = false
  1236. try { userActive = this.isUserActive(item.userId) } catch { userActive = false }
  1237. if (!userActive) {
  1238. this.store.mutate(current => {
  1239. current.installations = current.installations.filter(entry => entry.userId !== item.userId)
  1240. current.groupSubscriptions = current.groupSubscriptions.filter(entry => entry.userId !== item.userId)
  1241. current.outbox = current.outbox.filter(entry => entry.userId !== item.userId)
  1242. current.chatJobs = current.chatJobs.filter(entry => entry.localUserID !== item.userId)
  1243. })
  1244. summary.failed += 1
  1245. continue
  1246. }
  1247. const installation = state.installations.find(current => current.userId === item.userId
  1248. && current.installationId === item.installationId
  1249. && current.clientAccountId === item.clientAccountId)
  1250. if (installation && !this.registrationIsAuthorized(installation)) {
  1251. this.store.mutate(current => {
  1252. current.installations = current.installations.filter(entry => !(entry.userId === item.userId
  1253. && entry.installationId === item.installationId
  1254. && entry.clientAccountId === item.clientAccountId))
  1255. current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
  1256. && entry.installationId === item.installationId
  1257. && entry.clientAccountId === item.clientAccountId))
  1258. })
  1259. summary.failed += 1
  1260. continue
  1261. }
  1262. if (installation && installation.platform !== item.platform) {
  1263. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1264. summary.failed += 1
  1265. continue
  1266. }
  1267. if (installation?.platform === 'ios'
  1268. && !this.capabilities().environments.includes(installation.environment)) {
  1269. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1270. summary.failed += 1
  1271. continue
  1272. }
  1273. const subscribed = !item.roomId || state.groupSubscriptions.some(subscription => subscription.userId === item.userId
  1274. && subscription.roomId === item.roomId
  1275. && subscription.enabled)
  1276. if (!installation || !subscribed || item.expiresAt <= this.now()) {
  1277. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1278. summary.failed += 1
  1279. continue
  1280. }
  1281. let result: APNsSendResult
  1282. try {
  1283. result = installation.platform === 'ios'
  1284. ? await this.provider!.send({
  1285. deviceToken: installation.deviceToken,
  1286. environment: installation.environment,
  1287. payload: {
  1288. ...item.data,
  1289. aps: {
  1290. alert: { title: item.title, body: item.body },
  1291. sound: 'default',
  1292. badge: installation.badge,
  1293. ...(item.threadId ? { 'thread-id': item.threadId } : {}),
  1294. },
  1295. version: 1,
  1296. eventId: item.eventId,
  1297. kind: item.kind,
  1298. clientAccountId: item.clientAccountId,
  1299. },
  1300. apnsId: item.id,
  1301. ...(item.collapseId ? { collapseId: item.collapseId } : {}),
  1302. expiration: Math.floor(item.expiresAt / 1_000),
  1303. })
  1304. : await this.fcmProvider!.send({
  1305. fid: installation.fid,
  1306. data: this.fcmData(item),
  1307. ttlSeconds: Math.max(0, Math.ceil((item.expiresAt - this.now()) / 1_000)),
  1308. priority: 'high',
  1309. })
  1310. } catch (cause) {
  1311. // Provider implementations normally return a classified result, but a
  1312. // programming/runtime exception in one sender must not starve the
  1313. // other platform's durable queue.
  1314. result = {
  1315. disposition: 'retry',
  1316. status: 0,
  1317. reason: safeProviderException(cause, installation),
  1318. }
  1319. }
  1320. const at = new Date(this.now()).toISOString()
  1321. if (generation !== this.providerGeneration) {
  1322. if (result.disposition === 'success') {
  1323. this.store.mutate(current => {
  1324. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1325. current.providerStatus[item.platform].lastSuccessAt = at
  1326. })
  1327. summary.delivered += 1
  1328. } else {
  1329. this.store.mutate(current => {
  1330. const pending = current.outbox.find(entry => entry.id === item.id)
  1331. if (pending) pending.nextAttemptAt = this.now()
  1332. })
  1333. summary.retried += 1
  1334. }
  1335. break
  1336. }
  1337. if (result.disposition === 'success') {
  1338. this.store.mutate(current => {
  1339. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1340. current.providerStatus[item.platform].lastSuccessAt = at
  1341. })
  1342. summary.delivered += 1
  1343. } else if (result.disposition === 'unregister') {
  1344. let registrationRotated = false
  1345. this.store.mutate(current => {
  1346. const latest = current.installations.find(entry => entry.userId === item.userId
  1347. && entry.installationId === item.installationId
  1348. && entry.clientAccountId === item.clientAccountId)
  1349. registrationRotated = Boolean(latest && this.registrationIdentity(latest) !== this.registrationIdentity(installation))
  1350. if (registrationRotated) {
  1351. const pending = current.outbox.find(entry => entry.id === item.id)
  1352. if (pending) {
  1353. pending.attempts = 0
  1354. pending.nextAttemptAt = this.now()
  1355. }
  1356. return
  1357. }
  1358. current.installations = current.installations.filter(entry => !(entry.userId === item.userId
  1359. && entry.installationId === item.installationId
  1360. && entry.clientAccountId === item.clientAccountId))
  1361. current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
  1362. && entry.installationId === item.installationId
  1363. && entry.clientAccountId === item.clientAccountId))
  1364. })
  1365. if (registrationRotated) summary.retried += 1
  1366. else summary.removedRegistrations += 1
  1367. } else if (result.disposition === 'retry' || result.disposition === 'configuration') {
  1368. const attempts = item.attempts + 1
  1369. const exhausted = attempts >= this.maxAttempts || item.expiresAt <= this.now()
  1370. const delay = Math.min(
  1371. this.maxRetryMilliseconds,
  1372. Math.max(result.retryAfterMs ?? 0, this.baseRetryMilliseconds * (2 ** Math.max(0, attempts - 1))),
  1373. )
  1374. this.store.mutate(current => {
  1375. const pending = current.outbox.find(entry => entry.id === item.id)
  1376. if (pending) {
  1377. if (exhausted) current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1378. else {
  1379. pending.attempts = attempts
  1380. pending.nextAttemptAt = this.now() + delay
  1381. }
  1382. }
  1383. current.providerStatus[item.platform].lastError = safeError(result, item.platform)
  1384. current.providerStatus[item.platform].lastErrorAt = at
  1385. })
  1386. if (exhausted) summary.failed += 1
  1387. else summary.retried += 1
  1388. } else {
  1389. this.store.mutate(current => {
  1390. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1391. current.providerStatus[item.platform].lastError = safeError(result, item.platform)
  1392. current.providerStatus[item.platform].lastErrorAt = at
  1393. })
  1394. summary.failed += 1
  1395. }
  1396. }
  1397. return summary
  1398. }
  1399. private platformEnabled(platform: PushPlatform): boolean {
  1400. const capabilities = this.capabilities()
  1401. return platform === 'ios' ? capabilities.platforms.ios.enabled : capabilities.platforms.android.enabled
  1402. }
  1403. private registrationIdentity(installation: InstallationRecord): string {
  1404. return installation.platform === 'ios'
  1405. ? `ios:${installation.environment}:${installation.deviceToken}`
  1406. : `android:${installation.fid}`
  1407. }
  1408. private fcmData(item: OutboxRecord): Record<string, string> {
  1409. const data: Record<string, string> = {}
  1410. for (const [key, value] of Object.entries(item.data)) {
  1411. if (value === undefined || value === null) continue
  1412. if (typeof value === 'string') data[key] = value
  1413. else if (typeof value === 'number' || typeof value === 'boolean') data[key] = String(value)
  1414. else data[key] = JSON.stringify(value)
  1415. }
  1416. return {
  1417. ...data,
  1418. version: '1',
  1419. eventId: item.eventId,
  1420. kind: item.kind,
  1421. clientAccountId: item.clientAccountId,
  1422. title: item.title,
  1423. body: item.body,
  1424. ...(item.collapseId ? { collapseId: item.collapseId } : {}),
  1425. ...(item.threadId ? { threadId: item.threadId } : {}),
  1426. ...(item.roomId ? { roomId: item.roomId } : {}),
  1427. }
  1428. }
  1429. private scheduleNextFlush(): void {
  1430. if (!this.autoFlush || this.closed || !this.isAnyProviderEnabled() || this.activeFlush) return
  1431. if (this.timer) clearTimeout(this.timer)
  1432. this.timer = undefined
  1433. const next = this.store.snapshot().outbox.reduce<number | undefined>((earliest, item) => {
  1434. if (!this.platformEnabled(item.platform)) return earliest
  1435. const attemptAt = Math.max(item.nextAttemptAt, this.flushBlockedUntil[item.platform])
  1436. return earliest === undefined || attemptAt < earliest ? attemptAt : earliest
  1437. }, undefined)
  1438. if (next === undefined) return
  1439. const delay = Math.max(0, Math.min(2_147_483_647, next - this.now()))
  1440. this.timer = setTimeout(() => {
  1441. this.timer = undefined
  1442. void this.flushDue().catch(() => undefined)
  1443. }, delay)
  1444. this.timer.unref()
  1445. }
  1446. close(): void {
  1447. this.closed = true
  1448. if (this.timer) clearTimeout(this.timer)
  1449. this.timer = undefined
  1450. this.provider?.close?.()
  1451. this.fcmProvider?.close?.()
  1452. this.enabledListeners.clear()
  1453. }
  1454. private recordRuntimeError(platform: PushPlatform, cause: unknown): void {
  1455. const raw = cause instanceof Error ? cause.message : String(cause)
  1456. const message = raw
  1457. .replace(/[a-fA-F0-9]{32,}/g, '[redacted]')
  1458. .replace(/[\r\n]+/g, ' ')
  1459. .slice(0, 500) || '推送后台任务失败'
  1460. const at = new Date(this.now()).toISOString()
  1461. this.runtimeErrors[platform] = { message, at }
  1462. try {
  1463. this.store.mutate(state => {
  1464. state.providerStatus[platform].lastError = message
  1465. state.providerStatus[platform].lastErrorAt = at
  1466. })
  1467. } catch {
  1468. // The in-memory status remains available when the push state volume is
  1469. // the reason persistence failed. Never let optional push stop 15300.
  1470. }
  1471. }
  1472. private registrationIsAuthorized(installation: InstallationRecord): boolean {
  1473. if (!this.userAuthorizationVersion) return true
  1474. try {
  1475. const current = this.userAuthorizationVersion(installation.userId)
  1476. return current !== undefined && installation.authorizationVersion === current
  1477. } catch {
  1478. return false
  1479. }
  1480. }
  1481. private markPersistedJobsDisconnected(): void {
  1482. try {
  1483. const jobs = this.store.snapshot().chatJobs
  1484. if (!jobs.some(job => !Number.isFinite(Number(job.metadata?.disconnectedAt)))) return
  1485. const disconnectedAt = this.now()
  1486. this.store.mutate(state => {
  1487. state.chatJobs = state.chatJobs.map(job => ({
  1488. ...job,
  1489. metadata: {
  1490. ...job.metadata,
  1491. disconnectedAt: Number.isFinite(Number(job.metadata?.disconnectedAt))
  1492. ? job.metadata!.disconnectedAt
  1493. : disconnectedAt,
  1494. recoveredAfterRestart: true,
  1495. },
  1496. }))
  1497. })
  1498. } catch {
  1499. // A corrupt/unwritable optional push state already disables recovery.
  1500. }
  1501. }
  1502. private loadCorrelationSecret(): Buffer {
  1503. if (this.correlationSecret) return this.correlationSecret
  1504. const path = join(this.options.home, 'push', 'correlation.key')
  1505. try {
  1506. const existing = readFileSync(path)
  1507. if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
  1508. chmodSync(path, 0o600)
  1509. this.correlationSecret = existing
  1510. return existing
  1511. } catch (cause) {
  1512. if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause
  1513. }
  1514. mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  1515. const generated = randomBytes(32)
  1516. let descriptor: number | undefined
  1517. try {
  1518. descriptor = openSync(path, 'wx', 0o600)
  1519. writeFileSync(descriptor, generated)
  1520. fsyncSync(descriptor)
  1521. closeSync(descriptor)
  1522. descriptor = undefined
  1523. this.correlationSecret = generated
  1524. return generated
  1525. } catch (cause) {
  1526. if (descriptor !== undefined) closeSync(descriptor)
  1527. if ((cause as NodeJS.ErrnoException).code === 'EEXIST') {
  1528. const existing = readFileSync(path)
  1529. if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
  1530. this.correlationSecret = existing
  1531. return existing
  1532. }
  1533. throw cause
  1534. }
  1535. }
  1536. }