pushCoordinator.ts 65 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650
  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. // Reinstalls and the native-to-Expo migration can create a new local ID for
  765. // the same APNs/FCM endpoint. Keep one registration per user/device/provider
  766. // environment so a single event cannot produce two background banners.
  767. const superseded = state.installations.filter(item => item.userId === userId
  768. && !(item.installationId === installationId && item.clientAccountId === clientAccountId)
  769. && (target.platform === 'ios'
  770. ? item.platform === 'ios' && item.deviceToken === target.deviceToken && item.environment === target.environment
  771. : item.platform === 'android' && item.fid === target.fid))
  772. if (superseded.length) {
  773. state.installations = state.installations.filter(item => !superseded.includes(item))
  774. state.outbox = state.outbox.filter(item => !superseded.some(old => old.userId === item.userId
  775. && old.installationId === item.installationId && old.clientAccountId === item.clientAccountId))
  776. }
  777. const existingIndex = state.installations.findIndex(item => item.userId === userId
  778. && item.installationId === installationId
  779. && item.clientAccountId === clientAccountId)
  780. if (existingIndex >= 0) {
  781. const existing = state.installations[existingIndex]!
  782. const base = {
  783. userId,
  784. installationId,
  785. clientAccountId,
  786. ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
  787. ...(appVersion ? { appVersion } : {}),
  788. badge: existing.badge,
  789. updatedAt,
  790. }
  791. const next: InstallationRecord = target.platform === 'android'
  792. ? { ...base, ...target }
  793. : { ...base, ...target }
  794. state.installations[existingIndex] = next
  795. if (existing.platform !== next.platform) {
  796. state.outbox = state.outbox.filter(item => !(item.userId === userId
  797. && item.installationId === installationId
  798. && item.clientAccountId === clientAccountId))
  799. }
  800. return publicInstallation(next)
  801. }
  802. if (state.installations.filter(item => item.userId === userId).length >= MAX_INSTALLATIONS_PER_USER) {
  803. throw new Error('Push installation limit reached')
  804. }
  805. const base = {
  806. userId,
  807. installationId,
  808. clientAccountId,
  809. ...(authorizationVersion !== undefined ? { authorizationVersion } : {}),
  810. ...(appVersion ? { appVersion } : {}),
  811. badge: 0,
  812. updatedAt,
  813. }
  814. const record: InstallationRecord = target.platform === 'android'
  815. ? { ...base, ...target }
  816. : { ...base, ...target }
  817. state.installations.push(record)
  818. return publicInstallation(record)
  819. })
  820. this.scheduleNextFlush()
  821. return installation
  822. }
  823. unregisterInstallation(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): boolean {
  824. const userId = requiredIdentifier(userIdValue, 'userId')
  825. const installationId = requiredIdentifier(installationIdValue, 'installationId')
  826. const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
  827. return this.store.mutate(state => {
  828. const before = state.installations.length
  829. state.installations = state.installations.filter(item => !(item.userId === userId
  830. && item.installationId === installationId
  831. && item.clientAccountId === clientAccountId))
  832. state.outbox = state.outbox.filter(item => !(item.userId === userId
  833. && item.installationId === installationId
  834. && item.clientAccountId === clientAccountId))
  835. return state.installations.length !== before
  836. })
  837. }
  838. private unreadCounter?: (owner: string) => number
  839. setUnreadCounter(counter: (owner: string) => number): void { this.unreadCounter = counter }
  840. resetBadge(userIdValue: string, installationIdValue: string, clientAccountIdValue: string): number {
  841. const userId = requiredIdentifier(userIdValue, 'userId')
  842. const installationId = requiredIdentifier(installationIdValue, 'installationId')
  843. const clientAccountId = requiredIdentifier(clientAccountIdValue, 'clientAccountId')
  844. return this.store.mutate(state => {
  845. const installation = state.installations.find(item => item.userId === userId
  846. && item.installationId === installationId
  847. && item.clientAccountId === clientAccountId)
  848. const count = this.unreadCounter?.(userId) ?? 0
  849. if (installation) installation.badge = count
  850. return count
  851. })
  852. }
  853. listGroupSubscriptions(userIdValue: string): PushGroupSubscription[] {
  854. const userId = requiredIdentifier(userIdValue, 'userId')
  855. return this.store.snapshot().groupSubscriptions
  856. .filter(item => item.userId === userId)
  857. .map(({ roomId, enabled, updatedAt, lastMessageSeq }) => ({
  858. roomId, enabled, updatedAt, ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
  859. }))
  860. .sort((left, right) => left.roomId.localeCompare(right.roomId))
  861. }
  862. setGroupSubscription(
  863. userIdValue: string,
  864. roomIdValue: string,
  865. enabled: boolean,
  866. baselineMessageSeq?: number,
  867. ): PushGroupSubscription {
  868. const userId = requiredIdentifier(userIdValue, 'userId')
  869. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  870. if (typeof enabled !== 'boolean') throw new Error('enabled must be a boolean')
  871. if (baselineMessageSeq !== undefined && (!Number.isSafeInteger(baselineMessageSeq) || baselineMessageSeq < 0)) {
  872. throw new Error('baselineMessageSeq is invalid')
  873. }
  874. const updatedAt = new Date(this.now()).toISOString()
  875. return this.store.mutate(state => {
  876. const existing = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)
  877. if (existing) {
  878. if (!enabled) {
  879. state.groupSubscriptions = state.groupSubscriptions.filter(item => !(item.userId === userId && item.roomId === roomId))
  880. state.outbox = state.outbox.filter(item => !(item.userId === userId && item.roomId === roomId))
  881. return { roomId, enabled: false, updatedAt }
  882. }
  883. if (existing.enabled === enabled) {
  884. return {
  885. roomId, enabled: existing.enabled, updatedAt: existing.updatedAt,
  886. ...(existing.lastMessageSeq !== undefined ? { lastMessageSeq: existing.lastMessageSeq } : {}),
  887. }
  888. }
  889. existing.enabled = enabled
  890. existing.updatedAt = updatedAt
  891. if (enabled && baselineMessageSeq !== undefined) existing.lastMessageSeq = baselineMessageSeq
  892. } else {
  893. if (!enabled) return { roomId, enabled: false, updatedAt }
  894. const count = state.groupSubscriptions.filter(item => item.userId === userId && item.enabled).length
  895. if (count >= MAX_GROUP_SUBSCRIPTIONS_PER_USER) throw new Error('Group push subscription limit reached')
  896. state.groupSubscriptions.push({
  897. userId, roomId, enabled, updatedAt,
  898. ...(enabled && baselineMessageSeq !== undefined ? { lastMessageSeq: baselineMessageSeq } : {}),
  899. })
  900. }
  901. const saved = state.groupSubscriptions.find(item => item.userId === userId && item.roomId === roomId)!
  902. return {
  903. roomId, enabled, updatedAt,
  904. ...(saved.lastMessageSeq !== undefined ? { lastMessageSeq: saved.lastMessageSeq } : {}),
  905. }
  906. })
  907. }
  908. isGroupSubscribed(userIdValue: string, roomIdValue: string): boolean {
  909. const userId = requiredIdentifier(userIdValue, 'userId')
  910. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  911. return this.store.snapshot().groupSubscriptions.some(item => item.userId === userId
  912. && item.roomId === roomId
  913. && item.enabled)
  914. }
  915. groupSubscriptionStartedAt(userIdValue: string, roomIdValue: string): number | undefined {
  916. const userId = requiredIdentifier(userIdValue, 'userId')
  917. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  918. const subscription = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  919. && item.roomId === roomId
  920. && item.enabled)
  921. if (!subscription) return undefined
  922. const value = Date.parse(subscription.updatedAt)
  923. return Number.isNaN(value) ? undefined : value
  924. }
  925. groupSubscriptionLastMessageSeq(userIdValue: string, roomIdValue: string): number | undefined {
  926. const userId = requiredIdentifier(userIdValue, 'userId')
  927. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  928. return this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  929. && item.roomId === roomId
  930. && item.enabled)?.lastMessageSeq
  931. }
  932. advanceGroupSubscriptionMessageSeq(userIdValue: string, roomIdValue: string, sequence: number): void {
  933. const userId = requiredIdentifier(userIdValue, 'userId')
  934. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  935. if (!Number.isSafeInteger(sequence) || sequence < 0) throw new Error('message sequence is invalid')
  936. const snapshot = this.store.snapshot().groupSubscriptions.find(item => item.userId === userId
  937. && item.roomId === roomId
  938. && item.enabled)
  939. if (!snapshot || (snapshot.lastMessageSeq ?? -1) >= sequence) return
  940. this.store.mutate(state => {
  941. const subscription = state.groupSubscriptions.find(item => item.userId === userId
  942. && item.roomId === roomId
  943. && item.enabled)
  944. if (subscription && (subscription.lastMessageSeq ?? -1) < sequence) subscription.lastMessageSeq = sequence
  945. })
  946. }
  947. subscribedUserIds(roomIdValue: string): string[] {
  948. const roomId = requiredIdentifier(roomIdValue, 'roomId')
  949. return [...new Set(this.store.snapshot().groupSubscriptions
  950. .filter(item => item.roomId === roomId && item.enabled)
  951. .map(item => item.userId))]
  952. }
  953. activeGroupSubscriptions(): Array<PushGroupSubscription & { userId: string }> {
  954. return this.store.snapshot().groupSubscriptions
  955. .filter(item => item.enabled)
  956. .map(({ userId, roomId, enabled, updatedAt, lastMessageSeq }) => ({
  957. userId, roomId, enabled, updatedAt,
  958. ...(lastMessageSeq !== undefined ? { lastMessageSeq } : {}),
  959. }))
  960. .sort((left, right) => left.roomId.localeCompare(right.roomId) || left.userId.localeCompare(right.userId))
  961. }
  962. groupSubscribers(roomIdValue: string): readonly string[] {
  963. return this.subscribedUserIds(roomIdValue)
  964. }
  965. groupWatchAnchor(): PushGroupWatchAnchor | undefined {
  966. const anchor = this.store.snapshot().groupWatch
  967. return anchor ? { ...anchor } : undefined
  968. }
  969. resetGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  970. const next = canonicalGroupWatchAnchor(anchor, this.now())
  971. return this.store.mutate(state => {
  972. state.groupWatch = next
  973. return { ...next }
  974. })
  975. }
  976. resetGroupCursor(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  977. return this.resetGroupWatch(anchor)
  978. }
  979. advanceGroupWatch(anchor: PushGroupWatchAnchor): PushGroupWatchAnchor {
  980. const next = canonicalGroupWatchAnchor(anchor, this.now())
  981. return this.store.mutate(state => {
  982. if (state.groupWatch && state.groupWatch.epoch !== next.epoch) {
  983. throw new Error('group watch epoch changed without reset')
  984. }
  985. if (state.groupWatch && next.cursor < state.groupWatch.cursor) {
  986. throw new Error('group watch cursor cannot move backwards')
  987. }
  988. state.groupWatch = next
  989. return { ...next }
  990. })
  991. }
  992. advanceGroupCursor(anchor: PushGroupWatchAnchor): void {
  993. this.advanceGroupWatch(anchor)
  994. }
  995. upsertChatJob(input: PushChatJob): PushChatJob {
  996. const job = canonicalChatJob(input)
  997. return this.store.mutate(state => {
  998. const existingIndex = state.chatJobs.findIndex(item => item.id === job.id)
  999. if (existingIndex >= 0 && state.chatJobs[existingIndex]!.localUserID !== job.localUserID) {
  1000. throw new Error('job.id is already owned by another user')
  1001. }
  1002. if (existingIndex >= 0) state.chatJobs[existingIndex] = job
  1003. else {
  1004. if (state.chatJobs.filter(item => item.localUserID === job.localUserID).length >= MAX_CHAT_JOBS_PER_USER) {
  1005. throw new Error('Chat push job limit reached')
  1006. }
  1007. state.chatJobs.push(job)
  1008. }
  1009. if (job.storedSessionID) {
  1010. for (const [sessionID, observedAt] of Object.entries(state.ambiguousChatSessions)) {
  1011. if (!Number.isFinite(observedAt) || observedAt < this.now() - AMBIGUOUS_CHAT_SESSION_TTL_MS) {
  1012. delete state.ambiguousChatSessions[sessionID]
  1013. }
  1014. }
  1015. const owners = new Set(state.chatJobs
  1016. .filter(item => item.storedSessionID === job.storedSessionID)
  1017. .map(item => item.localUserID))
  1018. if (owners.size > 1) {
  1019. const entries = Object.entries(state.ambiguousChatSessions)
  1020. if (entries.length >= MAX_AMBIGUOUS_CHAT_SESSIONS
  1021. && state.ambiguousChatSessions[job.storedSessionID] === undefined) {
  1022. state.chatRecoveryDisabledUntil = this.now() + AMBIGUOUS_CHAT_SESSION_TTL_MS
  1023. } else {
  1024. state.ambiguousChatSessions[job.storedSessionID] = this.now()
  1025. }
  1026. }
  1027. }
  1028. return { ...job }
  1029. })
  1030. }
  1031. saveChatJob(input: PushChatJob): void {
  1032. this.upsertChatJob(input)
  1033. }
  1034. listChatJobs(userIdValue?: string): PushChatJob[] {
  1035. const userId = userIdValue ? requiredIdentifier(userIdValue, 'userId') : undefined
  1036. const now = this.now()
  1037. return this.store.snapshot().chatJobs
  1038. .filter(item => item.expiresAt > now && (!userId || item.localUserID === userId))
  1039. .sort((left, right) => left.submittedAt - right.submittedAt)
  1040. .map(item => ({ ...item, ...(item.metadata ? { metadata: { ...item.metadata } } : {}) }))
  1041. }
  1042. pendingChatJobs(): readonly PushChatJob[] {
  1043. const now = this.now()
  1044. const snapshot = this.store.snapshot()
  1045. if (snapshot.chatJobs.some(item => item.expiresAt <= now)) {
  1046. this.store.mutate(state => {
  1047. state.chatJobs = state.chatJobs.filter(item => item.expiresAt > now)
  1048. })
  1049. }
  1050. return this.listChatJobs()
  1051. }
  1052. chatJobRecoveryAllowed(job: PushChatJob): boolean {
  1053. if (!job.storedSessionID) return false
  1054. const state = this.store.snapshot()
  1055. if ((state.chatRecoveryDisabledUntil ?? 0) > this.now()) return false
  1056. if (state.ambiguousChatSessions[job.storedSessionID] !== undefined) return false
  1057. const owners = new Set(state.chatJobs
  1058. .filter(item => item.storedSessionID === job.storedSessionID)
  1059. .map(item => item.localUserID))
  1060. return owners.size === 1 && owners.has(job.localUserID)
  1061. }
  1062. promptDigest(userIdValue: string, prompt: string): string {
  1063. const userId = requiredIdentifier(userIdValue, 'userId')
  1064. const normalized = prompt.trim().replace(/\r\n/g, '\n')
  1065. if (!normalized || normalized.length > 200_000) throw new Error('prompt is invalid')
  1066. if (this.correlationError) throw new Error(this.correlationError)
  1067. return createHmac('sha256', this.loadCorrelationSecret())
  1068. .update(userId, 'utf8')
  1069. .update('\0', 'utf8')
  1070. .update(normalized, 'utf8')
  1071. .digest('base64url')
  1072. }
  1073. completeChatJob(jobId: string): void
  1074. completeChatJob(userId: string, jobId: string): void
  1075. completeChatJob(userOrJobId: string, optionalJobId?: string): void {
  1076. const jobId = requiredIdentifier(optionalJobId ?? userOrJobId, 'jobId', 512)
  1077. const userId = optionalJobId ? requiredIdentifier(userOrJobId, 'userId') : undefined
  1078. this.store.mutate(state => {
  1079. state.chatJobs = state.chatJobs.filter(item => !(item.id === jobId && (!userId || item.localUserID === userId)))
  1080. })
  1081. }
  1082. removeUser(userIdValue: string): { registrations: number; subscriptions: number; pending: number } {
  1083. const userId = requiredIdentifier(userIdValue, 'userId')
  1084. const prefix = dedupePrefix(userId)
  1085. return this.store.mutate(state => {
  1086. const registrations = state.installations.filter(item => item.userId === userId).length
  1087. const subscriptions = state.groupSubscriptions.filter(item => item.userId === userId).length
  1088. const pending = state.outbox.filter(item => item.userId === userId).length
  1089. state.installations = state.installations.filter(item => item.userId !== userId)
  1090. state.groupSubscriptions = state.groupSubscriptions.filter(item => item.userId !== userId)
  1091. state.outbox = state.outbox.filter(item => item.userId !== userId)
  1092. state.chatJobs = state.chatJobs.filter(item => item.localUserID !== userId)
  1093. for (const key of Object.keys(state.processedEvents)) {
  1094. if (key.startsWith(prefix)) delete state.processedEvents[key]
  1095. }
  1096. return { registrations, subscriptions, pending }
  1097. })
  1098. }
  1099. enqueueNotification(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
  1100. const userId = requiredIdentifier(input.localUserID, 'localUserID')
  1101. const eventId = requiredIdentifier(input.eventID, 'eventID', 512)
  1102. if (this.store.snapshot().processedEvents[dedupeKey(userId, eventId)] !== undefined) return 'duplicate'
  1103. const count = this.enqueue({
  1104. eventId,
  1105. userId,
  1106. kind: input.kind,
  1107. title: input.title,
  1108. body: input.body,
  1109. data: input.data,
  1110. collapseId: input.collapseID,
  1111. ...(input.roomID ? { roomId: input.roomID } : {}),
  1112. ...((input.topicID || input.roomID) ? { threadId: input.topicID ?? input.roomID } : {}),
  1113. })
  1114. return count > 0 ? 'enqueued' : 'ignored'
  1115. }
  1116. enqueueGroupEvent(input: PushNotificationCandidate & { roomID: string }): 'enqueued' | 'duplicate' | 'ignored' {
  1117. if (!this.isGroupSubscribed(input.localUserID, input.roomID)) return 'ignored'
  1118. return this.enqueueNotification(input)
  1119. }
  1120. enqueueChatCandidate(input: PushNotificationCandidate): 'enqueued' | 'duplicate' | 'ignored' {
  1121. return this.enqueueNotification(input)
  1122. }
  1123. enqueue(input: PushNotification): number {
  1124. if (!this.isAnyProviderEnabled()) return 0
  1125. const eventId = requiredIdentifier(input.eventId, 'eventId', 512)
  1126. const userId = requiredIdentifier(input.userId, 'userId')
  1127. try {
  1128. if (!this.isUserActive(userId)) return 0
  1129. } catch {
  1130. return 0
  1131. }
  1132. if (!PUSH_EVENT_KINDS.includes(input.kind)) throw new Error('kind is invalid')
  1133. const title = notificationPlainText(input.title, { fallback: '新消息', maximum: 120 })
  1134. const body = notificationPlainText(input.body, {
  1135. fallback: '请打开夭夭查看详情', maximum: MAX_SUMMARY_CHARACTERS,
  1136. })
  1137. const clientAccountId = input.clientAccountId
  1138. ? requiredIdentifier(input.clientAccountId, 'clientAccountId')
  1139. : undefined
  1140. const roomId = input.roomId ? requiredIdentifier(input.roomId, 'roomId') : undefined
  1141. const collapseId = input.collapseId ? requiredIdentifier(input.collapseId, 'collapseId', 64) : undefined
  1142. if (collapseId && Buffer.byteLength(collapseId, 'utf8') > 64) throw new Error('collapseId is too long')
  1143. const threadId = input.threadId ? requiredIdentifier(input.threadId, 'threadId', 512) : undefined
  1144. let data: Record<string, unknown> = {}
  1145. if (input.data) {
  1146. const encoded = JSON.stringify(input.data)
  1147. if (encoded === undefined) throw new Error('data must be JSON serializable')
  1148. data = JSON.parse(encoded) as Record<string, unknown>
  1149. }
  1150. const now = this.now()
  1151. const expiresAt = input.expiresAt ?? now + DEFAULT_EVENT_LIFETIME_MS
  1152. if (!Number.isFinite(expiresAt) || expiresAt <= now) throw new Error('expiresAt must be in the future')
  1153. const capabilities = this.capabilities()
  1154. const allowedEnvironments = new Set(capabilities.environments)
  1155. const queued = this.store.mutate(state => {
  1156. for (const [key, processedAt] of Object.entries(state.processedEvents)) {
  1157. if (!Number.isFinite(processedAt) || processedAt < now - DEDUPE_LIFETIME_MS) delete state.processedEvents[key]
  1158. }
  1159. const processed = Object.entries(state.processedEvents)
  1160. if (processed.length >= MAX_PROCESSED_EVENTS) {
  1161. processed.sort((left, right) => left[1] - right[1])
  1162. for (const [expiredKey] of processed.slice(0, processed.length - MAX_PROCESSED_EVENTS + 1)) {
  1163. delete state.processedEvents[expiredKey]
  1164. }
  1165. }
  1166. const key = dedupeKey(userId, eventId)
  1167. if (state.processedEvents[key] !== undefined) return 0
  1168. const targets = state.installations.filter(item => item.userId === userId
  1169. && this.registrationIsAuthorized(item)
  1170. && (item.platform === 'ios'
  1171. ? capabilities.platforms.ios.enabled && allowedEnvironments.has(item.environment)
  1172. : capabilities.platforms.android.enabled)
  1173. && (!clientAccountId || item.clientAccountId === clientAccountId))
  1174. if (state.outbox.length + targets.length > MAX_OUTBOX_ITEMS) throw new Error('Push outbox limit reached')
  1175. state.processedEvents[key] = now
  1176. for (const target of targets) {
  1177. target.badge = this.unreadCounter ? this.unreadCounter(userId) : target.badge + 1
  1178. state.outbox.push({
  1179. id: randomUUID(),
  1180. eventId,
  1181. userId,
  1182. installationId: target.installationId,
  1183. clientAccountId: target.clientAccountId,
  1184. platform: target.platform,
  1185. kind: input.kind,
  1186. title,
  1187. body,
  1188. data,
  1189. ...(collapseId ? { collapseId } : {}),
  1190. ...(threadId ? { threadId } : {}),
  1191. ...(roomId ? { roomId } : {}),
  1192. createdAt: now,
  1193. expiresAt,
  1194. attempts: 0,
  1195. nextAttemptAt: now,
  1196. })
  1197. }
  1198. return targets.length
  1199. })
  1200. this.scheduleNextFlush()
  1201. return queued
  1202. }
  1203. flushDue(): Promise<PushFlushResult> {
  1204. if (this.activeFlush) return this.activeFlush
  1205. if (this.closed || !this.isAnyProviderEnabled()) {
  1206. return Promise.resolve({ delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 })
  1207. }
  1208. const generation = this.providerGeneration
  1209. this.activeFlush = this.flushInternal(generation)
  1210. .then(result => {
  1211. if (generation === this.providerGeneration) {
  1212. for (const platform of ['ios', 'android'] as const) {
  1213. this.flushFailureCount[platform] = 0
  1214. this.flushBlockedUntil[platform] = 0
  1215. delete this.runtimeErrors[platform]
  1216. }
  1217. }
  1218. return result
  1219. })
  1220. .catch(cause => {
  1221. if (generation === this.providerGeneration) {
  1222. for (const platform of ['ios', 'android'] as const) {
  1223. if (!this.platformEnabled(platform)) continue
  1224. this.flushFailureCount[platform] += 1
  1225. const delay = Math.min(60_000, 1_000 * (2 ** Math.min(this.flushFailureCount[platform] - 1, 6)))
  1226. this.flushBlockedUntil[platform] = this.now() + delay
  1227. this.recordRuntimeError(platform, cause)
  1228. }
  1229. }
  1230. throw cause
  1231. })
  1232. .finally(() => {
  1233. this.activeFlush = undefined
  1234. this.scheduleNextFlush()
  1235. })
  1236. return this.activeFlush
  1237. }
  1238. private async flushInternal(generation: number): Promise<PushFlushResult> {
  1239. const summary: PushFlushResult = { delivered: 0, retried: 0, removedRegistrations: 0, failed: 0 }
  1240. const due = this.store.snapshot().outbox
  1241. .filter(item => item.nextAttemptAt <= this.now()
  1242. && this.platformEnabled(item.platform)
  1243. && this.flushBlockedUntil[item.platform] <= this.now())
  1244. .sort((left, right) => left.nextAttemptAt - right.nextAttemptAt)
  1245. .slice(0, 100)
  1246. for (const candidate of due) {
  1247. if (this.closed) break
  1248. const state = this.store.snapshot()
  1249. const item = state.outbox.find(current => current.id === candidate.id)
  1250. if (!item) continue
  1251. let userActive = false
  1252. try { userActive = this.isUserActive(item.userId) } catch { userActive = false }
  1253. if (!userActive) {
  1254. this.store.mutate(current => {
  1255. current.installations = current.installations.filter(entry => entry.userId !== item.userId)
  1256. current.groupSubscriptions = current.groupSubscriptions.filter(entry => entry.userId !== item.userId)
  1257. current.outbox = current.outbox.filter(entry => entry.userId !== item.userId)
  1258. current.chatJobs = current.chatJobs.filter(entry => entry.localUserID !== item.userId)
  1259. })
  1260. summary.failed += 1
  1261. continue
  1262. }
  1263. const installation = state.installations.find(current => current.userId === item.userId
  1264. && current.installationId === item.installationId
  1265. && current.clientAccountId === item.clientAccountId)
  1266. if (installation && !this.registrationIsAuthorized(installation)) {
  1267. this.store.mutate(current => {
  1268. current.installations = current.installations.filter(entry => !(entry.userId === item.userId
  1269. && entry.installationId === item.installationId
  1270. && entry.clientAccountId === item.clientAccountId))
  1271. current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
  1272. && entry.installationId === item.installationId
  1273. && entry.clientAccountId === item.clientAccountId))
  1274. })
  1275. summary.failed += 1
  1276. continue
  1277. }
  1278. if (installation && installation.platform !== item.platform) {
  1279. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1280. summary.failed += 1
  1281. continue
  1282. }
  1283. if (installation?.platform === 'ios'
  1284. && !this.capabilities().environments.includes(installation.environment)) {
  1285. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1286. summary.failed += 1
  1287. continue
  1288. }
  1289. const subscribed = !item.roomId || state.groupSubscriptions.some(subscription => subscription.userId === item.userId
  1290. && subscription.roomId === item.roomId
  1291. && subscription.enabled)
  1292. if (!installation || !subscribed || item.expiresAt <= this.now()) {
  1293. this.store.mutate(current => { current.outbox = current.outbox.filter(entry => entry.id !== item.id) })
  1294. summary.failed += 1
  1295. continue
  1296. }
  1297. let result: APNsSendResult
  1298. try {
  1299. result = installation.platform === 'ios'
  1300. ? await this.provider!.send({
  1301. deviceToken: installation.deviceToken,
  1302. environment: installation.environment,
  1303. payload: {
  1304. ...item.data,
  1305. aps: {
  1306. alert: { title: item.title, body: item.body },
  1307. sound: 'default',
  1308. badge: installation.badge,
  1309. ...(item.threadId ? { 'thread-id': item.threadId } : {}),
  1310. },
  1311. version: 1,
  1312. eventId: item.eventId,
  1313. kind: item.kind,
  1314. clientAccountId: item.clientAccountId,
  1315. },
  1316. apnsId: item.id,
  1317. ...(item.collapseId ? { collapseId: item.collapseId } : {}),
  1318. expiration: Math.floor(item.expiresAt / 1_000),
  1319. })
  1320. : await this.fcmProvider!.send({
  1321. fid: installation.fid,
  1322. data: this.fcmData(item),
  1323. ttlSeconds: Math.max(0, Math.ceil((item.expiresAt - this.now()) / 1_000)),
  1324. priority: 'high',
  1325. })
  1326. } catch (cause) {
  1327. // Provider implementations normally return a classified result, but a
  1328. // programming/runtime exception in one sender must not starve the
  1329. // other platform's durable queue.
  1330. result = {
  1331. disposition: 'retry',
  1332. status: 0,
  1333. reason: safeProviderException(cause, installation),
  1334. }
  1335. }
  1336. const at = new Date(this.now()).toISOString()
  1337. if (generation !== this.providerGeneration) {
  1338. if (result.disposition === 'success') {
  1339. this.store.mutate(current => {
  1340. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1341. current.providerStatus[item.platform].lastSuccessAt = at
  1342. })
  1343. summary.delivered += 1
  1344. } else {
  1345. this.store.mutate(current => {
  1346. const pending = current.outbox.find(entry => entry.id === item.id)
  1347. if (pending) pending.nextAttemptAt = this.now()
  1348. })
  1349. summary.retried += 1
  1350. }
  1351. break
  1352. }
  1353. if (result.disposition === 'success') {
  1354. this.store.mutate(current => {
  1355. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1356. current.providerStatus[item.platform].lastSuccessAt = at
  1357. })
  1358. summary.delivered += 1
  1359. } else if (result.disposition === 'unregister') {
  1360. let registrationRotated = false
  1361. this.store.mutate(current => {
  1362. const latest = current.installations.find(entry => entry.userId === item.userId
  1363. && entry.installationId === item.installationId
  1364. && entry.clientAccountId === item.clientAccountId)
  1365. registrationRotated = Boolean(latest && this.registrationIdentity(latest) !== this.registrationIdentity(installation))
  1366. if (registrationRotated) {
  1367. const pending = current.outbox.find(entry => entry.id === item.id)
  1368. if (pending) {
  1369. pending.attempts = 0
  1370. pending.nextAttemptAt = this.now()
  1371. }
  1372. return
  1373. }
  1374. current.installations = current.installations.filter(entry => !(entry.userId === item.userId
  1375. && entry.installationId === item.installationId
  1376. && entry.clientAccountId === item.clientAccountId))
  1377. current.outbox = current.outbox.filter(entry => !(entry.userId === item.userId
  1378. && entry.installationId === item.installationId
  1379. && entry.clientAccountId === item.clientAccountId))
  1380. })
  1381. if (registrationRotated) summary.retried += 1
  1382. else summary.removedRegistrations += 1
  1383. } else if (result.disposition === 'retry' || result.disposition === 'configuration') {
  1384. const attempts = item.attempts + 1
  1385. const exhausted = attempts >= this.maxAttempts || item.expiresAt <= this.now()
  1386. const delay = Math.min(
  1387. this.maxRetryMilliseconds,
  1388. Math.max(result.retryAfterMs ?? 0, this.baseRetryMilliseconds * (2 ** Math.max(0, attempts - 1))),
  1389. )
  1390. this.store.mutate(current => {
  1391. const pending = current.outbox.find(entry => entry.id === item.id)
  1392. if (pending) {
  1393. if (exhausted) current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1394. else {
  1395. pending.attempts = attempts
  1396. pending.nextAttemptAt = this.now() + delay
  1397. }
  1398. }
  1399. current.providerStatus[item.platform].lastError = safeError(result, item.platform)
  1400. current.providerStatus[item.platform].lastErrorAt = at
  1401. })
  1402. if (exhausted) summary.failed += 1
  1403. else summary.retried += 1
  1404. } else {
  1405. this.store.mutate(current => {
  1406. current.outbox = current.outbox.filter(entry => entry.id !== item.id)
  1407. current.providerStatus[item.platform].lastError = safeError(result, item.platform)
  1408. current.providerStatus[item.platform].lastErrorAt = at
  1409. })
  1410. summary.failed += 1
  1411. }
  1412. }
  1413. return summary
  1414. }
  1415. private platformEnabled(platform: PushPlatform): boolean {
  1416. const capabilities = this.capabilities()
  1417. return platform === 'ios' ? capabilities.platforms.ios.enabled : capabilities.platforms.android.enabled
  1418. }
  1419. private registrationIdentity(installation: InstallationRecord): string {
  1420. return installation.platform === 'ios'
  1421. ? `ios:${installation.environment}:${installation.deviceToken}`
  1422. : `android:${installation.fid}`
  1423. }
  1424. private fcmData(item: OutboxRecord): Record<string, string> {
  1425. const data: Record<string, string> = {}
  1426. for (const [key, value] of Object.entries(item.data)) {
  1427. if (value === undefined || value === null) continue
  1428. if (typeof value === 'string') data[key] = value
  1429. else if (typeof value === 'number' || typeof value === 'boolean') data[key] = String(value)
  1430. else data[key] = JSON.stringify(value)
  1431. }
  1432. return {
  1433. ...data,
  1434. version: '1',
  1435. eventId: item.eventId,
  1436. kind: item.kind,
  1437. clientAccountId: item.clientAccountId,
  1438. title: item.title,
  1439. body: item.body,
  1440. ...(item.collapseId ? { collapseId: item.collapseId } : {}),
  1441. ...(item.threadId ? { threadId: item.threadId } : {}),
  1442. ...(item.roomId ? { roomId: item.roomId } : {}),
  1443. }
  1444. }
  1445. private scheduleNextFlush(): void {
  1446. if (!this.autoFlush || this.closed || !this.isAnyProviderEnabled() || this.activeFlush) return
  1447. if (this.timer) clearTimeout(this.timer)
  1448. this.timer = undefined
  1449. const next = this.store.snapshot().outbox.reduce<number | undefined>((earliest, item) => {
  1450. if (!this.platformEnabled(item.platform)) return earliest
  1451. const attemptAt = Math.max(item.nextAttemptAt, this.flushBlockedUntil[item.platform])
  1452. return earliest === undefined || attemptAt < earliest ? attemptAt : earliest
  1453. }, undefined)
  1454. if (next === undefined) return
  1455. const delay = Math.max(0, Math.min(2_147_483_647, next - this.now()))
  1456. this.timer = setTimeout(() => {
  1457. this.timer = undefined
  1458. void this.flushDue().catch(() => undefined)
  1459. }, delay)
  1460. this.timer.unref()
  1461. }
  1462. close(): void {
  1463. this.closed = true
  1464. if (this.timer) clearTimeout(this.timer)
  1465. this.timer = undefined
  1466. this.provider?.close?.()
  1467. this.fcmProvider?.close?.()
  1468. this.enabledListeners.clear()
  1469. }
  1470. private recordRuntimeError(platform: PushPlatform, cause: unknown): void {
  1471. const raw = cause instanceof Error ? cause.message : String(cause)
  1472. const message = raw
  1473. .replace(/[a-fA-F0-9]{32,}/g, '[redacted]')
  1474. .replace(/[\r\n]+/g, ' ')
  1475. .slice(0, 500) || '推送后台任务失败'
  1476. const at = new Date(this.now()).toISOString()
  1477. this.runtimeErrors[platform] = { message, at }
  1478. try {
  1479. this.store.mutate(state => {
  1480. state.providerStatus[platform].lastError = message
  1481. state.providerStatus[platform].lastErrorAt = at
  1482. })
  1483. } catch {
  1484. // The in-memory status remains available when the push state volume is
  1485. // the reason persistence failed. Never let optional push stop 15300.
  1486. }
  1487. }
  1488. private registrationIsAuthorized(installation: InstallationRecord): boolean {
  1489. if (!this.userAuthorizationVersion) return true
  1490. try {
  1491. const current = this.userAuthorizationVersion(installation.userId)
  1492. return current !== undefined && installation.authorizationVersion === current
  1493. } catch {
  1494. return false
  1495. }
  1496. }
  1497. private markPersistedJobsDisconnected(): void {
  1498. try {
  1499. const jobs = this.store.snapshot().chatJobs
  1500. if (!jobs.some(job => !Number.isFinite(Number(job.metadata?.disconnectedAt)))) return
  1501. const disconnectedAt = this.now()
  1502. this.store.mutate(state => {
  1503. state.chatJobs = state.chatJobs.map(job => ({
  1504. ...job,
  1505. metadata: {
  1506. ...job.metadata,
  1507. disconnectedAt: Number.isFinite(Number(job.metadata?.disconnectedAt))
  1508. ? job.metadata!.disconnectedAt
  1509. : disconnectedAt,
  1510. recoveredAfterRestart: true,
  1511. },
  1512. }))
  1513. })
  1514. } catch {
  1515. // A corrupt/unwritable optional push state already disables recovery.
  1516. }
  1517. }
  1518. private loadCorrelationSecret(): Buffer {
  1519. if (this.correlationSecret) return this.correlationSecret
  1520. const path = join(this.options.home, 'push', 'correlation.key')
  1521. try {
  1522. const existing = readFileSync(path)
  1523. if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
  1524. chmodSync(path, 0o600)
  1525. this.correlationSecret = existing
  1526. return existing
  1527. } catch (cause) {
  1528. if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause
  1529. }
  1530. mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  1531. const generated = randomBytes(32)
  1532. let descriptor: number | undefined
  1533. try {
  1534. descriptor = openSync(path, 'wx', 0o600)
  1535. writeFileSync(descriptor, generated)
  1536. fsyncSync(descriptor)
  1537. closeSync(descriptor)
  1538. descriptor = undefined
  1539. this.correlationSecret = generated
  1540. return generated
  1541. } catch (cause) {
  1542. if (descriptor !== undefined) closeSync(descriptor)
  1543. if ((cause as NodeJS.ErrnoException).code === 'EEXIST') {
  1544. const existing = readFileSync(path)
  1545. if (existing.byteLength !== 32) throw new Error('Push correlation key has an invalid length')
  1546. this.correlationSecret = existing
  1547. return existing
  1548. }
  1549. throw cause
  1550. }
  1551. }
  1552. }