opencode-oncall 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +151 -0
  2. package/README.md +50 -0
  3. package/dist/common-settings-actions.d.ts +15 -0
  4. package/dist/common-settings-actions.js +48 -0
  5. package/dist/common-settings-store.d.ts +1 -0
  6. package/dist/common-settings-store.js +1 -0
  7. package/dist/index.d.ts +1 -0
  8. package/dist/index.js +1 -0
  9. package/dist/plugin-hooks.d.ts +51 -0
  10. package/dist/plugin-hooks.js +288 -0
  11. package/dist/plugin.d.ts +10 -0
  12. package/dist/plugin.js +115 -0
  13. package/dist/settings-store.d.ts +50 -0
  14. package/dist/settings-store.js +214 -0
  15. package/dist/store-paths.d.ts +16 -0
  16. package/dist/store-paths.js +61 -0
  17. package/dist/ui/wechat-menu.d.ts +26 -0
  18. package/dist/ui/wechat-menu.js +90 -0
  19. package/dist/wechat/bind-flow.d.ts +29 -0
  20. package/dist/wechat/bind-flow.js +207 -0
  21. package/dist/wechat/bridge.d.ts +136 -0
  22. package/dist/wechat/bridge.js +1059 -0
  23. package/dist/wechat/broker-client.d.ts +23 -0
  24. package/dist/wechat/broker-client.js +274 -0
  25. package/dist/wechat/broker-endpoint.d.ts +21 -0
  26. package/dist/wechat/broker-endpoint.js +78 -0
  27. package/dist/wechat/broker-entry.d.ts +123 -0
  28. package/dist/wechat/broker-entry.js +1321 -0
  29. package/dist/wechat/broker-launcher.d.ts +37 -0
  30. package/dist/wechat/broker-launcher.js +418 -0
  31. package/dist/wechat/broker-mutation-queue.d.ts +93 -0
  32. package/dist/wechat/broker-mutation-queue.js +126 -0
  33. package/dist/wechat/broker-server.d.ts +86 -0
  34. package/dist/wechat/broker-server.js +1340 -0
  35. package/dist/wechat/broker-state-store.d.ts +335 -0
  36. package/dist/wechat/broker-state-store.js +1964 -0
  37. package/dist/wechat/command-parser.d.ts +18 -0
  38. package/dist/wechat/command-parser.js +58 -0
  39. package/dist/wechat/compat/jiti-loader.d.ts +27 -0
  40. package/dist/wechat/compat/jiti-loader.js +118 -0
  41. package/dist/wechat/compat/openclaw-account-helpers.d.ts +29 -0
  42. package/dist/wechat/compat/openclaw-account-helpers.js +60 -0
  43. package/dist/wechat/compat/openclaw-bind-helpers.d.ts +29 -0
  44. package/dist/wechat/compat/openclaw-bind-helpers.js +169 -0
  45. package/dist/wechat/compat/openclaw-guided-smoke.d.ts +180 -0
  46. package/dist/wechat/compat/openclaw-guided-smoke.js +1134 -0
  47. package/dist/wechat/compat/openclaw-public-entry.d.ts +33 -0
  48. package/dist/wechat/compat/openclaw-public-entry.js +62 -0
  49. package/dist/wechat/compat/openclaw-public-helpers.d.ts +70 -0
  50. package/dist/wechat/compat/openclaw-public-helpers.js +68 -0
  51. package/dist/wechat/compat/openclaw-qr-gateway.d.ts +15 -0
  52. package/dist/wechat/compat/openclaw-qr-gateway.js +39 -0
  53. package/dist/wechat/compat/openclaw-smoke.d.ts +48 -0
  54. package/dist/wechat/compat/openclaw-smoke.js +100 -0
  55. package/dist/wechat/compat/openclaw-sync-buf.d.ts +24 -0
  56. package/dist/wechat/compat/openclaw-sync-buf.js +80 -0
  57. package/dist/wechat/compat/openclaw-updates-send.d.ts +47 -0
  58. package/dist/wechat/compat/openclaw-updates-send.js +38 -0
  59. package/dist/wechat/compat/qrcode-terminal-loader.d.ts +12 -0
  60. package/dist/wechat/compat/qrcode-terminal-loader.js +16 -0
  61. package/dist/wechat/compat/slash-guard.d.ts +11 -0
  62. package/dist/wechat/compat/slash-guard.js +24 -0
  63. package/dist/wechat/dead-letter-store.d.ts +48 -0
  64. package/dist/wechat/dead-letter-store.js +224 -0
  65. package/dist/wechat/debug-bundle-collector.d.ts +49 -0
  66. package/dist/wechat/debug-bundle-collector.js +580 -0
  67. package/dist/wechat/debug-bundle-flow.d.ts +37 -0
  68. package/dist/wechat/debug-bundle-flow.js +180 -0
  69. package/dist/wechat/debug-bundle-redaction.d.ts +14 -0
  70. package/dist/wechat/debug-bundle-redaction.js +339 -0
  71. package/dist/wechat/handle.d.ts +10 -0
  72. package/dist/wechat/handle.js +57 -0
  73. package/dist/wechat/ipc-auth.d.ts +6 -0
  74. package/dist/wechat/ipc-auth.js +39 -0
  75. package/dist/wechat/latest-account-state-store.d.ts +8 -0
  76. package/dist/wechat/latest-account-state-store.js +38 -0
  77. package/dist/wechat/notification-dispatcher.d.ts +34 -0
  78. package/dist/wechat/notification-dispatcher.js +266 -0
  79. package/dist/wechat/notification-format.d.ts +15 -0
  80. package/dist/wechat/notification-format.js +196 -0
  81. package/dist/wechat/notification-store.d.ts +72 -0
  82. package/dist/wechat/notification-store.js +807 -0
  83. package/dist/wechat/notification-types.d.ts +37 -0
  84. package/dist/wechat/notification-types.js +1 -0
  85. package/dist/wechat/openclaw-account-adapter.d.ts +30 -0
  86. package/dist/wechat/openclaw-account-adapter.js +60 -0
  87. package/dist/wechat/operator-store.d.ts +9 -0
  88. package/dist/wechat/operator-store.js +69 -0
  89. package/dist/wechat/protocol.d.ts +150 -0
  90. package/dist/wechat/protocol.js +197 -0
  91. package/dist/wechat/question-interaction.d.ts +24 -0
  92. package/dist/wechat/question-interaction.js +180 -0
  93. package/dist/wechat/request-store.d.ts +108 -0
  94. package/dist/wechat/request-store.js +669 -0
  95. package/dist/wechat/session-digest.d.ts +50 -0
  96. package/dist/wechat/session-digest.js +167 -0
  97. package/dist/wechat/state-paths.d.ts +26 -0
  98. package/dist/wechat/state-paths.js +92 -0
  99. package/dist/wechat/status-format.d.ts +26 -0
  100. package/dist/wechat/status-format.js +616 -0
  101. package/dist/wechat/token-store.d.ts +20 -0
  102. package/dist/wechat/token-store.js +193 -0
  103. package/dist/wechat/wechat-status-runtime.d.ts +89 -0
  104. package/dist/wechat/wechat-status-runtime.js +518 -0
  105. package/package.json +74 -0
@@ -0,0 +1,807 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { normalizeHandle } from "./handle.js";
4
+ import { normalizeRequestPromptSummary } from "./question-interaction.js";
5
+ import { findRequestByRouteKey } from "./request-store.js";
6
+ import { ensureWechatStateLayout, notificationStatePath, notificationsDir, WECHAT_FILE_MODE, } from "./state-paths.js";
7
+ let notificationStoreTestHooks;
8
+ function isNonEmptyString(value) {
9
+ return typeof value === "string" && value.trim().length > 0;
10
+ }
11
+ function isFiniteNumber(value) {
12
+ return typeof value === "number" && Number.isFinite(value);
13
+ }
14
+ function isNotificationKind(value) {
15
+ return (value === "question" ||
16
+ value === "permission" ||
17
+ value === "sessionError" ||
18
+ value === "requestTerminal" ||
19
+ value === "naturalStop");
20
+ }
21
+ function isRequestNotificationKind(value) {
22
+ return value === "question" || value === "permission";
23
+ }
24
+ function isRequestTerminalReason(value) {
25
+ return (value === "answered" ||
26
+ value === "handled" ||
27
+ value === "rejected" ||
28
+ value === "expired" ||
29
+ value === "replaced");
30
+ }
31
+ function isNaturalStopTerminalReason(value) {
32
+ return value === "replied" || value === "continued" || value === "expired";
33
+ }
34
+ function isSessionReplyTarget(value) {
35
+ return (typeof value === "object" &&
36
+ value !== null &&
37
+ isNonEmptyString(value.instanceID) &&
38
+ isNonEmptyString(value.sessionID));
39
+ }
40
+ function isNotificationStatus(value) {
41
+ return ["pending", "sent", "resolved", "failed", "suppressed"].includes(value);
42
+ }
43
+ function normalizeLookupValue(value) {
44
+ return value.trim().toLowerCase();
45
+ }
46
+ const DEFAULT_NOTIFICATION_MERGE_WINDOW_MS = 2_000;
47
+ function normalizeRecord(input) {
48
+ const base = {
49
+ idempotencyKey: input.idempotencyKey,
50
+ kind: input.kind,
51
+ wechatAccountId: input.wechatAccountId,
52
+ userId: input.userId,
53
+ ...(isNonEmptyString(input.registrationEpoch)
54
+ ? { registrationEpoch: input.registrationEpoch }
55
+ : {}),
56
+ createdAt: input.createdAt,
57
+ status: input.status,
58
+ ...(typeof input.sentAt === "number" ? { sentAt: input.sentAt } : {}),
59
+ ...(typeof input.resolvedAt === "number"
60
+ ? { resolvedAt: input.resolvedAt }
61
+ : {}),
62
+ ...(typeof input.failedAt === "number" ? { failedAt: input.failedAt } : {}),
63
+ ...(typeof input.suppressedAt === "number"
64
+ ? { suppressedAt: input.suppressedAt }
65
+ : {}),
66
+ ...(isNonEmptyString(input.failureReason)
67
+ ? { failureReason: input.failureReason }
68
+ : {}),
69
+ };
70
+ if (input.kind === "sessionError") {
71
+ return {
72
+ ...base,
73
+ ...(isNonEmptyString(input.sessionID)
74
+ ? { sessionID: input.sessionID.trim() }
75
+ : {}),
76
+ ...(isNonEmptyString(input.action)
77
+ ? { action: input.action.trim() }
78
+ : {}),
79
+ ...(isNonEmptyString(input.redactedSummary)
80
+ ? { redactedSummary: input.redactedSummary.trim() }
81
+ : {}),
82
+ ...(isNonEmptyString(input.severityAdvice)
83
+ ? { severityAdvice: input.severityAdvice.trim() }
84
+ : {}),
85
+ ...(input.source === "retryError" ? { source: input.source } : {}),
86
+ };
87
+ }
88
+ if (input.kind === "naturalStop") {
89
+ return {
90
+ ...base,
91
+ ...(isNonEmptyString(input.handle)
92
+ ? { handle: normalizeHandle(input.handle) }
93
+ : {}),
94
+ ...(isNonEmptyString(input.scopeKey) ? { scopeKey: input.scopeKey } : {}),
95
+ ...(isNonEmptyString(input.sessionID)
96
+ ? { sessionID: input.sessionID.trim() }
97
+ : {}),
98
+ ...(isNonEmptyString(input.redactedSummary)
99
+ ? { redactedSummary: input.redactedSummary.trim() }
100
+ : {}),
101
+ ...(isNonEmptyString(input.severityAdvice)
102
+ ? { severityAdvice: input.severityAdvice.trim() }
103
+ : {}),
104
+ ...(isSessionReplyTarget(input.replyTarget)
105
+ ? {
106
+ replyTarget: {
107
+ instanceID: input.replyTarget.instanceID.trim(),
108
+ sessionID: input.replyTarget.sessionID.trim(),
109
+ },
110
+ }
111
+ : {}),
112
+ ...(isNaturalStopTerminalReason(input.naturalStopTerminalReason)
113
+ ? { naturalStopTerminalReason: input.naturalStopTerminalReason }
114
+ : {}),
115
+ };
116
+ }
117
+ return {
118
+ ...base,
119
+ ...(isNonEmptyString(input.routeKey) ? { routeKey: input.routeKey } : {}),
120
+ ...(isNonEmptyString(input.handle) ? { handle: input.handle } : {}),
121
+ ...(isNonEmptyString(input.scopeKey) ? { scopeKey: input.scopeKey } : {}),
122
+ ...(isRequestNotificationKind(input.kind) && input.prompt !== undefined
123
+ ? { prompt: normalizeRequestPromptSummary(input.kind, input.prompt) }
124
+ : {}),
125
+ ...(input.kind === "requestTerminal" &&
126
+ isRequestNotificationKind(input.requestKind)
127
+ ? { requestKind: input.requestKind }
128
+ : {}),
129
+ ...(input.kind === "requestTerminal" &&
130
+ isRequestTerminalReason(input.terminalReason)
131
+ ? { terminalReason: input.terminalReason }
132
+ : {}),
133
+ ...(input.kind === "requestTerminal" &&
134
+ isNonEmptyString(input.replacementHandle)
135
+ ? { replacementHandle: normalizeHandle(input.replacementHandle) }
136
+ : {}),
137
+ };
138
+ }
139
+ function assertValidIdempotencyKey(idempotencyKey) {
140
+ if (!/^[a-z0-9-]+$/.test(idempotencyKey) || idempotencyKey.includes("..")) {
141
+ throw new Error("invalid notification record format");
142
+ }
143
+ }
144
+ function toRecord(input) {
145
+ const parsed = input;
146
+ if (!parsed ||
147
+ !isNonEmptyString(parsed.idempotencyKey) ||
148
+ !isNotificationKind(parsed.kind) ||
149
+ !isNonEmptyString(parsed.wechatAccountId) ||
150
+ !isNonEmptyString(parsed.userId) ||
151
+ (parsed.registrationEpoch !== undefined &&
152
+ !isNonEmptyString(parsed.registrationEpoch)) ||
153
+ !isFiniteNumber(parsed.createdAt) ||
154
+ !isNotificationStatus(parsed.status)) {
155
+ throw new Error("invalid notification record format");
156
+ }
157
+ if ((parsed.sentAt !== undefined && !isFiniteNumber(parsed.sentAt)) ||
158
+ (parsed.resolvedAt !== undefined && !isFiniteNumber(parsed.resolvedAt)) ||
159
+ (parsed.failedAt !== undefined && !isFiniteNumber(parsed.failedAt)) ||
160
+ (parsed.suppressedAt !== undefined &&
161
+ !isFiniteNumber(parsed.suppressedAt)) ||
162
+ (parsed.failureReason !== undefined &&
163
+ !isNonEmptyString(parsed.failureReason))) {
164
+ throw new Error("invalid notification record format");
165
+ }
166
+ if (parsed.kind === "sessionError") {
167
+ if (parsed.routeKey !== undefined ||
168
+ parsed.handle !== undefined ||
169
+ parsed.scopeKey !== undefined ||
170
+ parsed.prompt !== undefined ||
171
+ parsed.replyTarget !== undefined ||
172
+ parsed.naturalStopTerminalReason !== undefined ||
173
+ (parsed.source !== undefined && parsed.source !== "retryError") ||
174
+ parsed.requestKind !== undefined ||
175
+ parsed.terminalReason !== undefined ||
176
+ parsed.replacementHandle !== undefined) {
177
+ throw new Error("invalid notification record format");
178
+ }
179
+ if ((parsed.sessionID !== undefined && !isNonEmptyString(parsed.sessionID)) ||
180
+ (parsed.action !== undefined && !isNonEmptyString(parsed.action)) ||
181
+ (parsed.redactedSummary !== undefined &&
182
+ !isNonEmptyString(parsed.redactedSummary)) ||
183
+ (parsed.severityAdvice !== undefined &&
184
+ !isNonEmptyString(parsed.severityAdvice))) {
185
+ throw new Error("invalid notification record format");
186
+ }
187
+ }
188
+ else if (parsed.kind === "naturalStop") {
189
+ if (parsed.routeKey !== undefined ||
190
+ parsed.prompt !== undefined ||
191
+ parsed.requestKind !== undefined ||
192
+ parsed.terminalReason !== undefined ||
193
+ parsed.replacementHandle !== undefined ||
194
+ parsed.action !== undefined ||
195
+ !isNonEmptyString(parsed.handle) ||
196
+ !isNonEmptyString(parsed.sessionID) ||
197
+ !isSessionReplyTarget(parsed.replyTarget) ||
198
+ !isNonEmptyString(parsed.redactedSummary) ||
199
+ !isNonEmptyString(parsed.severityAdvice) ||
200
+ (parsed.scopeKey !== undefined && !isNonEmptyString(parsed.scopeKey)) ||
201
+ (parsed.naturalStopTerminalReason !== undefined &&
202
+ !isNaturalStopTerminalReason(parsed.naturalStopTerminalReason))) {
203
+ throw new Error("invalid notification record format");
204
+ }
205
+ }
206
+ else if (isRequestNotificationKind(parsed.kind)) {
207
+ if (!isNonEmptyString(parsed.routeKey) ||
208
+ !isNonEmptyString(parsed.handle)) {
209
+ throw new Error("invalid notification record format");
210
+ }
211
+ if (parsed.scopeKey !== undefined && !isNonEmptyString(parsed.scopeKey)) {
212
+ throw new Error("invalid notification record format");
213
+ }
214
+ if (parsed.prompt !== undefined) {
215
+ normalizeRequestPromptSummary(parsed.kind, parsed.prompt);
216
+ }
217
+ if (parsed.requestKind !== undefined ||
218
+ parsed.terminalReason !== undefined ||
219
+ parsed.replacementHandle !== undefined ||
220
+ parsed.sessionID !== undefined ||
221
+ parsed.action !== undefined ||
222
+ parsed.redactedSummary !== undefined ||
223
+ parsed.severityAdvice !== undefined ||
224
+ parsed.replyTarget !== undefined ||
225
+ parsed.naturalStopTerminalReason !== undefined) {
226
+ throw new Error("invalid notification record format");
227
+ }
228
+ }
229
+ else {
230
+ if (!isNonEmptyString(parsed.routeKey) ||
231
+ !isNonEmptyString(parsed.handle) ||
232
+ !isRequestNotificationKind(parsed.requestKind) ||
233
+ !isRequestTerminalReason(parsed.terminalReason)) {
234
+ throw new Error("invalid notification record format");
235
+ }
236
+ if (parsed.scopeKey !== undefined && !isNonEmptyString(parsed.scopeKey)) {
237
+ throw new Error("invalid notification record format");
238
+ }
239
+ if (parsed.prompt !== undefined) {
240
+ throw new Error("invalid notification record format");
241
+ }
242
+ if (parsed.sessionID !== undefined ||
243
+ parsed.action !== undefined ||
244
+ parsed.redactedSummary !== undefined ||
245
+ parsed.severityAdvice !== undefined ||
246
+ parsed.replyTarget !== undefined ||
247
+ parsed.naturalStopTerminalReason !== undefined) {
248
+ throw new Error("invalid notification record format");
249
+ }
250
+ if (parsed.replacementHandle !== undefined &&
251
+ !isNonEmptyString(parsed.replacementHandle)) {
252
+ throw new Error("invalid notification record format");
253
+ }
254
+ if (parsed.terminalReason === "replaced") {
255
+ if (!isNonEmptyString(parsed.replacementHandle)) {
256
+ throw new Error("invalid notification record format");
257
+ }
258
+ }
259
+ else if (parsed.replacementHandle !== undefined) {
260
+ throw new Error("invalid notification record format");
261
+ }
262
+ }
263
+ if (parsed.status === "sent" && !isFiniteNumber(parsed.sentAt)) {
264
+ throw new Error("invalid notification record format");
265
+ }
266
+ if (parsed.status === "resolved" && !isFiniteNumber(parsed.resolvedAt)) {
267
+ throw new Error("invalid notification record format");
268
+ }
269
+ if (parsed.status === "failed" &&
270
+ (!isFiniteNumber(parsed.failedAt) ||
271
+ !isNonEmptyString(parsed.failureReason))) {
272
+ throw new Error("invalid notification record format");
273
+ }
274
+ if (parsed.status === "suppressed" && !isFiniteNumber(parsed.suppressedAt)) {
275
+ throw new Error("invalid notification record format");
276
+ }
277
+ return normalizeRecord(parsed);
278
+ }
279
+ async function readNotification(idempotencyKey) {
280
+ try {
281
+ const raw = await readFile(notificationStatePath(idempotencyKey), "utf8");
282
+ const record = toRecord(JSON.parse(raw));
283
+ if (record.idempotencyKey !== idempotencyKey) {
284
+ throw new Error("invalid notification record format");
285
+ }
286
+ return record;
287
+ }
288
+ catch (error) {
289
+ const issue = error;
290
+ if (issue.code === "ENOENT")
291
+ throw error;
292
+ if (error instanceof Error &&
293
+ error.message === "invalid notification record format")
294
+ throw error;
295
+ throw new Error("invalid notification record format");
296
+ }
297
+ }
298
+ export function setNotificationStoreTestHooks(hooks) {
299
+ notificationStoreTestHooks = hooks;
300
+ }
301
+ async function writeNotification(record) {
302
+ await ensureWechatStateLayout();
303
+ const filePath = notificationStatePath(record.idempotencyKey);
304
+ await mkdir(path.dirname(filePath), { recursive: true });
305
+ const normalized = normalizeRecord(record);
306
+ await writeFile(filePath, JSON.stringify(normalized, null, 2), {
307
+ mode: WECHAT_FILE_MODE,
308
+ });
309
+ await notificationStoreTestHooks?.afterWriteNotification?.(normalized);
310
+ return normalized;
311
+ }
312
+ export async function upsertNotification(input, options = {}) {
313
+ if (!isNonEmptyString(input.idempotencyKey) ||
314
+ !isNotificationKind(input.kind) ||
315
+ !isNonEmptyString(input.wechatAccountId) ||
316
+ !isNonEmptyString(input.userId) ||
317
+ !isFiniteNumber(input.createdAt)) {
318
+ throw new Error("invalid notification record format");
319
+ }
320
+ assertValidIdempotencyKey(input.idempotencyKey);
321
+ const initialStatus = options.initialStatus ?? "pending";
322
+ if (initialStatus === "suppressed" && !isFiniteNumber(options.suppressedAt)) {
323
+ throw new Error("invalid notification record format");
324
+ }
325
+ if (input.kind === "sessionError") {
326
+ if (input.routeKey !== undefined ||
327
+ input.handle !== undefined ||
328
+ input.scopeKey !== undefined ||
329
+ input.requestKind !== undefined ||
330
+ input.terminalReason !== undefined ||
331
+ input.replacementHandle !==
332
+ undefined ||
333
+ input.replyTarget !==
334
+ undefined ||
335
+ input
336
+ .naturalStopTerminalReason !== undefined ||
337
+ (input.source !== undefined &&
338
+ input.source !== "retryError")) {
339
+ throw new Error("invalid notification record format");
340
+ }
341
+ const sessionID = input.sessionID;
342
+ const action = input.action;
343
+ const redactedSummary = input
344
+ .redactedSummary;
345
+ const severityAdvice = input
346
+ .severityAdvice;
347
+ if ((sessionID !== undefined && !isNonEmptyString(sessionID)) ||
348
+ (action !== undefined && !isNonEmptyString(action)) ||
349
+ (redactedSummary !== undefined && !isNonEmptyString(redactedSummary)) ||
350
+ (severityAdvice !== undefined && !isNonEmptyString(severityAdvice))) {
351
+ throw new Error("invalid notification record format");
352
+ }
353
+ }
354
+ else if (input.kind === "naturalStop") {
355
+ const handle = input.handle;
356
+ const sessionID = input.sessionID;
357
+ const replyTarget = input.replyTarget;
358
+ const redactedSummary = input
359
+ .redactedSummary;
360
+ const severityAdvice = input
361
+ .severityAdvice;
362
+ if (!isNonEmptyString(handle) ||
363
+ !isNonEmptyString(sessionID) ||
364
+ !isSessionReplyTarget(replyTarget) ||
365
+ !isNonEmptyString(redactedSummary) ||
366
+ !isNonEmptyString(severityAdvice) ||
367
+ input.routeKey !== undefined ||
368
+ input.requestKind !== undefined ||
369
+ input.terminalReason !== undefined ||
370
+ input.replacementHandle !==
371
+ undefined ||
372
+ input.action !== undefined ||
373
+ input
374
+ .naturalStopTerminalReason !== undefined) {
375
+ throw new Error("invalid notification record format");
376
+ }
377
+ if (input.scopeKey !== undefined &&
378
+ !isNonEmptyString(input.scopeKey)) {
379
+ throw new Error("invalid notification record format");
380
+ }
381
+ }
382
+ else if (isRequestNotificationKind(input.kind)) {
383
+ if (!isNonEmptyString(input.routeKey) ||
384
+ !isNonEmptyString(input.handle)) {
385
+ throw new Error("invalid notification record format");
386
+ }
387
+ if (input.scopeKey !== undefined &&
388
+ !isNonEmptyString(input.scopeKey)) {
389
+ throw new Error("invalid notification record format");
390
+ }
391
+ if (input.requestKind !== undefined ||
392
+ input.terminalReason !== undefined ||
393
+ input.replacementHandle !==
394
+ undefined ||
395
+ input.sessionID !== undefined ||
396
+ input.action !== undefined ||
397
+ input.redactedSummary !== undefined ||
398
+ input.severityAdvice !== undefined ||
399
+ input.replyTarget !== undefined ||
400
+ input
401
+ .naturalStopTerminalReason !== undefined) {
402
+ throw new Error("invalid notification record format");
403
+ }
404
+ }
405
+ else if (!isNonEmptyString(input.routeKey) ||
406
+ !isNonEmptyString(input.handle) ||
407
+ !isRequestNotificationKind(input.requestKind) ||
408
+ !isRequestTerminalReason(input.terminalReason)) {
409
+ throw new Error("invalid notification record format");
410
+ }
411
+ else {
412
+ if (input.scopeKey !== undefined &&
413
+ !isNonEmptyString(input.scopeKey)) {
414
+ throw new Error("invalid notification record format");
415
+ }
416
+ if (input.sessionID !== undefined ||
417
+ input.action !== undefined ||
418
+ input.redactedSummary !== undefined ||
419
+ input.severityAdvice !== undefined ||
420
+ input.replyTarget !== undefined ||
421
+ input
422
+ .naturalStopTerminalReason !== undefined) {
423
+ throw new Error("invalid notification record format");
424
+ }
425
+ const replacementHandle = input
426
+ .replacementHandle;
427
+ const terminalReason = input
428
+ .terminalReason;
429
+ if (terminalReason === "replaced") {
430
+ if (!isNonEmptyString(replacementHandle)) {
431
+ throw new Error("invalid notification record format");
432
+ }
433
+ }
434
+ else if (replacementHandle !== undefined) {
435
+ throw new Error("invalid notification record format");
436
+ }
437
+ }
438
+ try {
439
+ const current = await readNotification(input.idempotencyKey);
440
+ return current;
441
+ }
442
+ catch (error) {
443
+ const issue = error;
444
+ if (issue.code !== "ENOENT")
445
+ throw error;
446
+ }
447
+ return writeNotification({
448
+ ...input,
449
+ status: initialStatus,
450
+ ...(initialStatus === "suppressed"
451
+ ? { suppressedAt: options.suppressedAt }
452
+ : {}),
453
+ });
454
+ }
455
+ export async function markNotificationSent(input) {
456
+ if (!isFiniteNumber(input.sentAt) ||
457
+ !isNonEmptyString(input.idempotencyKey)) {
458
+ throw new Error("invalid notification record format");
459
+ }
460
+ assertValidIdempotencyKey(input.idempotencyKey);
461
+ const current = await readNotification(input.idempotencyKey);
462
+ if (current.status !== "pending" && current.status !== "failed") {
463
+ throw new Error("notification is not pending");
464
+ }
465
+ return writeNotification({
466
+ ...current,
467
+ status: "sent",
468
+ sentAt: input.sentAt,
469
+ failedAt: undefined,
470
+ failureReason: undefined,
471
+ });
472
+ }
473
+ export async function markNotificationResolved(input) {
474
+ if (!isFiniteNumber(input.resolvedAt) ||
475
+ !isNonEmptyString(input.idempotencyKey)) {
476
+ throw new Error("invalid notification record format");
477
+ }
478
+ assertValidIdempotencyKey(input.idempotencyKey);
479
+ const current = await readNotification(input.idempotencyKey);
480
+ if (input.suppressed) {
481
+ if (current.status !== "pending" && current.status !== "sent") {
482
+ throw new Error("notification is neither pending nor sent");
483
+ }
484
+ return writeNotification({
485
+ ...current,
486
+ status: "suppressed",
487
+ suppressedAt: input.resolvedAt,
488
+ });
489
+ }
490
+ if (current.status !== "sent") {
491
+ throw new Error("notification is not sent");
492
+ }
493
+ return writeNotification({
494
+ ...current,
495
+ status: "resolved",
496
+ resolvedAt: input.resolvedAt,
497
+ });
498
+ }
499
+ export async function markNotificationFailed(input) {
500
+ if (!isFiniteNumber(input.failedAt) ||
501
+ !isNonEmptyString(input.reason) ||
502
+ !isNonEmptyString(input.idempotencyKey)) {
503
+ throw new Error("invalid notification record format");
504
+ }
505
+ assertValidIdempotencyKey(input.idempotencyKey);
506
+ const current = await readNotification(input.idempotencyKey);
507
+ if (current.status !== "pending") {
508
+ throw new Error("notification is not pending");
509
+ }
510
+ return writeNotification({
511
+ ...current,
512
+ status: "failed",
513
+ failedAt: input.failedAt,
514
+ failureReason: input.reason,
515
+ });
516
+ }
517
+ export async function listPendingNotifications() {
518
+ await ensureWechatStateLayout();
519
+ const files = await readdir(notificationsDir()).catch((error) => {
520
+ if (error.code === "ENOENT")
521
+ return [];
522
+ throw error;
523
+ });
524
+ const pending = [];
525
+ for (const fileName of files) {
526
+ if (!fileName.endsWith(".json"))
527
+ continue;
528
+ const idempotencyKey = fileName.slice(0, -5);
529
+ const record = await readNotification(idempotencyKey);
530
+ if (record.status === "pending") {
531
+ pending.push(record);
532
+ }
533
+ }
534
+ pending.sort((a, b) => a.createdAt - b.createdAt);
535
+ return pending;
536
+ }
537
+ function isMergeableNotificationStatus(status) {
538
+ return status === "pending" || status === "sent";
539
+ }
540
+ export async function findMergeableNotification(input) {
541
+ if ((input.kind !== "question" && input.kind !== "permission") ||
542
+ !isNonEmptyString(input.routeKey) ||
543
+ !isNonEmptyString(input.handle) ||
544
+ !isNonEmptyString(input.scopeKey) ||
545
+ !isFiniteNumber(input.createdAt) ||
546
+ (input.excludeIdempotencyKey !== undefined &&
547
+ !isNonEmptyString(input.excludeIdempotencyKey))) {
548
+ throw new Error("invalid notification record format");
549
+ }
550
+ await ensureWechatStateLayout();
551
+ const files = await readdir(notificationsDir()).catch((error) => {
552
+ if (error.code === "ENOENT")
553
+ return [];
554
+ throw error;
555
+ });
556
+ const expectedRouteKey = normalizeLookupValue(input.routeKey);
557
+ const expectedHandle = normalizeLookupValue(input.handle);
558
+ let mergeable;
559
+ for (const fileName of files) {
560
+ if (!fileName.endsWith(".json"))
561
+ continue;
562
+ const idempotencyKey = fileName.slice(0, -5);
563
+ if (input.excludeIdempotencyKey !== undefined &&
564
+ idempotencyKey === input.excludeIdempotencyKey) {
565
+ continue;
566
+ }
567
+ const record = await readNotification(idempotencyKey);
568
+ if (record.kind !== input.kind ||
569
+ !isMergeableNotificationStatus(record.status))
570
+ continue;
571
+ if (!isNonEmptyString(record.routeKey) ||
572
+ !isNonEmptyString(record.handle) ||
573
+ !isNonEmptyString(record.scopeKey))
574
+ continue;
575
+ if (record.scopeKey !== input.scopeKey)
576
+ continue;
577
+ if (normalizeLookupValue(record.routeKey) !== expectedRouteKey)
578
+ continue;
579
+ if (normalizeLookupValue(record.handle) !== expectedHandle)
580
+ continue;
581
+ if (Math.abs(record.createdAt - input.createdAt) >
582
+ DEFAULT_NOTIFICATION_MERGE_WINDOW_MS)
583
+ continue;
584
+ if (!mergeable || record.createdAt > mergeable.createdAt) {
585
+ mergeable = record;
586
+ }
587
+ }
588
+ return mergeable;
589
+ }
590
+ export async function findSentNotificationByRequest(input) {
591
+ if ((input.kind !== "question" && input.kind !== "permission") ||
592
+ !isNonEmptyString(input.routeKey) ||
593
+ !isNonEmptyString(input.handle)) {
594
+ throw new Error("invalid notification record format");
595
+ }
596
+ await ensureWechatStateLayout();
597
+ const files = await readdir(notificationsDir()).catch((error) => {
598
+ if (error.code === "ENOENT")
599
+ return [];
600
+ throw error;
601
+ });
602
+ const expectedRouteKey = normalizeLookupValue(input.routeKey);
603
+ const expectedHandle = normalizeLookupValue(input.handle);
604
+ for (const fileName of files) {
605
+ if (!fileName.endsWith(".json"))
606
+ continue;
607
+ const idempotencyKey = fileName.slice(0, -5);
608
+ let record = await readNotification(idempotencyKey);
609
+ if (record.kind !== input.kind)
610
+ continue;
611
+ if (!isNonEmptyString(record.routeKey) || !isNonEmptyString(record.handle))
612
+ continue;
613
+ if (normalizeLookupValue(record.routeKey) !== expectedRouteKey)
614
+ continue;
615
+ if (normalizeLookupValue(record.handle) !== expectedHandle)
616
+ continue;
617
+ if (!isNonEmptyString(record.scopeKey)) {
618
+ const request = await findRequestByRouteKey({
619
+ kind: record.kind,
620
+ routeKey: record.routeKey,
621
+ });
622
+ if (isNonEmptyString(request?.scopeKey)) {
623
+ await notificationStoreTestHooks?.beforePersistBackfilledScopeKey?.({
624
+ record,
625
+ scopeKey: request.scopeKey,
626
+ });
627
+ record = await writeNotification({
628
+ ...(await readNotification(idempotencyKey)),
629
+ scopeKey: request.scopeKey,
630
+ });
631
+ }
632
+ }
633
+ if (record.status !== "sent")
634
+ continue;
635
+ return record;
636
+ }
637
+ return undefined;
638
+ }
639
+ function isActiveNaturalStopRecord(record) {
640
+ return (record.kind === "naturalStop" &&
641
+ (record.status === "pending" || record.status === "sent"));
642
+ }
643
+ function isTerminalNaturalStopRecord(record) {
644
+ return (record.kind === "naturalStop" &&
645
+ isNaturalStopTerminalReason(record.naturalStopTerminalReason));
646
+ }
647
+ function isSameReplyTarget(record, replyTarget) {
648
+ return (record.replyTarget?.instanceID === replyTarget.instanceID &&
649
+ record.replyTarget?.sessionID === replyTarget.sessionID);
650
+ }
651
+ async function listAllNotifications() {
652
+ await ensureWechatStateLayout();
653
+ const files = await readdir(notificationsDir()).catch((error) => {
654
+ if (error.code === "ENOENT")
655
+ return [];
656
+ throw error;
657
+ });
658
+ const records = [];
659
+ for (const fileName of files) {
660
+ if (!fileName.endsWith(".json"))
661
+ continue;
662
+ records.push(await readNotification(fileName.slice(0, -5)));
663
+ }
664
+ return records;
665
+ }
666
+ export async function listActiveNaturalStops(input = {}) {
667
+ if (input.wechatAccountId !== undefined &&
668
+ !isNonEmptyString(input.wechatAccountId)) {
669
+ throw new Error("invalid notification record format");
670
+ }
671
+ if (input.userId !== undefined && !isNonEmptyString(input.userId)) {
672
+ throw new Error("invalid notification record format");
673
+ }
674
+ if (input.scopeKey !== undefined && !isNonEmptyString(input.scopeKey)) {
675
+ throw new Error("invalid notification record format");
676
+ }
677
+ const records = await listAllNotifications();
678
+ return records
679
+ .filter((record) => isActiveNaturalStopRecord(record) &&
680
+ (input.wechatAccountId === undefined ||
681
+ record.wechatAccountId === input.wechatAccountId) &&
682
+ (input.userId === undefined || record.userId === input.userId) &&
683
+ (input.scopeKey === undefined || record.scopeKey === input.scopeKey))
684
+ .sort((left, right) => left.createdAt - right.createdAt);
685
+ }
686
+ export async function listRetainedNaturalStopHandles() {
687
+ const records = await listAllNotifications();
688
+ return records
689
+ .filter((record) => record.kind === "naturalStop" && isNonEmptyString(record.handle))
690
+ .map((record) => record.handle);
691
+ }
692
+ export async function findActiveNaturalStopByReplyTarget(input) {
693
+ if (!isSessionReplyTarget(input.replyTarget)) {
694
+ throw new Error("invalid notification record format");
695
+ }
696
+ if (input.wechatAccountId !== undefined &&
697
+ !isNonEmptyString(input.wechatAccountId)) {
698
+ throw new Error("invalid notification record format");
699
+ }
700
+ if (input.userId !== undefined && !isNonEmptyString(input.userId)) {
701
+ throw new Error("invalid notification record format");
702
+ }
703
+ const records = await listActiveNaturalStops({
704
+ ...(input.wechatAccountId
705
+ ? { wechatAccountId: input.wechatAccountId }
706
+ : {}),
707
+ ...(input.userId ? { userId: input.userId } : {}),
708
+ });
709
+ return records
710
+ .filter((record) => isSameReplyTarget(record, input.replyTarget))
711
+ .sort((left, right) => right.createdAt - left.createdAt)[0];
712
+ }
713
+ export async function findActiveNaturalStopByHandle(input) {
714
+ if (!isNonEmptyString(input.handle)) {
715
+ throw new Error("invalid notification record format");
716
+ }
717
+ const expectedHandle = normalizeLookupValue(normalizeHandle(input.handle));
718
+ const records = await listAllNotifications();
719
+ return records
720
+ .filter((record) => isActiveNaturalStopRecord(record) &&
721
+ isNonEmptyString(record.handle) &&
722
+ normalizeLookupValue(record.handle) === expectedHandle)
723
+ .sort((left, right) => right.createdAt - left.createdAt)[0];
724
+ }
725
+ export async function findTerminalNaturalStopByHandle(input) {
726
+ if (!isNonEmptyString(input.handle)) {
727
+ throw new Error("invalid notification record format");
728
+ }
729
+ const expectedHandle = normalizeLookupValue(normalizeHandle(input.handle));
730
+ const records = await listAllNotifications();
731
+ return records
732
+ .filter((record) => isTerminalNaturalStopRecord(record) &&
733
+ isNonEmptyString(record.handle) &&
734
+ normalizeLookupValue(record.handle) === expectedHandle)
735
+ .sort((left, right) => right.createdAt - left.createdAt)[0];
736
+ }
737
+ export async function listActiveNaturalStopsForScope(input) {
738
+ if (!isNonEmptyString(input.scopeKey)) {
739
+ throw new Error("invalid notification record format");
740
+ }
741
+ return listActiveNaturalStops({
742
+ scopeKey: input.scopeKey,
743
+ ...(input.wechatAccountId
744
+ ? { wechatAccountId: input.wechatAccountId }
745
+ : {}),
746
+ ...(input.userId ? { userId: input.userId } : {}),
747
+ });
748
+ }
749
+ export async function markNaturalStopTerminal(input) {
750
+ if (!isNonEmptyString(input.idempotencyKey) ||
751
+ !isFiniteNumber(input.resolvedAt) ||
752
+ !isNaturalStopTerminalReason(input.terminalReason)) {
753
+ throw new Error("invalid notification record format");
754
+ }
755
+ assertValidIdempotencyKey(input.idempotencyKey);
756
+ const current = await readNotification(input.idempotencyKey);
757
+ if (current.kind !== "naturalStop") {
758
+ throw new Error("invalid notification record format");
759
+ }
760
+ if (isNaturalStopTerminalReason(current.naturalStopTerminalReason)) {
761
+ return current;
762
+ }
763
+ if (current.status !== "pending" && current.status !== "sent") {
764
+ throw new Error("notification is neither pending nor sent");
765
+ }
766
+ return writeNotification({
767
+ ...current,
768
+ status: "resolved",
769
+ resolvedAt: input.resolvedAt,
770
+ naturalStopTerminalReason: input.terminalReason,
771
+ });
772
+ }
773
+ function terminalAt(record) {
774
+ if (record.status === "resolved")
775
+ return record.resolvedAt;
776
+ if (record.status === "failed")
777
+ return record.failedAt;
778
+ if (record.status === "suppressed")
779
+ return record.suppressedAt;
780
+ return undefined;
781
+ }
782
+ export async function purgeTerminalNotificationsBefore(input) {
783
+ if (!isFiniteNumber(input.cutoffAt)) {
784
+ throw new Error("invalid notification record format");
785
+ }
786
+ await ensureWechatStateLayout();
787
+ const files = await readdir(notificationsDir()).catch((error) => {
788
+ if (error.code === "ENOENT")
789
+ return [];
790
+ throw error;
791
+ });
792
+ let deleted = 0;
793
+ for (const fileName of files) {
794
+ if (!fileName.endsWith(".json"))
795
+ continue;
796
+ const idempotencyKey = fileName.slice(0, -5);
797
+ const record = await readNotification(idempotencyKey);
798
+ const at = terminalAt(record);
799
+ if (typeof at !== "number")
800
+ continue;
801
+ if (at >= input.cutoffAt)
802
+ continue;
803
+ await rm(notificationStatePath(idempotencyKey), { force: true });
804
+ deleted += 1;
805
+ }
806
+ return deleted;
807
+ }