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.
- package/LICENSE +151 -0
- package/README.md +50 -0
- package/dist/common-settings-actions.d.ts +15 -0
- package/dist/common-settings-actions.js +48 -0
- package/dist/common-settings-store.d.ts +1 -0
- package/dist/common-settings-store.js +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plugin-hooks.d.ts +51 -0
- package/dist/plugin-hooks.js +288 -0
- package/dist/plugin.d.ts +10 -0
- package/dist/plugin.js +115 -0
- package/dist/settings-store.d.ts +50 -0
- package/dist/settings-store.js +214 -0
- package/dist/store-paths.d.ts +16 -0
- package/dist/store-paths.js +61 -0
- package/dist/ui/wechat-menu.d.ts +26 -0
- package/dist/ui/wechat-menu.js +90 -0
- package/dist/wechat/bind-flow.d.ts +29 -0
- package/dist/wechat/bind-flow.js +207 -0
- package/dist/wechat/bridge.d.ts +136 -0
- package/dist/wechat/bridge.js +1059 -0
- package/dist/wechat/broker-client.d.ts +23 -0
- package/dist/wechat/broker-client.js +274 -0
- package/dist/wechat/broker-endpoint.d.ts +21 -0
- package/dist/wechat/broker-endpoint.js +78 -0
- package/dist/wechat/broker-entry.d.ts +123 -0
- package/dist/wechat/broker-entry.js +1321 -0
- package/dist/wechat/broker-launcher.d.ts +37 -0
- package/dist/wechat/broker-launcher.js +418 -0
- package/dist/wechat/broker-mutation-queue.d.ts +93 -0
- package/dist/wechat/broker-mutation-queue.js +126 -0
- package/dist/wechat/broker-server.d.ts +86 -0
- package/dist/wechat/broker-server.js +1340 -0
- package/dist/wechat/broker-state-store.d.ts +335 -0
- package/dist/wechat/broker-state-store.js +1964 -0
- package/dist/wechat/command-parser.d.ts +18 -0
- package/dist/wechat/command-parser.js +58 -0
- package/dist/wechat/compat/jiti-loader.d.ts +27 -0
- package/dist/wechat/compat/jiti-loader.js +118 -0
- package/dist/wechat/compat/openclaw-account-helpers.d.ts +29 -0
- package/dist/wechat/compat/openclaw-account-helpers.js +60 -0
- package/dist/wechat/compat/openclaw-bind-helpers.d.ts +29 -0
- package/dist/wechat/compat/openclaw-bind-helpers.js +169 -0
- package/dist/wechat/compat/openclaw-guided-smoke.d.ts +180 -0
- package/dist/wechat/compat/openclaw-guided-smoke.js +1134 -0
- package/dist/wechat/compat/openclaw-public-entry.d.ts +33 -0
- package/dist/wechat/compat/openclaw-public-entry.js +62 -0
- package/dist/wechat/compat/openclaw-public-helpers.d.ts +70 -0
- package/dist/wechat/compat/openclaw-public-helpers.js +68 -0
- package/dist/wechat/compat/openclaw-qr-gateway.d.ts +15 -0
- package/dist/wechat/compat/openclaw-qr-gateway.js +39 -0
- package/dist/wechat/compat/openclaw-smoke.d.ts +48 -0
- package/dist/wechat/compat/openclaw-smoke.js +100 -0
- package/dist/wechat/compat/openclaw-sync-buf.d.ts +24 -0
- package/dist/wechat/compat/openclaw-sync-buf.js +80 -0
- package/dist/wechat/compat/openclaw-updates-send.d.ts +47 -0
- package/dist/wechat/compat/openclaw-updates-send.js +38 -0
- package/dist/wechat/compat/qrcode-terminal-loader.d.ts +12 -0
- package/dist/wechat/compat/qrcode-terminal-loader.js +16 -0
- package/dist/wechat/compat/slash-guard.d.ts +11 -0
- package/dist/wechat/compat/slash-guard.js +24 -0
- package/dist/wechat/dead-letter-store.d.ts +48 -0
- package/dist/wechat/dead-letter-store.js +224 -0
- package/dist/wechat/debug-bundle-collector.d.ts +49 -0
- package/dist/wechat/debug-bundle-collector.js +580 -0
- package/dist/wechat/debug-bundle-flow.d.ts +37 -0
- package/dist/wechat/debug-bundle-flow.js +180 -0
- package/dist/wechat/debug-bundle-redaction.d.ts +14 -0
- package/dist/wechat/debug-bundle-redaction.js +339 -0
- package/dist/wechat/handle.d.ts +10 -0
- package/dist/wechat/handle.js +57 -0
- package/dist/wechat/ipc-auth.d.ts +6 -0
- package/dist/wechat/ipc-auth.js +39 -0
- package/dist/wechat/latest-account-state-store.d.ts +8 -0
- package/dist/wechat/latest-account-state-store.js +38 -0
- package/dist/wechat/notification-dispatcher.d.ts +34 -0
- package/dist/wechat/notification-dispatcher.js +266 -0
- package/dist/wechat/notification-format.d.ts +15 -0
- package/dist/wechat/notification-format.js +196 -0
- package/dist/wechat/notification-store.d.ts +72 -0
- package/dist/wechat/notification-store.js +807 -0
- package/dist/wechat/notification-types.d.ts +37 -0
- package/dist/wechat/notification-types.js +1 -0
- package/dist/wechat/openclaw-account-adapter.d.ts +30 -0
- package/dist/wechat/openclaw-account-adapter.js +60 -0
- package/dist/wechat/operator-store.d.ts +9 -0
- package/dist/wechat/operator-store.js +69 -0
- package/dist/wechat/protocol.d.ts +150 -0
- package/dist/wechat/protocol.js +197 -0
- package/dist/wechat/question-interaction.d.ts +24 -0
- package/dist/wechat/question-interaction.js +180 -0
- package/dist/wechat/request-store.d.ts +108 -0
- package/dist/wechat/request-store.js +669 -0
- package/dist/wechat/session-digest.d.ts +50 -0
- package/dist/wechat/session-digest.js +167 -0
- package/dist/wechat/state-paths.d.ts +26 -0
- package/dist/wechat/state-paths.js +92 -0
- package/dist/wechat/status-format.d.ts +26 -0
- package/dist/wechat/status-format.js +616 -0
- package/dist/wechat/token-store.d.ts +20 -0
- package/dist/wechat/token-store.js +193 -0
- package/dist/wechat/wechat-status-runtime.d.ts +89 -0
- package/dist/wechat/wechat-status-runtime.js +518 -0
- 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
|
+
}
|