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,669 @@
1
+ import path from "node:path";
2
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { WECHAT_FILE_MODE, ensureWechatStateLayout, requestKindDir, requestStatePath, } from "./state-paths.js";
4
+ import { loadBrokerStateStoreForMutation, persistBrokerStateStoreSnapshot, readBrokerIndexedRequest, upsertBrokerIndexedRequest, } from "./broker-state-store.js";
5
+ import { assertValidHandleInput, createHandle, createRouteKey, normalizeHandle } from "./handle.js";
6
+ import { normalizeRequestPromptSummary } from "./question-interaction.js";
7
+ const TERMINAL_REASON_PRIORITY = {
8
+ expired: 1,
9
+ handled: 2,
10
+ rejected: 3,
11
+ answered: 4,
12
+ replaced: 5,
13
+ };
14
+ function isRequestTerminalReason(value) {
15
+ return ["answered", "handled", "rejected", "expired", "replaced"].includes(value);
16
+ }
17
+ function deriveTerminalReason(record) {
18
+ if (record.status === "open") {
19
+ return undefined;
20
+ }
21
+ if (record.terminalReason === "replaced" && isNonEmptyString(record.replacementHandle)) {
22
+ return "replaced";
23
+ }
24
+ if (record.terminalReason === "handled") {
25
+ return "handled";
26
+ }
27
+ if (record.terminalReason === "answered" || record.status === "answered" || typeof record.answeredAt === "number") {
28
+ return "answered";
29
+ }
30
+ if (record.terminalReason === "rejected" || record.status === "rejected" || typeof record.rejectedAt === "number") {
31
+ return "rejected";
32
+ }
33
+ if (record.terminalReason === "expired" || record.status === "expired" || typeof record.expiredAt === "number") {
34
+ return "expired";
35
+ }
36
+ return undefined;
37
+ }
38
+ function normalizeTerminalMetadata(record) {
39
+ const terminalReason = deriveTerminalReason(record);
40
+ const replacementHandle = terminalReason === "replaced" && isNonEmptyString(record.replacementHandle)
41
+ ? normalizeHandle(record.replacementHandle)
42
+ : undefined;
43
+ return {
44
+ ...record,
45
+ terminalReason,
46
+ replacementHandle,
47
+ terminalResultSent: terminalReason ? record.terminalResultSent === true : undefined,
48
+ };
49
+ }
50
+ function selectHigherPriorityTerminalReason(input) {
51
+ const currentReason = input.currentReason === "replaced" && !isNonEmptyString(input.currentReplacementHandle)
52
+ ? undefined
53
+ : input.currentReason;
54
+ const nextReason = input.nextReason === "replaced" && !isNonEmptyString(input.nextReplacementHandle)
55
+ ? undefined
56
+ : input.nextReason;
57
+ if (!currentReason && !nextReason) {
58
+ return {};
59
+ }
60
+ if (!currentReason) {
61
+ return {
62
+ terminalReason: nextReason,
63
+ ...(nextReason === "replaced" && isNonEmptyString(input.nextReplacementHandle)
64
+ ? { replacementHandle: normalizeHandle(input.nextReplacementHandle) }
65
+ : {}),
66
+ };
67
+ }
68
+ if (!nextReason) {
69
+ return {
70
+ terminalReason: currentReason,
71
+ ...(currentReason === "replaced" && isNonEmptyString(input.currentReplacementHandle)
72
+ ? { replacementHandle: normalizeHandle(input.currentReplacementHandle) }
73
+ : {}),
74
+ };
75
+ }
76
+ if (TERMINAL_REASON_PRIORITY[nextReason] > TERMINAL_REASON_PRIORITY[currentReason]) {
77
+ return {
78
+ terminalReason: nextReason,
79
+ ...(nextReason === "replaced" && isNonEmptyString(input.nextReplacementHandle)
80
+ ? { replacementHandle: normalizeHandle(input.nextReplacementHandle) }
81
+ : {}),
82
+ };
83
+ }
84
+ return {
85
+ terminalReason: currentReason,
86
+ ...(currentReason === "replaced" && isNonEmptyString(input.currentReplacementHandle)
87
+ ? { replacementHandle: normalizeHandle(input.currentReplacementHandle) }
88
+ : {}),
89
+ };
90
+ }
91
+ function terminalSortTimestamp(record) {
92
+ return record.cleanedAt ?? record.answeredAt ?? record.rejectedAt ?? record.expiredAt ?? record.createdAt;
93
+ }
94
+ function normalizeRecord(input) {
95
+ const normalized = normalizeTerminalMetadata({
96
+ kind: input.kind,
97
+ requestID: input.requestID,
98
+ routeKey: input.routeKey,
99
+ handle: input.handle,
100
+ ...(isNonEmptyString(input.scopeKey) ? { scopeKey: input.scopeKey } : {}),
101
+ ...(input.prompt !== undefined ? { prompt: normalizeRequestPromptSummary(input.kind, input.prompt) } : {}),
102
+ wechatAccountId: input.wechatAccountId,
103
+ userId: input.userId,
104
+ status: input.status,
105
+ createdAt: input.createdAt,
106
+ ...(typeof input.answeredAt === "number" ? { answeredAt: input.answeredAt } : {}),
107
+ ...(typeof input.rejectedAt === "number" ? { rejectedAt: input.rejectedAt } : {}),
108
+ ...(typeof input.expiredAt === "number" ? { expiredAt: input.expiredAt } : {}),
109
+ ...(typeof input.cleanedAt === "number" ? { cleanedAt: input.cleanedAt } : {}),
110
+ ...(isRequestTerminalReason(input.terminalReason) ? { terminalReason: input.terminalReason } : {}),
111
+ ...(isNonEmptyString(input.replacementHandle) ? { replacementHandle: input.replacementHandle } : {}),
112
+ ...(input.terminalResultSent === true ? { terminalResultSent: true } : {}),
113
+ });
114
+ return {
115
+ kind: normalized.kind,
116
+ requestID: normalized.requestID,
117
+ routeKey: normalized.routeKey,
118
+ handle: normalized.handle,
119
+ ...(isNonEmptyString(normalized.scopeKey) ? { scopeKey: normalized.scopeKey } : {}),
120
+ ...(normalized.prompt !== undefined ? { prompt: normalized.prompt } : {}),
121
+ wechatAccountId: normalized.wechatAccountId,
122
+ userId: normalized.userId,
123
+ status: normalized.status,
124
+ createdAt: normalized.createdAt,
125
+ ...(typeof normalized.answeredAt === "number" ? { answeredAt: normalized.answeredAt } : {}),
126
+ ...(typeof normalized.rejectedAt === "number" ? { rejectedAt: normalized.rejectedAt } : {}),
127
+ ...(typeof normalized.expiredAt === "number" ? { expiredAt: normalized.expiredAt } : {}),
128
+ ...(typeof normalized.cleanedAt === "number" ? { cleanedAt: normalized.cleanedAt } : {}),
129
+ ...(isRequestTerminalReason(normalized.terminalReason) ? { terminalReason: normalized.terminalReason } : {}),
130
+ ...(isNonEmptyString(normalized.replacementHandle) ? { replacementHandle: normalized.replacementHandle } : {}),
131
+ ...(isRequestTerminalReason(normalized.terminalReason)
132
+ ? { terminalResultSent: normalized.terminalResultSent === true }
133
+ : {}),
134
+ };
135
+ }
136
+ function isRequestStatus(value) {
137
+ return ["open", "answered", "rejected", "expired", "cleaned"].includes(value);
138
+ }
139
+ function isRequestKind(value) {
140
+ return value === "question" || value === "permission";
141
+ }
142
+ function isNonEmptyString(value) {
143
+ return typeof value === "string" && value.trim().length > 0;
144
+ }
145
+ function isFiniteNumber(value) {
146
+ return typeof value === "number" && Number.isFinite(value);
147
+ }
148
+ function normalizeRequestIdentity(value) {
149
+ return value.trim().toLowerCase();
150
+ }
151
+ function isSameOpenIdentity(current, input) {
152
+ return (normalizeRequestIdentity(current.requestID) === normalizeRequestIdentity(input.requestID)
153
+ && current.wechatAccountId === input.wechatAccountId
154
+ && current.userId === input.userId);
155
+ }
156
+ function assertValidRouteKey(routeKey) {
157
+ if (!/^[a-z0-9-]+$/.test(routeKey) || routeKey.includes("..")) {
158
+ throw new Error("invalid routeKey format");
159
+ }
160
+ }
161
+ function toRequestRecord(input) {
162
+ const parsed = input;
163
+ if (!parsed ||
164
+ !isRequestKind(parsed.kind) ||
165
+ !isNonEmptyString(parsed.requestID) ||
166
+ !isNonEmptyString(parsed.routeKey) ||
167
+ !isNonEmptyString(parsed.handle) ||
168
+ (parsed.scopeKey !== undefined && !isNonEmptyString(parsed.scopeKey)) ||
169
+ !isNonEmptyString(parsed.wechatAccountId) ||
170
+ !isNonEmptyString(parsed.userId) ||
171
+ !isFiniteNumber(parsed.createdAt) ||
172
+ !isRequestStatus(parsed.status)) {
173
+ throw new Error("invalid request record format");
174
+ }
175
+ if (parsed.prompt !== undefined) {
176
+ normalizeRequestPromptSummary(parsed.kind, parsed.prompt);
177
+ }
178
+ if ((parsed.answeredAt !== undefined && !isFiniteNumber(parsed.answeredAt)) ||
179
+ (parsed.rejectedAt !== undefined && !isFiniteNumber(parsed.rejectedAt)) ||
180
+ (parsed.expiredAt !== undefined && !isFiniteNumber(parsed.expiredAt)) ||
181
+ (parsed.cleanedAt !== undefined && !isFiniteNumber(parsed.cleanedAt)) ||
182
+ (parsed.terminalReason !== undefined && !isRequestTerminalReason(parsed.terminalReason)) ||
183
+ (parsed.replacementHandle !== undefined && !isNonEmptyString(parsed.replacementHandle)) ||
184
+ (parsed.terminalResultSent !== undefined && parsed.terminalResultSent !== true && parsed.terminalResultSent !== false)) {
185
+ throw new Error("invalid request record format");
186
+ }
187
+ return normalizeRecord(parsed);
188
+ }
189
+ async function readRequest(kind, routeKey) {
190
+ try {
191
+ const raw = await readFile(requestStatePath(kind, routeKey), "utf8");
192
+ const record = toRequestRecord(JSON.parse(raw));
193
+ if (record.kind !== kind) {
194
+ throw new Error("invalid request record format");
195
+ }
196
+ return record;
197
+ }
198
+ catch (error) {
199
+ const issue = error;
200
+ if (issue.code === "ENOENT")
201
+ throw error;
202
+ if (error instanceof Error && error.message === "invalid request record format")
203
+ throw error;
204
+ throw new Error("invalid request record format");
205
+ }
206
+ }
207
+ async function readRequestIfExists(kind, routeKey) {
208
+ try {
209
+ return await readRequest(kind, routeKey);
210
+ }
211
+ catch (error) {
212
+ const issue = error;
213
+ if (issue.code === "ENOENT") {
214
+ return undefined;
215
+ }
216
+ throw error;
217
+ }
218
+ }
219
+ async function writeRequest(record) {
220
+ await ensureWechatStateLayout();
221
+ const filePath = requestStatePath(record.kind, record.routeKey);
222
+ await mkdir(path.dirname(filePath), { recursive: true });
223
+ const normalized = normalizeRecord(record);
224
+ await writeFile(filePath, JSON.stringify(normalized, null, 2), { mode: WECHAT_FILE_MODE });
225
+ const brokerState = await loadBrokerStateStoreForMutation();
226
+ upsertBrokerIndexedRequest(brokerState, normalized);
227
+ await persistBrokerStateStoreSnapshot(brokerState);
228
+ return normalized;
229
+ }
230
+ async function markTerminalStatus(input) {
231
+ if (!isFiniteNumber(input.at)) {
232
+ throw new Error("invalid request record format");
233
+ }
234
+ const current = await readRequest(input.kind, input.routeKey);
235
+ if (current.status !== "open") {
236
+ throw new Error("request is not open");
237
+ }
238
+ return writeRequest({
239
+ ...current,
240
+ status: input.status,
241
+ [input.atField]: input.at,
242
+ terminalReason: input.status,
243
+ replacementHandle: undefined,
244
+ terminalResultSent: current.terminalResultSent === true,
245
+ });
246
+ }
247
+ export async function upsertRequest(input) {
248
+ if (!isRequestKind(input.kind) ||
249
+ !isNonEmptyString(input.requestID) ||
250
+ !isNonEmptyString(input.routeKey) ||
251
+ !isNonEmptyString(input.wechatAccountId) ||
252
+ !isNonEmptyString(input.userId) ||
253
+ !isFiniteNumber(input.createdAt)) {
254
+ throw new Error("invalid request record format");
255
+ }
256
+ assertValidRouteKey(input.routeKey);
257
+ assertValidHandleInput(input.handle);
258
+ const normalizedHandle = normalizeHandle(input.handle);
259
+ try {
260
+ const current = await readRequest(input.kind, input.routeKey);
261
+ if (current.status !== "open") {
262
+ throw new Error("cannot upsert terminal request");
263
+ }
264
+ if (!isSameOpenIdentity(current, input)) {
265
+ throw new Error("cannot upsert open request with different identity");
266
+ }
267
+ return current;
268
+ }
269
+ catch (error) {
270
+ const issue = error;
271
+ if (issue.code !== "ENOENT")
272
+ throw error;
273
+ }
274
+ const active = await listActiveRequests();
275
+ if (active.some((item) => item.kind === input.kind && item.status === "open" && item.handle === normalizedHandle)) {
276
+ throw new Error("open request handle already exists");
277
+ }
278
+ return writeRequest({
279
+ ...input,
280
+ handle: normalizedHandle,
281
+ status: "open",
282
+ });
283
+ }
284
+ export async function expireOpenRequestsForScope(input) {
285
+ if (!isNonEmptyString(input.scopeKey) || !isFiniteNumber(input.expiredAt)) {
286
+ throw new Error("invalid request record format");
287
+ }
288
+ const activeRequests = await listActiveRequests();
289
+ const expired = [];
290
+ for (const item of activeRequests) {
291
+ if (item.status !== "open") {
292
+ continue;
293
+ }
294
+ if (item.scopeKey !== input.scopeKey) {
295
+ continue;
296
+ }
297
+ expired.push(await markRequestExpired({
298
+ kind: item.kind,
299
+ routeKey: item.routeKey,
300
+ expiredAt: input.expiredAt,
301
+ }));
302
+ }
303
+ return expired;
304
+ }
305
+ export async function markRequestAnswered(input) {
306
+ return markTerminalStatus({
307
+ kind: input.kind,
308
+ routeKey: input.routeKey,
309
+ status: "answered",
310
+ atField: "answeredAt",
311
+ at: input.answeredAt,
312
+ });
313
+ }
314
+ export async function markRequestRejected(input) {
315
+ return markTerminalStatus({
316
+ kind: input.kind,
317
+ routeKey: input.routeKey,
318
+ status: "rejected",
319
+ atField: "rejectedAt",
320
+ at: input.rejectedAt,
321
+ });
322
+ }
323
+ export async function markRequestExpired(input) {
324
+ return markTerminalStatus({
325
+ kind: input.kind,
326
+ routeKey: input.routeKey,
327
+ status: "expired",
328
+ atField: "expiredAt",
329
+ at: input.expiredAt,
330
+ });
331
+ }
332
+ function createRecoveryRouteKey(input) {
333
+ return createRouteKey({
334
+ kind: input.kind,
335
+ requestID: `${input.requestID}-recover-${input.recoveredAt}-${input.attempt}`,
336
+ scopeKey: input.scopeKey,
337
+ });
338
+ }
339
+ function toErrorMessage(error) {
340
+ if (error instanceof Error) {
341
+ return error.message;
342
+ }
343
+ return String(error);
344
+ }
345
+ export async function allocateFreshRecoveryHandle(input) {
346
+ if (!isRequestKind(input.kind)) {
347
+ throw new Error("invalid request record format");
348
+ }
349
+ if (input.bannedHandles !== undefined
350
+ && !Array.isArray(input.bannedHandles)) {
351
+ throw new Error("invalid request record format");
352
+ }
353
+ const active = await listActiveRequests();
354
+ const existingOpenHandles = active
355
+ .filter((item) => item.kind === input.kind && item.status === "open")
356
+ .map((item) => item.handle);
357
+ const bannedHandles = Array.isArray(input.bannedHandles)
358
+ ? input.bannedHandles.filter((item) => isNonEmptyString(item)).map((item) => normalizeHandle(item))
359
+ : [];
360
+ return createHandle(input.kind, [...existingOpenHandles, ...bannedHandles]);
361
+ }
362
+ export async function prepareRecoveryRequestReopen(input) {
363
+ if (!isRequestKind(input.kind) ||
364
+ !isNonEmptyString(input.routeKey) ||
365
+ !isFiniteNumber(input.recoveredAt)) {
366
+ throw new Error("invalid request record format");
367
+ }
368
+ if (input.bannedHandles !== undefined
369
+ && !Array.isArray(input.bannedHandles)) {
370
+ throw new Error("invalid request record format");
371
+ }
372
+ assertValidRouteKey(input.routeKey);
373
+ const current = await readRequestIfExists(input.kind, input.routeKey);
374
+ if (!current) {
375
+ throw new Error("request missing for recovery");
376
+ }
377
+ if (current.status !== "expired" && current.status !== "cleaned") {
378
+ throw new Error("request is not recoverable from current status");
379
+ }
380
+ const nextHandle = await allocateFreshRecoveryHandle({
381
+ kind: current.kind,
382
+ bannedHandles: [current.handle, ...(input.bannedHandles ?? [])],
383
+ });
384
+ let nextRouteKey;
385
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
386
+ const candidateRouteKey = createRecoveryRouteKey({
387
+ kind: current.kind,
388
+ requestID: current.requestID,
389
+ scopeKey: current.scopeKey,
390
+ recoveredAt: input.recoveredAt,
391
+ attempt,
392
+ });
393
+ const existing = await readRequestIfExists(current.kind, candidateRouteKey);
394
+ if (!existing) {
395
+ nextRouteKey = candidateRouteKey;
396
+ break;
397
+ }
398
+ }
399
+ if (!nextRouteKey) {
400
+ throw new Error("failed to allocate recovery routeKey");
401
+ }
402
+ return {
403
+ originalRequest: current,
404
+ nextHandle,
405
+ nextRouteKey,
406
+ };
407
+ }
408
+ export async function commitPreparedRecoveryRequestReopen(prepared) {
409
+ const current = await readRequestIfExists(prepared.originalRequest.kind, prepared.originalRequest.routeKey);
410
+ if (!current) {
411
+ throw new Error("request missing for recovery");
412
+ }
413
+ if (current.status !== "expired" && current.status !== "cleaned") {
414
+ throw new Error("request is not recoverable from current status");
415
+ }
416
+ const active = await listActiveRequests();
417
+ if (active.some((item) => (item.kind === prepared.originalRequest.kind
418
+ && item.status === "open"
419
+ && item.handle === prepared.nextHandle))) {
420
+ throw new Error("recovery handle is no longer fresh");
421
+ }
422
+ const existingTarget = await readRequestIfExists(prepared.originalRequest.kind, prepared.nextRouteKey);
423
+ if (existingTarget) {
424
+ throw new Error("recovery routeKey is no longer fresh");
425
+ }
426
+ const recovered = await writeRequest({
427
+ ...current,
428
+ routeKey: prepared.nextRouteKey,
429
+ handle: prepared.nextHandle,
430
+ status: "open",
431
+ answeredAt: undefined,
432
+ rejectedAt: undefined,
433
+ expiredAt: undefined,
434
+ cleanedAt: undefined,
435
+ terminalReason: undefined,
436
+ replacementHandle: undefined,
437
+ terminalResultSent: undefined,
438
+ });
439
+ try {
440
+ if (current.terminalResultSent !== true) {
441
+ await markTerminalMetadata({
442
+ kind: current.kind,
443
+ routeKey: current.routeKey,
444
+ terminalReason: "replaced",
445
+ replacementHandle: prepared.nextHandle,
446
+ });
447
+ }
448
+ }
449
+ catch (error) {
450
+ try {
451
+ await rm(requestStatePath(prepared.originalRequest.kind, prepared.nextRouteKey), { force: true });
452
+ }
453
+ catch (cleanupError) {
454
+ throw new Error(`failed to cleanup fresh recovery request after original metadata update failure: ${toErrorMessage(cleanupError)}`);
455
+ }
456
+ throw error;
457
+ }
458
+ return recovered;
459
+ }
460
+ export async function rollbackPreparedRecoveryRequestReopen(prepared) {
461
+ let cleanupFreshError;
462
+ let restoreOriginalError;
463
+ try {
464
+ await rm(requestStatePath(prepared.originalRequest.kind, prepared.nextRouteKey), { force: true });
465
+ }
466
+ catch (error) {
467
+ cleanupFreshError = new Error(toErrorMessage(error));
468
+ }
469
+ try {
470
+ await writeRequest(prepared.originalRequest);
471
+ }
472
+ catch (error) {
473
+ restoreOriginalError = new Error(toErrorMessage(error));
474
+ }
475
+ if (cleanupFreshError && restoreOriginalError) {
476
+ throw new Error(`failed to cleanup fresh recovery request: ${cleanupFreshError.message}; failed to restore original recovery request: ${restoreOriginalError.message}`);
477
+ }
478
+ if (cleanupFreshError) {
479
+ throw cleanupFreshError;
480
+ }
481
+ if (restoreOriginalError) {
482
+ throw restoreOriginalError;
483
+ }
484
+ }
485
+ export async function recoverRequestFromDeadLetter(input) {
486
+ if (!isRequestKind(input.kind) ||
487
+ !isNonEmptyString(input.routeKey) ||
488
+ !isFiniteNumber(input.recoveredAt)) {
489
+ throw new Error("invalid request record format");
490
+ }
491
+ if (input.excludedHandles !== undefined
492
+ && !Array.isArray(input.excludedHandles)) {
493
+ throw new Error("invalid request record format");
494
+ }
495
+ const prepared = await prepareRecoveryRequestReopen({
496
+ kind: input.kind,
497
+ routeKey: input.routeKey,
498
+ recoveredAt: input.recoveredAt,
499
+ bannedHandles: input.excludedHandles,
500
+ });
501
+ return commitPreparedRecoveryRequestReopen(prepared);
502
+ }
503
+ export async function markCleaned(input) {
504
+ const current = await readRequest(input.kind, input.routeKey);
505
+ if (!["answered", "rejected", "expired"].includes(current.status)) {
506
+ throw new Error("request cannot be cleaned from current status");
507
+ }
508
+ return writeRequest({
509
+ ...normalizeTerminalMetadata(current),
510
+ status: "cleaned",
511
+ cleanedAt: input.cleanedAt,
512
+ });
513
+ }
514
+ async function listStoredRequests(kind) {
515
+ await ensureWechatStateLayout();
516
+ const dir = requestKindDir(kind);
517
+ const files = await readdir(dir).catch((error) => {
518
+ if (error.code === "ENOENT")
519
+ return [];
520
+ throw error;
521
+ });
522
+ const records = [];
523
+ for (const fileName of files) {
524
+ if (!fileName.endsWith(".json"))
525
+ continue;
526
+ const routeKey = fileName.slice(0, -5);
527
+ records.push(await readRequest(kind, routeKey));
528
+ }
529
+ records.sort((a, b) => a.createdAt - b.createdAt);
530
+ return records;
531
+ }
532
+ export async function purgeCleanedBefore(input) {
533
+ const purged = await purgeCleanedRequestsBefore(input);
534
+ return purged.length;
535
+ }
536
+ export async function purgeCleanedRequestsBefore(input) {
537
+ await ensureWechatStateLayout();
538
+ const deleted = [];
539
+ for (const kind of ["question", "permission"]) {
540
+ const dir = requestKindDir(kind);
541
+ const files = await readdir(dir).catch((error) => {
542
+ if (error.code === "ENOENT")
543
+ return [];
544
+ throw error;
545
+ });
546
+ for (const fileName of files) {
547
+ if (!fileName.endsWith(".json"))
548
+ continue;
549
+ const routeKey = fileName.slice(0, -5);
550
+ const current = await readRequest(kind, routeKey);
551
+ if (current.status !== "cleaned")
552
+ continue;
553
+ if (typeof current.cleanedAt !== "number")
554
+ continue;
555
+ if (current.cleanedAt >= input.cutoffAt)
556
+ continue;
557
+ await rm(requestStatePath(kind, routeKey), { force: true });
558
+ deleted.push(current);
559
+ }
560
+ }
561
+ return deleted;
562
+ }
563
+ export async function listActiveRequests() {
564
+ const result = [];
565
+ for (const kind of ["question", "permission"]) {
566
+ const records = await listStoredRequests(kind);
567
+ for (const current of records) {
568
+ if (current.status === "cleaned")
569
+ continue;
570
+ result.push(current);
571
+ }
572
+ }
573
+ result.sort((a, b) => a.createdAt - b.createdAt);
574
+ return result;
575
+ }
576
+ export async function findOpenRequestByHandle(input) {
577
+ if (!isRequestKind(input.kind) || !isNonEmptyString(input.handle)) {
578
+ throw new Error("invalid request record format");
579
+ }
580
+ const normalizedHandle = normalizeHandle(input.handle);
581
+ const all = await listActiveRequests();
582
+ return all.find((item) => item.kind === input.kind && item.status === "open" && item.handle === normalizedHandle);
583
+ }
584
+ export async function findTerminalRequestByHandle(input) {
585
+ if (!isRequestKind(input.kind) || !isNonEmptyString(input.handle)) {
586
+ throw new Error("invalid request record format");
587
+ }
588
+ const normalizedHandle = normalizeHandle(input.handle);
589
+ const all = await listStoredRequests(input.kind);
590
+ const matches = all.filter((item) => item.status !== "open" && item.handle === normalizedHandle);
591
+ matches.sort((left, right) => terminalSortTimestamp(right) - terminalSortTimestamp(left));
592
+ return matches[0];
593
+ }
594
+ export async function findOpenRequestByIdentity(input) {
595
+ if (!isRequestKind(input.kind) ||
596
+ !isNonEmptyString(input.requestID) ||
597
+ !isNonEmptyString(input.wechatAccountId) ||
598
+ !isNonEmptyString(input.userId)) {
599
+ throw new Error("invalid request record format");
600
+ }
601
+ const all = await listActiveRequests();
602
+ const matches = all.filter((item) => (item.kind === input.kind
603
+ && item.status === "open"
604
+ && normalizeRequestIdentity(item.requestID) === normalizeRequestIdentity(input.requestID)
605
+ && item.wechatAccountId === input.wechatAccountId
606
+ && item.userId === input.userId
607
+ && (input.scopeKey === undefined || item.scopeKey === input.scopeKey)));
608
+ if (input.scopeKey === undefined) {
609
+ return matches.length === 1 ? matches[0] : undefined;
610
+ }
611
+ return matches[0];
612
+ }
613
+ export async function findRequestByRouteKey(input) {
614
+ if (!isRequestKind(input.kind) || !isNonEmptyString(input.routeKey)) {
615
+ throw new Error("invalid request record format");
616
+ }
617
+ assertValidRouteKey(input.routeKey);
618
+ const brokerRecord = await readBrokerIndexedRequest({
619
+ kind: input.kind,
620
+ routeKey: input.routeKey,
621
+ });
622
+ if (brokerRecord) {
623
+ return normalizeRecord(brokerRecord);
624
+ }
625
+ return undefined;
626
+ }
627
+ export async function markTerminalMetadata(input) {
628
+ if (!isRequestKind(input.kind)
629
+ || !isNonEmptyString(input.routeKey)
630
+ || !isRequestTerminalReason(input.terminalReason)) {
631
+ throw new Error("invalid request record format");
632
+ }
633
+ if (input.replacementHandle !== undefined && !isNonEmptyString(input.replacementHandle)) {
634
+ throw new Error("invalid request record format");
635
+ }
636
+ assertValidRouteKey(input.routeKey);
637
+ const current = await readRequest(input.kind, input.routeKey);
638
+ if (current.status === "open") {
639
+ throw new Error("request is still open");
640
+ }
641
+ const selected = selectHigherPriorityTerminalReason({
642
+ currentReason: current.terminalReason,
643
+ currentReplacementHandle: current.replacementHandle,
644
+ nextReason: input.terminalReason,
645
+ nextReplacementHandle: input.replacementHandle,
646
+ });
647
+ return writeRequest({
648
+ ...current,
649
+ terminalReason: selected.terminalReason,
650
+ replacementHandle: selected.replacementHandle,
651
+ terminalResultSent: current.terminalResultSent === true,
652
+ });
653
+ }
654
+ export async function markTerminalResultSent(input) {
655
+ if (!isRequestKind(input.kind)
656
+ || !isNonEmptyString(input.routeKey)
657
+ || !isFiniteNumber(input.sentAt)) {
658
+ throw new Error("invalid request record format");
659
+ }
660
+ assertValidRouteKey(input.routeKey);
661
+ const current = await readRequest(input.kind, input.routeKey);
662
+ if (current.status === "open") {
663
+ throw new Error("request is still open");
664
+ }
665
+ return writeRequest({
666
+ ...current,
667
+ terminalResultSent: true,
668
+ });
669
+ }