@rei-standard/amsg-server 2.6.0-next.9 → 3.0.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +822 -41
  3. package/dist/adapters/d1.d.ts +693 -0
  4. package/dist/adapters/factory.d.ts +39 -0
  5. package/dist/adapters/interface.d.ts +384 -0
  6. package/dist/adapters/neon.d.ts +105 -0
  7. package/dist/adapters/pg-shared.d.ts +194 -0
  8. package/dist/adapters/pg.d.ts +101 -0
  9. package/dist/adapters/schema.d.ts +57 -0
  10. package/dist/adapters/schema.sqlite.d.ts +80 -0
  11. package/dist/chunk-7NMQFTDJ.cjs +148 -0
  12. package/dist/{chunk-GOBCWCJP.mjs → chunk-GN44PST5.mjs} +68 -1
  13. package/dist/chunk-RL7V7VSS.mjs +8784 -0
  14. package/dist/chunk-TKDISKCF.cjs +163 -0
  15. package/dist/chunk-XKUWMYVF.mjs +163 -0
  16. package/dist/chunk-YBDQJVFQ.cjs +8784 -0
  17. package/dist/cloudflare/single-user-worker.d.ts +39 -0
  18. package/dist/cloudflare.cjs +35 -2
  19. package/dist/cloudflare.d.ts +11 -2
  20. package/dist/cloudflare.mjs +36 -3
  21. package/dist/handlers/cancel-message.d.ts +3 -0
  22. package/dist/handlers/capabilities.d.ts +4 -0
  23. package/dist/handlers/client-state-namespaces.d.ts +5 -0
  24. package/dist/handlers/client-state.d.ts +7 -0
  25. package/dist/handlers/cloud-data.d.ts +4 -0
  26. package/dist/handlers/get-message.d.ts +3 -0
  27. package/dist/handlers/get-user-key.d.ts +3 -0
  28. package/dist/handlers/init-tenant.d.ts +23 -0
  29. package/dist/handlers/llm-credentials.d.ts +5 -0
  30. package/dist/handlers/messages.d.ts +3 -0
  31. package/dist/handlers/outbox.d.ts +9 -0
  32. package/dist/handlers/push-subscription.d.ts +5 -0
  33. package/dist/handlers/schedule-message.d.ts +3 -0
  34. package/dist/handlers/send-notifications.d.ts +3 -0
  35. package/dist/handlers/single-user-init.d.ts +13 -0
  36. package/dist/handlers/update-message.d.ts +3 -0
  37. package/dist/handlers/vapid-public-key.d.ts +19 -0
  38. package/dist/index.cjs +133 -44
  39. package/dist/index.d.cts +145 -766
  40. package/dist/index.d.ts +145 -766
  41. package/dist/index.mjs +125 -36
  42. package/dist/lib/agentic-fire.d.ts +177 -0
  43. package/dist/lib/client-state-store.d.ts +88 -0
  44. package/dist/lib/cloud-data-cleanup.d.ts +36 -0
  45. package/dist/lib/cloud-data-guard.d.ts +14 -0
  46. package/dist/lib/cloud-data-inventory.d.ts +15 -0
  47. package/dist/lib/constant-time.d.ts +14 -0
  48. package/dist/lib/db-errors.d.ts +10 -0
  49. package/dist/lib/encryption.d.ts +48 -0
  50. package/dist/lib/errors.d.ts +273 -0
  51. package/dist/lib/llm-credentials-store.d.ts +147 -0
  52. package/dist/lib/llm.d.ts +12 -0
  53. package/dist/lib/message-processor.d.ts +82 -0
  54. package/dist/lib/outbox-store.d.ts +162 -0
  55. package/dist/lib/push-policy.d.ts +11 -0
  56. package/dist/lib/push-subscription-store.d.ts +94 -0
  57. package/dist/lib/recurrence.d.ts +51 -0
  58. package/dist/lib/request.d.ts +182 -0
  59. package/dist/lib/result-emitter.d.ts +60 -0
  60. package/dist/lib/retry-policy.d.ts +11 -0
  61. package/dist/lib/run-tick.d.ts +121 -0
  62. package/dist/lib/schema-version.d.ts +50 -0
  63. package/dist/lib/state-accessors.d.ts +70 -0
  64. package/dist/lib/state-chunks.d.ts +65 -0
  65. package/dist/lib/task-projection.d.ts +92 -0
  66. package/dist/lib/validation.d.ts +97 -0
  67. package/dist/lib/version.d.ts +7 -0
  68. package/dist/lib/webcrypto-utils.d.ts +1 -0
  69. package/dist/lib/webpush-webcrypto.d.ts +78 -0
  70. package/dist/{neon-QJ7KYHEX.mjs → neon-GKLT4UY3.mjs} +116 -44
  71. package/dist/{neon-V4VOH55N.cjs → neon-XHPJZESV.cjs} +121 -49
  72. package/dist/{pg-6X74HKMX.cjs → pg-FHXQ6BJP.cjs} +109 -48
  73. package/dist/{pg-DFJSFOJQ.mjs → pg-J4YXRQP5.mjs} +104 -43
  74. package/dist/single-user.d.ts +106 -0
  75. package/dist/tenant/blob-store.d.ts +30 -0
  76. package/dist/tenant/context.d.ts +47 -0
  77. package/dist/tenant/single-user-context.d.ts +29 -0
  78. package/dist/tenant/token.d.ts +26 -0
  79. package/package.json +3 -3
  80. package/dist/chunk-3QRVP4JE.cjs +0 -2834
  81. package/dist/chunk-DCKCAGFV.cjs +0 -81
  82. package/dist/chunk-S6O3MXQO.mjs +0 -2834
  83. package/dist/cloudflare-kUY6z-k5.d.cts +0 -4460
  84. package/dist/cloudflare-kUY6z-k5.d.ts +0 -4460
  85. package/dist/cloudflare.d.cts +0 -2
  86. package/dist/neon-D8Z2Nn97.d.cts +0 -295
  87. package/dist/neon-PqNFJIz2.d.ts +0 -295
  88. package/dist/pg-C48FwCWW.d.cts +0 -289
  89. package/dist/pg-C5gtWUfU.d.ts +0 -289
  90. package/dist/schema-BHuyH_6Z.d.cts +0 -88
  91. package/dist/schema-BHuyH_6Z.d.ts +0 -88
@@ -1,4460 +0,0 @@
1
- import { validateAvatarUrl, toUint8, concatBytes, base64UrlToBytes, chunkReasoningByUtf8Bytes, extractAssistantMessage, buildSessionContext, assertValidDecision, extractToolCallsFromDecision, readReasoningContent, stripReasoningTags, buildReasoningPush, buildContentPush, normalizeVapidSubject } from '@rei-standard/amsg-shared';
2
-
3
- /**
4
- * Validation utility library (SDK version)
5
- * ReiStandard SDK v2.0.1
6
- */
7
-
8
-
9
- /**
10
- * Validate ISO 8601 date string.
11
- * @param {string} dateString
12
- * @returns {boolean}
13
- */
14
- function isValidISO8601(dateString) {
15
- const date = new Date(dateString);
16
- return date instanceof Date && !isNaN(date.getTime());
17
- }
18
-
19
- /**
20
- * Validate URL format.
21
- * @param {string} urlString
22
- * @returns {boolean}
23
- */
24
- function isValidUrl(urlString) {
25
- try {
26
- new URL(urlString);
27
- return true;
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- /**
34
- * Validate UUID format.
35
- * @param {string} uuid
36
- * @returns {boolean}
37
- */
38
- function isValidUUID(uuid) {
39
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
40
- return uuidRegex.test(uuid);
41
- }
42
-
43
- /**
44
- * Validate UUID v4 format.
45
- * @param {string} uuid
46
- * @returns {boolean}
47
- */
48
- function isValidUUIDv4(uuid) {
49
- const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
50
- return uuidV4Regex.test(uuid);
51
- }
52
-
53
- const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
54
-
55
- const SPLIT_PATTERN_MAX_LENGTH = 200;
56
- const SPLIT_PATTERN_MAX_ITEMS = 10;
57
-
58
- /**
59
- * Validate the optional `splitPattern` field (amsg-server scheduled tasks
60
- * only; amsg-instant 0.8.0 dropped its request-level `splitPattern`).
61
- * Accepts `string`, `string[]`, or absent/null. Returns an error message
62
- * string, or null when valid.
63
- *
64
- * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
65
- * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
66
- * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
67
- * backtracking. The real backstop is Worker / runtime CPU limits + the
68
- * fact that splitPattern is stored under the user's own encrypted task
69
- * and matched against output from the user's own LLM API key, so the
70
- * blast radius is self-inflicted only (no cross-tenant attack surface).
71
- *
72
- * @param {unknown} value
73
- * @returns {string | null}
74
- */
75
- function validateSplitPattern(value) {
76
- if (value === undefined || value === null) return null;
77
- const isArray = Array.isArray(value);
78
- const items = isArray ? value : [value];
79
- if (isArray && items.length === 0) return null; // empty array = use default
80
- if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
81
- return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
82
- }
83
- for (let i = 0; i < items.length; i++) {
84
- const s = items[i];
85
- const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
86
- if (typeof s !== 'string') return `${label} 必须是字符串`;
87
- if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
88
- return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
89
- }
90
- try { new RegExp(s); }
91
- catch (_) { return `${label} 不是有效正则表达式`; }
92
- }
93
- return null;
94
- }
95
-
96
- /**
97
- * Validate an OpenAI-style messages array. Same shape contract as
98
- * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
99
- * end up forwarding this to the same LLM body).
100
- *
101
- * @param {unknown} messages
102
- * @returns {string | null} Error message, or null if valid.
103
- */
104
- function validateLlmMessagesArray(messages) {
105
- if (!Array.isArray(messages) || messages.length === 0) {
106
- return 'messages must be a non-empty array';
107
- }
108
- for (let i = 0; i < messages.length; i++) {
109
- const m = messages[i];
110
- if (!m || typeof m !== 'object' || Array.isArray(m)) {
111
- return `messages[${i}] must be an object`;
112
- }
113
- if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
114
- return `messages[${i}].role must be one of system / user / assistant / tool`;
115
- }
116
- if (typeof m.content === 'string') {
117
- if (!m.content) return `messages[${i}].content must be a non-empty string`;
118
- } else if (Array.isArray(m.content)) {
119
- if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
120
- // Element schema is intentionally not enforced — passed through to LLM as-is.
121
- } else {
122
- return `messages[${i}].content must be a non-empty string or a non-empty array`;
123
- }
124
- }
125
- return null;
126
- }
127
-
128
- /**
129
- * Validate the schedule-message request payload.
130
- *
131
- * @param {Object} payload
132
- * @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
133
- */
134
- function validateScheduleMessagePayload(payload) {
135
- if (!payload.contactName || typeof payload.contactName !== 'string') {
136
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
137
- }
138
-
139
- if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
140
- return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
141
- }
142
-
143
- if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
144
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
145
- }
146
-
147
- if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
148
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
149
- }
150
-
151
- if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
152
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
153
- }
154
-
155
- if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
156
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
157
- }
158
-
159
- if (
160
- payload.maxTokens !== undefined &&
161
- payload.maxTokens !== null &&
162
- (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)
163
- ) {
164
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['maxTokens'] } };
165
- }
166
-
167
- if (payload.messageType === 'fixed') {
168
- if (!payload.userMessage) {
169
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
170
- }
171
- }
172
-
173
- // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
174
- //
175
- // Callers provide *exactly one of* `completePrompt` (string) or `messages`
176
- // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
177
- // server's LLM path forwards either verbatim.
178
- const promptCheck = (() => {
179
- const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
180
- const hasMessages = payload.messages !== undefined && payload.messages !== null;
181
- if (hasCompletePrompt && hasMessages) {
182
- return {
183
- error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
184
- hasCompletePrompt: true, hasMessages: true,
185
- };
186
- }
187
- if (hasMessages) {
188
- const err = validateLlmMessagesArray(payload.messages);
189
- if (err) {
190
- return {
191
- error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
192
- hasCompletePrompt: false, hasMessages: true,
193
- };
194
- }
195
- }
196
- return { error: null, hasCompletePrompt, hasMessages };
197
- })();
198
-
199
- if (promptCheck.error) {
200
- return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
201
- }
202
- const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
203
-
204
- if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
205
- const missingAiFields = [];
206
- if (!hasPrompt) missingAiFields.push('completePrompt or messages');
207
- if (!payload.apiUrl) missingAiFields.push('apiUrl');
208
- if (!payload.apiKey) missingAiFields.push('apiKey');
209
- if (!payload.primaryModel) missingAiFields.push('primaryModel');
210
- if (missingAiFields.length > 0) {
211
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
212
- }
213
- }
214
-
215
- if (payload.messageType === 'instant') {
216
- if (payload.recurrenceType && payload.recurrenceType !== 'none') {
217
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
218
- }
219
- const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
220
- const hasUserMessage = payload.userMessage;
221
- if (!hasAiConfig && !hasUserMessage) {
222
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
223
- }
224
- }
225
-
226
- if (
227
- payload.temperature !== undefined &&
228
- payload.temperature !== null &&
229
- (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
230
- ) {
231
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
232
- }
233
-
234
- const avatarErr = validateAvatarUrl(payload.avatarUrl);
235
- if (avatarErr) {
236
- // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
237
- // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
238
- // let the rest of the task ship. See standards §6.2.
239
- console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
240
- payload.avatarUrl = null;
241
- }
242
- if (payload.uuid && !isValidUUID(payload.uuid)) {
243
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
244
- }
245
- // messageSubtype is a free-form string tag forwarded to SW-side push payload
246
- // for classification. Only the type is enforced; the taxonomy is the
247
- // consumer's call (previously the enum was chat/forum/moment).
248
- if (
249
- payload.messageSubtype !== undefined &&
250
- payload.messageSubtype !== null &&
251
- typeof payload.messageSubtype !== 'string'
252
- ) {
253
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
254
- }
255
-
256
- const splitErr = validateSplitPattern(payload.splitPattern);
257
- if (splitErr) {
258
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
259
- }
260
-
261
- return { valid: true };
262
- }
263
-
264
- /**
265
- * Request payload utilities.
266
- * Keeps body parsing and shape validation consistent across handlers.
267
- */
268
-
269
- const REQUEST_ERRORS = {
270
- INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
271
- INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
272
- INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
273
- };
274
-
275
- /**
276
- * @typedef {{ code: string, message: string }} ValidationError
277
- */
278
-
279
- /**
280
- * @typedef {{
281
- * invalidJson?: ValidationError,
282
- * invalidType?: ValidationError
283
- * }} ParseBodyOptions
284
- */
285
-
286
- /**
287
- * @typedef {{
288
- * ok: true,
289
- * data: Record<string, any>
290
- * } | {
291
- * ok: false,
292
- * error: ValidationError
293
- * }} ParseBodyResult
294
- */
295
-
296
- /**
297
- * Parse body into a JSON object.
298
- *
299
- * @param {unknown} body
300
- * @param {ParseBodyOptions} [options]
301
- * @returns {ParseBodyResult}
302
- */
303
- function parseBodyAsObject(body, options = {}) {
304
- const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
305
- const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
306
-
307
- let parsed = body;
308
- if (typeof parsed === 'string') {
309
- try {
310
- parsed = JSON.parse(parsed);
311
- } catch {
312
- return { ok: false, error: invalidJson };
313
- }
314
- }
315
-
316
- if (!isPlainObject(parsed)) {
317
- return { ok: false, error: invalidType };
318
- }
319
-
320
- return { ok: true, data: parsed };
321
- }
322
-
323
- /**
324
- * Parse a standard JSON object body.
325
- *
326
- * @param {unknown} body
327
- * @returns {ParseBodyResult}
328
- */
329
- function parseJsonBody(body) {
330
- return parseBodyAsObject(body, {
331
- invalidJson: REQUEST_ERRORS.INVALID_JSON,
332
- invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
333
- });
334
- }
335
-
336
- /**
337
- * Check if a value is a plain object (and not null/array).
338
- *
339
- * @param {unknown} value
340
- * @returns {value is Record<string, any>}
341
- */
342
- function isPlainObject(value) {
343
- return typeof value === 'object' && value !== null && !Array.isArray(value);
344
- }
345
-
346
- /**
347
- * Check if an object follows the encrypted payload envelope shape.
348
- *
349
- * @param {unknown} payload
350
- * @returns {payload is { iv: string, authTag: string, encryptedData: string }}
351
- */
352
- function isEncryptedEnvelope(payload) {
353
- if (!isPlainObject(payload)) return false;
354
-
355
- return (
356
- typeof payload.iv === 'string' &&
357
- typeof payload.authTag === 'string' &&
358
- typeof payload.encryptedData === 'string'
359
- );
360
- }
361
-
362
- /**
363
- * Parse and validate an encrypted payload envelope.
364
- *
365
- * @param {unknown} body
366
- * @returns {ParseBodyResult}
367
- */
368
- function parseEncryptedBody(body) {
369
- const parsedBody = parseBodyAsObject(body, {
370
- invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
371
- invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
372
- });
373
-
374
- if (!parsedBody.ok) {
375
- return parsedBody;
376
- }
377
-
378
- if (!isEncryptedEnvelope(parsedBody.data)) {
379
- return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
380
- }
381
-
382
- return parsedBody;
383
- }
384
-
385
- /**
386
- * Read a header value case-insensitively.
387
- *
388
- * @param {Record<string, any>} headers
389
- * @param {string} name
390
- * @returns {string}
391
- */
392
- function getHeader(headers = {}, name) {
393
- if (!headers || typeof headers !== 'object') {
394
- return '';
395
- }
396
-
397
- const lowerName = String(name || '').toLowerCase();
398
- if (!lowerName) return '';
399
-
400
- for (const [key, value] of Object.entries(headers)) {
401
- if (String(key).toLowerCase() === lowerName) {
402
- return String(value || '').trim();
403
- }
404
- }
405
-
406
- return '';
407
- }
408
-
409
- /**
410
- * Runtime-neutral crypto + encoding helpers.
411
- *
412
- * Everything in here is implemented on top of WHATWG-standard primitives
413
- * (`globalThis.crypto.subtle`, `TextEncoder`, `Uint8Array`) so the package
414
- * runs on Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and
415
- * Node ≥ 19 with zero polyfills. The Node adapter polyfills
416
- * `globalThis.crypto` for Node 18 deployments before any of these are
417
- * touched.
418
- */
419
-
420
- const TEXT_ENCODER = new TextEncoder();
421
- const TEXT_DECODER = new TextDecoder('utf-8', { fatal: false });
422
-
423
- /** UTF-8 encode a string into a Uint8Array. */
424
- function utf8$1(str) {
425
- return TEXT_ENCODER.encode(String(str));
426
- }
427
-
428
- /** UTF-8 decode a Uint8Array / ArrayBuffer into a string. */
429
- function utf8Decode(buf) {
430
- return TEXT_DECODER.decode(toUint8(buf));
431
- }
432
-
433
-
434
- /** Encode bytes as standard base64 (with padding). */
435
- function bytesToBase64(buf) {
436
- const bytes = toUint8(buf);
437
- let bin = '';
438
- for (let i = 0; i < bytes.length; i++) {
439
- bin += String.fromCharCode(bytes[i]);
440
- }
441
- // btoa is available in all Web Crypto runtimes (browsers, Workers, Node 16+).
442
- return (typeof btoa === 'function')
443
- ? btoa(bin)
444
- : Buffer.from(bin, 'binary').toString('base64');
445
- }
446
-
447
- /** Encode bytes as base64url (no padding). */
448
- function bytesToBase64Url(buf) {
449
- return bytesToBase64(buf).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
450
- }
451
-
452
- /** Encode bytes as lowercase hex. */
453
- function bytesToHex(buf) {
454
- const bytes = toUint8(buf);
455
- let out = '';
456
- for (let i = 0; i < bytes.length; i++) {
457
- out += bytes[i].toString(16).padStart(2, '0');
458
- }
459
- return out;
460
- }
461
-
462
- /** Decode a hex string into a Uint8Array. */
463
- function hexToBytes(hex) {
464
- const out = new Uint8Array(hex.length / 2);
465
- for (let i = 0; i < out.length; i++) {
466
- out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
467
- }
468
- return out;
469
- }
470
-
471
-
472
- /** Encode a JSON-serializable value as base64url (UTF-8 JSON). */
473
- function jsonToBase64Url(value) {
474
- return bytesToBase64Url(utf8$1(JSON.stringify(value)));
475
- }
476
-
477
- /** `crypto.randomUUID()`. The Node adapter polyfills `globalThis.crypto`. */
478
- function randomUUID() {
479
- return globalThis.crypto.randomUUID();
480
- }
481
-
482
- /** Cryptographically random bytes. */
483
- function randomBytes(n) {
484
- const out = new Uint8Array(n);
485
- globalThis.crypto.getRandomValues(out);
486
- return out;
487
- }
488
-
489
- /**
490
- * Encryption utility library (SDK version)
491
- *
492
- * Wraps AES-256-GCM operations for request/response and storage encryption.
493
- * Implemented on `globalThis.crypto.subtle` (Web Crypto) so the module runs
494
- * on Cloudflare Workers and other edge runtimes without `nodejs_compat`, as
495
- * well as on Node ≥ 19. All exports are async because SubtleCrypto is.
496
- *
497
- * Wire formats are byte-identical to the previous node:crypto implementation
498
- * (pinned by test/encryption-compat.test.mjs):
499
- * - payload: 12-byte IV, base64 `{ iv, authTag, encryptedData }`
500
- * - storage: 16-byte IV, hex `iv:authTag:encryptedData`
501
- * Web Crypto appends the GCM auth tag to the ciphertext, while these formats
502
- * carry it detached — aesGcmSeal / aesGcmOpen below own that seam.
503
- */
504
-
505
-
506
- // Decoder for the wire formats' base64 fields. base64UrlToBytes normalizes
507
- // the url-safe alphabet and padding first, so it accepts both standard and
508
- // url-safe base64 — same leniency as node's Buffer.from(x, 'base64').
509
- const base64ToBytes = base64UrlToBytes;
510
-
511
- const TAG_LENGTH_BYTES = 16; // AES-GCM auth tag emitted on encrypt
512
-
513
- function importAesKey(hexKey, usage) {
514
- return globalThis.crypto.subtle.importKey(
515
- 'raw',
516
- hexToBytes(hexKey),
517
- { name: 'AES-GCM' },
518
- false,
519
- [usage]
520
- );
521
- }
522
-
523
- /** AES-256-GCM encrypt; returns the ciphertext and the detached 16-byte tag. */
524
- async function aesGcmSeal(hexKey, iv, plaintextBytes) {
525
- const key = await importAesKey(hexKey, 'encrypt');
526
- const sealed = new Uint8Array(await globalThis.crypto.subtle.encrypt(
527
- { name: 'AES-GCM', iv, tagLength: TAG_LENGTH_BYTES * 8 },
528
- key,
529
- plaintextBytes
530
- ));
531
- return {
532
- ciphertext: sealed.slice(0, sealed.length - TAG_LENGTH_BYTES),
533
- authTag: sealed.slice(sealed.length - TAG_LENGTH_BYTES)
534
- };
535
- }
536
-
537
- /** AES-256-GCM decrypt from detached-tag form; returns plaintext bytes. */
538
- async function aesGcmOpen(hexKey, iv, ciphertext, authTag) {
539
- const key = await importAesKey(hexKey, 'decrypt');
540
- // node's decipher accepted GCM tags truncated down to 96 bits; mirror that
541
- // by sizing tagLength from the provided tag. Anything outside the standard
542
- // 96–128-bit range keeps 128 and fails authentication, as it did before.
543
- const tagBits = authTag.length * 8;
544
- const tagLength = tagBits >= 96 && tagBits <= 128 ? tagBits : 128;
545
- const plain = await globalThis.crypto.subtle.decrypt(
546
- { name: 'AES-GCM', iv, tagLength },
547
- key,
548
- concatBytes(ciphertext, authTag)
549
- );
550
- return new Uint8Array(plain);
551
- }
552
-
553
- /**
554
- * Derive a user-specific encryption key from the master key.
555
- *
556
- * @param {string} userId - Unique user identifier.
557
- * @param {string} masterKey - 64-char hex master key.
558
- * @returns {Promise<string>} 64-char hex key.
559
- */
560
- async function deriveUserEncryptionKey(userId, masterKey) {
561
- const digest = await globalThis.crypto.subtle.digest('SHA-256', utf8$1(masterKey + userId));
562
- return bytesToHex(new Uint8Array(digest)).slice(0, 64);
563
- }
564
-
565
- /**
566
- * Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
567
- *
568
- * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
569
- * @param {string} encryptionKey - 64-char hex key.
570
- * @returns {Promise<Object>} Decrypted JSON object.
571
- */
572
- async function decryptPayload(encryptedPayload, encryptionKey) {
573
- const { iv, authTag, encryptedData } = encryptedPayload;
574
- const plain = await aesGcmOpen(
575
- encryptionKey,
576
- base64ToBytes(iv),
577
- base64ToBytes(encryptedData),
578
- base64ToBytes(authTag)
579
- );
580
- return JSON.parse(utf8Decode(plain));
581
- }
582
-
583
- /**
584
- * Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
585
- *
586
- * @param {string|Object} payload
587
- * @param {string} encryptionKey - 64-char hex key.
588
- * @returns {Promise<{ iv: string, authTag: string, encryptedData: string }>}
589
- */
590
- async function encryptPayload(payload, encryptionKey) {
591
- const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
592
- const iv = randomBytes(12);
593
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(plaintext));
594
-
595
- return {
596
- iv: bytesToBase64(iv),
597
- authTag: bytesToBase64(authTag),
598
- encryptedData: bytesToBase64(ciphertext)
599
- };
600
- }
601
-
602
- /**
603
- * Encrypt data for database storage (hex encoded, colon-separated).
604
- *
605
- * @param {string} text - Plaintext string.
606
- * @param {string} encryptionKey - 64-char hex key.
607
- * @returns {Promise<string>} Format: iv:authTag:encryptedData
608
- */
609
- async function encryptForStorage(text, encryptionKey) {
610
- const iv = randomBytes(16);
611
- const { ciphertext, authTag } = await aesGcmSeal(encryptionKey, iv, utf8$1(text));
612
- return `${bytesToHex(iv)}:${bytesToHex(authTag)}:${bytesToHex(ciphertext)}`;
613
- }
614
-
615
- /**
616
- * Decrypt data from database storage format.
617
- *
618
- * @param {string} encryptedText - Format: iv:authTag:encryptedData
619
- * @param {string} encryptionKey - 64-char hex key.
620
- * @returns {Promise<string>} Plaintext string.
621
- */
622
- async function decryptFromStorage(encryptedText, encryptionKey) {
623
- const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
624
- const plain = await aesGcmOpen(
625
- encryptionKey,
626
- hexToBytes(ivHex),
627
- hexToBytes(encryptedDataHex),
628
- hexToBytes(authTagHex)
629
- );
630
- return utf8Decode(plain);
631
- }
632
-
633
- /**
634
- * Handler: get-user-key
635
- * ReiStandard SDK v2.0.1
636
- *
637
- * @param {Object} ctx - Server context.
638
- * @returns {{ GET: function }}
639
- */
640
-
641
-
642
- function createGetUserKeyHandler(ctx) {
643
- async function GET(url, headers) {
644
- const effectiveHeaders = headers || url || {};
645
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
646
- if (!tenantResult.ok) {
647
- return tenantResult.error;
648
- }
649
-
650
- const { masterKey } = tenantResult.context;
651
- const userId = getHeader(effectiveHeaders, 'x-user-id');
652
-
653
- if (!userId) {
654
- return {
655
- status: 400,
656
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
657
- };
658
- }
659
-
660
- if (!isValidUUIDv4(userId)) {
661
- return {
662
- status: 400,
663
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
664
- };
665
- }
666
-
667
- return {
668
- status: 200,
669
- body: {
670
- success: true,
671
- data: {
672
- userKey: await deriveUserEncryptionKey(userId, masterKey),
673
- version: 1
674
- }
675
- }
676
- };
677
- }
678
-
679
- return { GET };
680
- }
681
-
682
- /**
683
- * Database error helpers.
684
- */
685
-
686
- /**
687
- * Check whether an error is caused by a unique-constraint violation.
688
- *
689
- * @param {unknown} error
690
- * @returns {boolean}
691
- */
692
- function isUniqueViolation(error) {
693
- if (!error || typeof error !== 'object') return false;
694
-
695
- const code = error.code;
696
- if (code === '23505') return true;
697
-
698
- const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
699
- return message.includes('duplicate key') || message.includes('unique constraint');
700
- }
701
-
702
- /**
703
- * OpenAI-compatible LLM call for the server-side fire chain.
704
- *
705
- * Extracted from message-processor.js so the agentic fire loop
706
- * (lib/agentic-fire.js) can call the LLM without a circular import.
707
- * The legacy single-shot path (processSingleMessage) and the multi-round
708
- * loop share this one function.
709
- */
710
-
711
- /**
712
- * Call an OpenAI-compatible API.
713
- *
714
- * Returns the full response object alongside the extracted (trimmed)
715
- * `content` string. Callers that only need the text can ignore
716
- * `response`; callers that want `reasoning_content` / `tool_calls`
717
- * read from `response.choices[0].message`.
718
- *
719
- * @param {Object} payload
720
- * @param {{ requireContent?: boolean, timeoutMs?: number }} [options]
721
- * requireContent defaults to true (legacy single-shot behavior:
722
- * throw when the response carries no content). Tool rounds legitimately
723
- * return no content (pure tool_calls), so the agentic loop passes
724
- * `{ requireContent: false }` — same precedent as amsg-instant's
725
- * callLlmRaw.
726
- * timeoutMs defaults to 300000 (the legacy per-call ceiling). The
727
- * agentic loop passes its remaining wall-time budget so a hung LLM
728
- * request cannot outlive totalTimeoutMs.
729
- * @returns {Promise<{ response: unknown, content: string }>}
730
- */
731
- async function callLlm(payload, options = {}) {
732
- const requireContent = options.requireContent !== false;
733
- const timeoutMs = typeof options.timeoutMs === 'number' && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
734
- ? options.timeoutMs
735
- : 300000;
736
- const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
737
- const requestBody = buildAiRequestBody(payload);
738
-
739
- const aiResponse = await fetch(normalizedApiUrl, {
740
- method: 'POST',
741
- headers: {
742
- 'Content-Type': 'application/json',
743
- 'Authorization': `Bearer ${payload.apiKey}`
744
- },
745
- body: JSON.stringify(requestBody),
746
- signal: AbortSignal.timeout(timeoutMs)
747
- });
748
-
749
- if (!aiResponse.ok) {
750
- if (aiResponse.status === 405) {
751
- throw new Error(
752
- `AI API error: 405 Method Not Allowed. ` +
753
- `apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
754
- `Received: ${normalizedApiUrl}`
755
- );
756
- }
757
-
758
- throw new Error(
759
- `AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
760
- `Request URL: ${normalizedApiUrl}`
761
- );
762
- }
763
-
764
- const aiData = await aiResponse.json();
765
- const rawContent = aiData?.choices?.[0]?.message?.content;
766
- if (requireContent && (typeof rawContent !== 'string' || !rawContent.trim())) {
767
- throw new Error('AI API error: response missing choices[0].message.content');
768
- }
769
-
770
- return { response: aiData, content: typeof rawContent === 'string' ? rawContent.trim() : '' };
771
- }
772
-
773
- /**
774
- * Build OpenAI-compatible request body.
775
- *
776
- * `max_tokens` is optional:
777
- * - include it only when payload.maxTokens is provided
778
- * - omit it when payload.maxTokens is undefined / null
779
- *
780
- * `tools` / `tool_choice` are optional as well: forwarded only when the
781
- * caller passes a non-empty payload.tools.
782
- *
783
- * @param {Object} payload
784
- * @returns {Object}
785
- */
786
- function buildAiRequestBody(payload) {
787
- // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
788
- // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
789
- // role injection, no concatenation back to a single user message. Lets
790
- // the upstream app preserve system / multi-turn context byte-for-byte
791
- // across the schedule-message path.
792
- const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
793
- ? payload.messages
794
- : [{ role: 'user', content: payload.completePrompt }];
795
-
796
- const requestBody = {
797
- model: payload.primaryModel,
798
- messages: llmMessages,
799
- };
800
-
801
- // Match the instant package's behavior: only inject default temperature
802
- // for the legacy completePrompt path; messages mode forwards whatever the
803
- // upstream app set (or nothing) so behavior matches their main chat path.
804
- if (payload.temperature !== undefined && payload.temperature !== null) {
805
- requestBody.temperature = payload.temperature;
806
- } else if (!Array.isArray(payload.messages)) {
807
- requestBody.temperature = 0.8;
808
- }
809
-
810
- // tools mode (added in v2.6.0): forward the caller's OpenAI tools array
811
- // verbatim — same philosophy as messages mode above. The agentic loop
812
- // already appends assistant tool_calls + role:'tool' results; this is
813
- // the request-side half of the same protocol. An empty array is treated
814
- // as "no tools" because some OpenAI-compatible relays reject `tools: []`.
815
- if (Array.isArray(payload.tools) && payload.tools.length > 0) {
816
- requestBody.tools = payload.tools;
817
- if (payload.toolChoice !== undefined && payload.toolChoice !== null) {
818
- requestBody.tool_choice = payload.toolChoice;
819
- }
820
- }
821
-
822
- if (payload.maxTokens === undefined || payload.maxTokens === null) {
823
- return requestBody;
824
- }
825
-
826
- if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
827
- throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
828
- }
829
-
830
- requestBody.max_tokens = payload.maxTokens;
831
- return requestBody;
832
- }
833
-
834
- /**
835
- * Normalize AI API URL for OpenAI-compatible chat endpoints.
836
- *
837
- * **Keep in sync** with `@rei-standard/amsg-instant`'s
838
- * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
839
- * tests. The two packages share this logic but each carry their own copy
840
- * to avoid an architectural dependency (server should not depend on the
841
- * stateless worker package).
842
- *
843
- * @param {string} apiUrl
844
- * @returns {string}
845
- */
846
- function normalizeAiApiUrl(apiUrl) {
847
- if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
848
- throw new Error(
849
- 'Invalid apiUrl: apiUrl is required. ' +
850
- 'Please provide a chat endpoint URL ' +
851
- '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
852
- );
853
- }
854
-
855
- const trimmedApiUrl = apiUrl.trim();
856
- let parsedUrl;
857
-
858
- try {
859
- parsedUrl = new URL(trimmedApiUrl);
860
- } catch {
861
- throw new Error(
862
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
863
- );
864
- }
865
-
866
- let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
867
-
868
- if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
869
- // Bare host → assume OpenAI shape.
870
- path = '/v1/chat/completions';
871
- } else if (/\/v\d+$/.test(path)) {
872
- // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
873
- // Append only `/chat/completions`; never re-add `/v1`.
874
- path = `${path}/chat/completions`;
875
- }
876
- // Any other custom path is left untouched on purpose.
877
-
878
- parsedUrl.pathname = path;
879
- return parsedUrl.toString();
880
- }
881
-
882
- /**
883
- * client_state 大值透明分块(单用户/D1 专用;handlers/client-state.js 与
884
- * lib/agentic-fire.js 的 readState 共用)。
885
- *
886
- * 存储格式(库内部实现细节,不进公开契约):
887
- * - 单条 value ≤ STATE_CHUNK_SLICE_BYTES(200KB)→ 历史单行路径,存储字节级不变。
888
- * - 超过 → 服务端切片跨行:原 (namespace, key) 行的 value 写成纯文本 marker
889
- * (`\u001famsg-chunked\u001fv1\u001f<块数>`),切片本体逐片
890
- * encryptForStorage 后存进保留 namespace(`\u001famsg-chunks\u001f<原ns>`),
891
- * key 为 `<原key>\u001f<序号>`。写入方与读取方(客户端 / hook 作者)完全无感。
892
- * - marker 以 \u001f (Unit Separator) 开头;encryptForStorage 输出是
893
- * `hex:hex:hex`,永远不以控制字符开头,两种行值不会混淆。
894
- *
895
- * 读取完整性:块必须齐全,且每块 updated_at 与根行一致(同一次写入的印记)。
896
- * 不满足(写到一半断了 / 新旧写交错)→ 该 key 视为不存在,读方走自己的兜底,
897
- * 不抛错、不吐半截数据。
898
- *
899
- * 保留字符:namespace / key 里的 C0 控制字符(\u0000-\u001f)为库内部保留,
900
- * handler 对用户输入逐条拒绝。内部分隔符选 \u001f 而不是 NUL,因为 SQLite 的
901
- * LIKE 在 \u0000 处截断 pattern,前缀清理会失效。
902
- */
903
-
904
-
905
- // 每个切片行的 plaintext 上限 = 历史单条上限,沿用已验证的行大小。
906
- const STATE_CHUNK_SLICE_BYTES = 200 * 1024;
907
- // 单条 value 总上限的默认值(工厂配置 maxStateValueBytes 可调)。
908
- const DEFAULT_MAX_STATE_VALUE_BYTES = 5 * 1024 * 1024;
909
- // 用户输入里的保留字符(namespace / key 逐条拒绝)。
910
- const INTERNAL_STATE_CHAR_RE = /[\u0000-\u001f]/;
911
-
912
- const SEP = '\u001f';
913
- const CHUNK_NS_PREFIX = `${SEP}amsg-chunks${SEP}`;
914
- const ROOT_MARKER_PREFIX = `${SEP}amsg-chunked${SEP}v1${SEP}`;
915
-
916
- /** 某个用户 namespace 的切片行所在的保留 namespace。 */
917
- function chunkNamespaceFor(namespace) {
918
- return CHUNK_NS_PREFIX + namespace;
919
- }
920
-
921
- /** 第 index 片的存储 key。 */
922
- function chunkKeyFor(key, index) {
923
- return `${key}${SEP}${index}`;
924
- }
925
-
926
- /** 清理某 key 全部切片行用的 key 前缀。 */
927
- function chunkKeyPrefixFor(key) {
928
- return `${key}${SEP}`;
929
- }
930
-
931
- /** 分块根行的 value(纯文本 marker,不加密——不含用户数据)。 */
932
- function buildChunkedRootValue(chunkCount) {
933
- return `${ROOT_MARKER_PREFIX}${chunkCount}`;
934
- }
935
-
936
- /**
937
- * 严格解析根行 marker。不是 marker(普通密文 / 任意文本 / 计数非正整数)
938
- * → null,调用方按普通单行处理。
939
- *
940
- * @param {unknown} value
941
- * @returns {number | null}
942
- */
943
- function parseChunkedRootCount(value) {
944
- if (typeof value !== 'string' || !value.startsWith(ROOT_MARKER_PREFIX)) return null;
945
- const raw = value.slice(ROOT_MARKER_PREFIX.length);
946
- if (!/^[1-9][0-9]*$/.test(raw)) return null;
947
- return Number(raw);
948
- }
949
-
950
- /**
951
- * 把超限 value 切成 ≤ STATE_CHUNK_SLICE_BYTES 的切片。切点在码点边界
952
- * (chunkReasoningByUtf8Bytes 保证多字节字符 / emoji 代理对不被劈开,
953
- * join('') === 原文)。
954
- *
955
- * @param {string} value
956
- * @returns {string[]}
957
- */
958
- function splitStateValue(value) {
959
- return chunkReasoningByUtf8Bytes(value, STATE_CHUNK_SLICE_BYTES);
960
- }
961
-
962
- /**
963
- * 把一个 namespace 的存储行解析成逻辑条目(GET /client-state 与 readState
964
- * 共用)。普通行解密直读;分块根行按 marker 拼回。切片行查询是惰性的:
965
- * 整个 namespace 没有分块根行时一次都不发。
966
- *
967
- * @param {Array<{ namespace: string, key: string, value: string, updated_at: number }>} rows
968
- * 用户 namespace 的存储行(getClientState 返回值)。
969
- * @param {() => Promise<Array<{ key: string, value: string, updated_at: number }>>} fetchChunkRows
970
- * 取该 namespace 对应保留 namespace 的全部切片行(最多调用一次)。
971
- * @param {(value: string) => Promise<string>} decryptValue
972
- * @returns {Promise<Array<{ namespace: string, key: string, value: string, updatedAt: number }>>}
973
- */
974
- async function resolveClientStateEntries(rows, fetchChunkRows, decryptValue) {
975
- let chunkMap = null;
976
- const loadChunks = async () => {
977
- if (chunkMap === null) {
978
- const chunkRows = await fetchChunkRows();
979
- chunkMap = new Map(chunkRows.map((row) => [row.key, row]));
980
- }
981
- return chunkMap;
982
- };
983
-
984
- const entries = [];
985
- for (const row of rows) {
986
- const count = parseChunkedRootCount(row.value);
987
- if (count === null) {
988
- entries.push({
989
- namespace: row.namespace,
990
- key: row.key,
991
- value: await decryptValue(row.value),
992
- updatedAt: row.updated_at,
993
- });
994
- continue;
995
- }
996
-
997
- const map = await loadChunks();
998
- const chunkRows = [];
999
- let intact = true;
1000
- for (let i = 0; i < count; i++) {
1001
- const chunk = map.get(chunkKeyFor(row.key, i));
1002
- if (!chunk || chunk.updated_at !== row.updated_at) {
1003
- intact = false;
1004
- break;
1005
- }
1006
- chunkRows.push(chunk);
1007
- }
1008
- if (!intact) continue; // 写到一半断了 → 该 key 视为不存在
1009
-
1010
- const parts = await Promise.all(chunkRows.map((chunk) => decryptValue(chunk.value)));
1011
- entries.push({
1012
- namespace: row.namespace,
1013
- key: row.key,
1014
- value: parts.join(''),
1015
- updatedAt: row.updated_at,
1016
- });
1017
- }
1018
- return entries;
1019
- }
1020
-
1021
- /**
1022
- * client_state 的落库实现,所有写入方共用。
1023
- *
1024
- * 写入方有两个:HTTP 的 `PUT /client-state`(客户端批量把本地状态同步上来)
1025
- * 和 fire-time hook 的 `ctx.writeState()`(服务端在组消息时把大内容旁路存下
1026
- * 来,见 lib/agentic-fire.js)。加密、大值分块、旧切片清理都在这里一份,
1027
- * 两条路径写出来的行完全同构,读回时也就不用区分是谁写的。
1028
- *
1029
- * 读回在 lib/state-chunks.js 的 resolveClientStateEntries。
1030
- */
1031
-
1032
-
1033
- const utf8 = new TextEncoder();
1034
-
1035
- // 一次写入的条目上限。客户端一次后台窗口传几十条是设计负载,200 留足余量,
1036
- // 同时给 D1 的一次 batch 定个上界。
1037
- const MAX_STATE_ENTRIES_PER_BATCH = 200;
1038
- // namespace / key 的字符长度上限,HTTP 与 hook 两条写入路径共用一套。
1039
- const MAX_NAMESPACE_CHARS = 128;
1040
- const MAX_KEY_CHARS = 256;
1041
-
1042
- /** value 的 UTF-8 字节数——各处限额判断用的都是这个口径。 */
1043
- function stateValueBytes(value) {
1044
- return utf8.encode(value).length;
1045
- }
1046
-
1047
- /**
1048
- * 把逻辑条目写进 client_state。条目要么是覆盖写(`value` 是字符串),要么是
1049
- * 删除(`value` 为 `null`):
1050
- *
1051
- * - 覆盖写:≤ 200KB 单行存;超过的切片跨行存(见 lib/state-chunks.js)。
1052
- * 同一批里先清掉这个 key 上次写入留下的切片行,所以覆盖成更小的值、或者
1053
- * 块数变少,都不会留下尾巴。
1054
- * - 删除:根行按精确 key 删,切片行按 key 前缀删,一个 key 的数据清干净。
1055
- *
1056
- * 两者都受同一套 last-write-wins 约束:`updatedAt` 比库里已有值旧的写入
1057
- * (或删除)不生效,陈旧批次盖不掉新数据。
1058
- *
1059
- * 条目本身的合法性(namespace / key 字符、value 大小)由调用方先校验好:
1060
- * HTTP handler 逐条拒绝并把原因回给客户端,`writeState()` 直接抛错给 hook。
1061
- *
1062
- * @param {Object} args
1063
- * @param {{ upsertClientState: Function }} args.db
1064
- * @param {string} args.userId
1065
- * @param {CryptoKey|string} args.userKey - 该用户的存储密钥(value 用它加密)
1066
- * @param {Array<{ namespace: string, key: string, value: string|null, updatedAt: number }>} args.entries
1067
- * @returns {Promise<{ upserted: number, skipped: number, deleted: number }>}
1068
- * `upserted` / `skipped` 按逻辑条目计(切片行不计);`deleted` 是请求删除的
1069
- * key 数,不代表这些 key 原本一定存在。
1070
- */
1071
- async function writeClientStateEntries({ db, userId, userKey, entries }) {
1072
- const physicalRows = [];
1073
- const cleanups = [];
1074
- const rootRowIndexes = [];
1075
- let deleted = 0;
1076
-
1077
- for (const entry of entries) {
1078
- // 不管写还是删,都先清掉这个 key 上一次写入留下的切片行(同一批里先删后写)。
1079
- cleanups.push({
1080
- namespace: chunkNamespaceFor(entry.namespace),
1081
- keyPrefix: chunkKeyPrefixFor(entry.key),
1082
- updatedAt: entry.updatedAt,
1083
- });
1084
-
1085
- if (entry.value === null) {
1086
- // 根行按精确 key 删——用前缀会连带删掉同前缀的兄弟 key('note' 删掉 'notes')。
1087
- cleanups.push({
1088
- namespace: entry.namespace,
1089
- key: entry.key,
1090
- updatedAt: entry.updatedAt,
1091
- });
1092
- deleted++;
1093
- continue;
1094
- }
1095
-
1096
- rootRowIndexes.push(physicalRows.length);
1097
- if (stateValueBytes(entry.value) <= STATE_CHUNK_SLICE_BYTES) {
1098
- physicalRows.push({
1099
- namespace: entry.namespace,
1100
- key: entry.key,
1101
- value: await encryptForStorage(entry.value, userKey),
1102
- updatedAt: entry.updatedAt,
1103
- });
1104
- } else {
1105
- const slices = splitStateValue(entry.value);
1106
- physicalRows.push({
1107
- namespace: entry.namespace,
1108
- key: entry.key,
1109
- value: buildChunkedRootValue(slices.length),
1110
- updatedAt: entry.updatedAt,
1111
- });
1112
- const encryptedSlices = await Promise.all(slices.map((slice) => encryptForStorage(slice, userKey)));
1113
- for (let c = 0; c < encryptedSlices.length; c++) {
1114
- physicalRows.push({
1115
- namespace: chunkNamespaceFor(entry.namespace),
1116
- key: chunkKeyFor(entry.key, c),
1117
- value: encryptedSlices[c],
1118
- updatedAt: entry.updatedAt,
1119
- });
1120
- }
1121
- }
1122
- }
1123
-
1124
- if (physicalRows.length === 0 && cleanups.length === 0) {
1125
- return { upserted: 0, skipped: 0, deleted: 0 };
1126
- }
1127
-
1128
- const result = await db.upsertClientState(userId, physicalRows, cleanups);
1129
- let upserted = 0;
1130
- let skipped = 0;
1131
- if (Array.isArray(result.outcomes) && result.outcomes.length === physicalRows.length) {
1132
- // 逻辑计数:一条条目的 upserted/skipped 看它的根行,切片行不计数。
1133
- for (const rootIndex of rootRowIndexes) {
1134
- if (result.outcomes[rootIndex]) upserted++; else skipped++;
1135
- }
1136
- } else {
1137
- // 自定义 adapter 只回老形状 { upserted, skipped } 时按物理行计数兜底。
1138
- upserted = result.upserted;
1139
- skipped = result.skipped;
1140
- }
1141
- return { upserted, skipped, deleted };
1142
- }
1143
-
1144
- /**
1145
- * Server-side agentic fire loop.
1146
- *
1147
- * When the host configures fire-time hooks, an LLM task stops replaying
1148
- * the completePrompt frozen at schedule time. At fire time instead:
1149
- *
1150
- * onBeforeFire(fireCtx) → fresh messages (may read client_state) | { skip: true }
1151
- * → callLlm → onLLMOutput(sessionCtx) → decision
1152
- * ├─ 'finish' → push decision.pushPayloads, done
1153
- * ├─ 'skip-push' → record, done (task counts as delivered)
1154
- * ├─ 'continue' → replace history, next round
1155
- * └─ 'tool-request' → executeToolCalls IN the worker — the client
1156
- * is offline at fire time, so unlike
1157
- * amsg-instant nothing is pushed back for the
1158
- * client to execute — then append the
1159
- * assistant turn + tool results, next round
1160
- *
1161
- * The decision contract is shared with @rei-standard/amsg-instant
1162
- * (assertValidDecision / buildSessionContext live in
1163
- * @rei-standard/amsg-shared), so a classifier written for instant's
1164
- * onLLMOutput drops in unchanged: 'tool-request' may carry `toolCalls`
1165
- * directly, or tool_request pushPayloads that embed them — both work.
1166
- *
1167
- * Credential hiding: hook ctx objects never contain apiKey /
1168
- * pushSubscription / vapid / masterKey (same rationale as instant's
1169
- * SessionContext — a console.log(ctx) in a hook must not leak keys).
1170
- *
1171
- * client_state 读写:fireCtx 和每轮的 sessionCtx 上都挂着 readState /
1172
- * writeState,读到和写出的都是客户端 `GET/PUT /client-state` 那套数据。写口
1173
- * 在 sessionCtx 上也给,是因为「这条内容太大、塞不进 push」往往到工具跑完、
1174
- * 组 pushPayloads 时才知道,那时 onBeforeFire 早已返回。
1175
- *
1176
- * scratch:每次 fire 新建一个普通对象,onBeforeFire 的 fireCtx 与同一次
1177
- * fire 每轮的 sessionCtx 都持有同一个引用,hook 之间借它传工具上下文,
1178
- * 不用再自己维护 Map<sessionId, state>。fire 结束(finish / skip-push /
1179
- * 抛错 / 轮数超限)后随调用栈丢弃;库不读不写、不落库、不打日志。
1180
- *
1181
- * 工具声明:onBeforeFire 的返回值里可以带 `tools`(OpenAI 的 tools 数组,
1182
- * 另有可选的 `toolChoice`),本次 fire 的每一轮 LLM 请求都原样带上它们——
1183
- * 补完那轮模型仍可能再发起调用。库不看 tools 的内容,执行照旧是
1184
- * executeToolCalls 的事。
1185
- *
1186
- * 自排后续任务:fireCtx 和每轮的 sessionCtx 上都挂着 scheduleTask,宿主用它
1187
- * 在这次 fire 里给同一个用户再建一条定时任务(「这条发完,一个半小时后我再
1188
- * 接着说一句」)。写口在 sessionCtx 上也给,是因为要不要接着说往往是看完
1189
- * LLM 输出才定的,那时 onBeforeFire 早已返回。凭据(pushSubscription /
1190
- * apiKey)和投递配置从当前任务继承、宿主全程看不到(与 buildHookTask 屏蔽
1191
- * 凭据同一个原则),宿主只提供「什么时候、说什么方向」。
1192
- *
1193
- * 新任务不继承 completePrompt / messages,两者都置 null:fire-time hook 每次
1194
- * 现场重组 prompt,把排程时冻结的旧 prompt 带过去,会在新任务万一走回老链路
1195
- * (onBeforeFire 返回 null、或那次部署没配 hooks)时静默顶替宿主的意图——
1196
- * 与其发一条来路不明的旧文案,不如让它当场失败、走既有的重试/标记逻辑。
1197
- *
1198
- * 护栏(几条都是这个能力能不能上线的关键):
1199
- * - firstSendTime 至少比现在晚 MIN_SCHEDULE_LEAD_MS(60 秒)。cron 每分钟
1200
- * 一跳,排在 60 秒内等于让下一跳立刻捡走,容易变成自己触发自己的紧密循环。
1201
- * - messageType 只收 auto / prompted / fixed,不收 instant。instant 的语义是
1202
- * 「建行的那一刻就投递」,那条路径归 POST /schedule-message 管;从 fire 里
1203
- * 造这么一行,投递时机反而说不清(这里不投,要等 cron 捞到才发)。
1204
- * - 单次 fire 的建任务上限默认 DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE 条,
1205
- * factory 配置 ctx.maxScheduledTasksPerFire 可覆盖(0 表示不许自排)。模型
1206
- * 自排后续本质上是个可以无限延伸的链:没有上限的话,一次 fire 里连排十条、
1207
- * 或者每条任务都排下一条,就成了谁也没按下停止键的循环。
1208
- * - uuid 冲突不抛错,返回 { created: false, reason: 'duplicate' }。fire 失败
1209
- * 会整条重跑(run-tick 的重试语义),宿主传一个由「任务 id + 触发时刻」推
1210
- * 出来的确定性 uuid 就天然幂等,不会每重试一次多排一条。
1211
- *
1212
- * Budget guards, both factory-level (ctx.maxToolIterations /
1213
- * ctx.totalTimeoutMs) and per-fire (onBeforeFire may return
1214
- * { messages, maxToolIterations?, totalTimeoutMs? } to override for one
1215
- * task — e.g. a task the host knows runs slow tools): maxToolIterations
1216
- * caps LLM rounds; totalTimeoutMs is a wall-time ceiling checked before
1217
- * each round so a cron tick can never hang forever. Both exhaustions
1218
- * surface as ordinary task failures → the existing retry / mark-failed
1219
- * semantics apply.
1220
- */
1221
-
1222
-
1223
- const DEFAULT_MAX_TOOL_ITERATIONS = 5;
1224
- const DEFAULT_TOTAL_TIMEOUT_MS = 240_000;
1225
-
1226
- // 一次 fire 里最多能给自己排几条后续任务(ctx.maxScheduledTasksPerFire 可覆盖)。
1227
- const DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE = 2;
1228
- // 自排任务的最小提前量:cron 一分钟一跳,比这更近等于让下一跳立刻捡走。
1229
- const MIN_SCHEDULE_LEAD_MS = 60_000;
1230
-
1231
- // 自排任务允许的类型。instant 归 POST /schedule-message 那条同步路径管。
1232
- const SCHEDULABLE_MESSAGE_TYPES = new Set(['auto', 'prompted', 'fixed']);
1233
- // 与 validateScheduleMessagePayload 同一套;run-tick 只认得这三种,别的值会让
1234
- // 任务发完之后推进不到下一次。
1235
- const SCHEDULABLE_RECURRENCE_TYPES = new Set(['none', 'daily', 'weekly']);
1236
-
1237
- // Same pacing as the legacy path / amsg-instant.
1238
- const SLEEP_BETWEEN_MESSAGES_MS$1 = 1500;
1239
-
1240
- // Task-payload fields that must never reach hook code.
1241
- const CREDENTIAL_PAYLOAD_KEYS = new Set(['apiKey', 'pushSubscription']);
1242
-
1243
- const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1244
-
1245
- /**
1246
- * Does this task need the LLM at fire time? Fixed text never does, so it
1247
- * always stays on the legacy path regardless of hooks.
1248
- *
1249
- * @param {Object} decryptedPayload
1250
- * @returns {boolean}
1251
- */
1252
- function taskNeedsLlm(decryptedPayload) {
1253
- const type = decryptedPayload.messageType;
1254
- if (type === 'prompted' || type === 'auto') return true;
1255
- if (type === 'instant') {
1256
- return !!(decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel);
1257
- }
1258
- return false;
1259
- }
1260
-
1261
- /**
1262
- * Frozen, credential-free view of the task for hook authors.
1263
- *
1264
- * nextSendAt 是这条任务原本的触发时刻。run-tick 领取任务时写的是 lease_until,
1265
- * next_send_at 那一列不动,所以这里给出去的和库里的是同一个值——宿主拿它当
1266
- * 时间锚点(窗口判断、缓存键)时对得上。
1267
- */
1268
- function buildHookTask(task, decryptedPayload) {
1269
- const safe = {};
1270
- for (const [key, value] of Object.entries(decryptedPayload)) {
1271
- if (!CREDENTIAL_PAYLOAD_KEYS.has(key)) safe[key] = value;
1272
- }
1273
- return Object.freeze({
1274
- ...safe,
1275
- id: task.id ?? null,
1276
- uuid: task.uuid ?? null,
1277
- nextSendAt: task.next_send_at ?? null,
1278
- retryCount: task.retry_count ?? 0,
1279
- });
1280
- }
1281
-
1282
- function normalizeBeforeFireResult(result) {
1283
- if (Array.isArray(result)) {
1284
- return { messages: result };
1285
- }
1286
- if (result && typeof result === 'object' && Array.isArray(result.messages)) {
1287
- return {
1288
- messages: result.messages,
1289
- maxToolIterations: result.maxToolIterations,
1290
- totalTimeoutMs: result.totalTimeoutMs,
1291
- // 归一成「有就是非空数组,否则 undefined」,让下面每轮的透传判断能直接
1292
- // 用真值。真正决定请求体带不带 tools 的规则住在 llm.js 的
1293
- // buildAiRequestBody(那边有测试钉着),这层只是不把空数组往下传。
1294
- tools: Array.isArray(result.tools) && result.tools.length > 0 ? result.tools : undefined,
1295
- toolChoice: result.toolChoice,
1296
- };
1297
- }
1298
- throw new TypeError(
1299
- 'AGENTIC_BAD_BEFORE_FIRE: onBeforeFire must return ChatMessage[] | { messages, maxToolIterations?, totalTimeoutMs?, tools?, toolChoice? } | { skip: true } | null'
1300
- );
1301
- }
1302
-
1303
- function firstPositiveInt(values, fallback) {
1304
- for (const v of values) {
1305
- if (Number.isInteger(v) && v > 0) return v;
1306
- }
1307
- return fallback;
1308
- }
1309
-
1310
- function firstPositiveNumber(values, fallback) {
1311
- for (const v of values) {
1312
- if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v;
1313
- }
1314
- return fallback;
1315
- }
1316
-
1317
- /**
1318
- * Try the hook-driven fire path for one task.
1319
- *
1320
- * @param {Object} args
1321
- * @param {import('../adapters/interface.js').TaskRow} args.task
1322
- * @param {Object} args.decryptedPayload - decrypted task payload (has credentials; they stop here)
1323
- * @param {string} args.userKey - per-user storage key (for readState decryption)
1324
- * @param {Object} args.ctx - processor ctx ({ db, webpush, vapid, hooks, maxToolIterations, totalTimeoutMs, maxScheduledTasksPerFire })
1325
- * @returns {Promise<{ handled: false } | { handled: true, result: { success: true, messagesSent: number, status: 'finished'|'skipped', iterations: number } }>}
1326
- * `handled: false` → caller falls back to the legacy frozen-prompt path
1327
- * (onBeforeFire returned null).
1328
- * `onBeforeFire` may also return `{ skip: true }` to complete the fire
1329
- * before the first LLM call → `status: 'skipped', iterations: 0`, same
1330
- * success handling as the post-LLM skip-push path.
1331
- * Failures (timeout / loop exceeded / config errors) throw — the caller's
1332
- * existing error handling turns them into task retry/failure.
1333
- */
1334
- async function runAgenticFire({ task, decryptedPayload, userKey, ctx }) {
1335
- const hooks = ctx.hooks;
1336
- if (typeof hooks.onLLMOutput !== 'function') {
1337
- throw new Error('AGENTIC_CONFIG_ERROR: hooks.onBeforeFire requires hooks.onLLMOutput to classify LLM rounds');
1338
- }
1339
-
1340
- // Injectable seams for tests only (fake clock / no real 1500ms pacing).
1341
- const nowFn = typeof ctx._agenticNow === 'function' ? ctx._agenticNow : Date.now;
1342
- const sleep = typeof ctx._agenticSleep === 'function' ? ctx._agenticSleep : defaultSleep;
1343
-
1344
- const readState = async (namespace) => {
1345
- if (typeof namespace !== 'string' || !namespace.trim()) {
1346
- throw new TypeError('readState(namespace) requires a non-empty string');
1347
- }
1348
- if (!ctx.db || typeof ctx.db.getClientState !== 'function') return [];
1349
- const rows = await ctx.db.getClientState(task.user_id, namespace);
1350
- // 分块存储的值在这里拼回原文(见 state-chunks.js);块不齐全的 key 视为
1351
- // 不存在 —— hook 作者拿到的与客户端写入的一致,永远不会是半截数据。
1352
- return resolveClientStateEntries(
1353
- rows,
1354
- () => ctx.db.getClientState(task.user_id, chunkNamespaceFor(namespace)),
1355
- (value) => decryptFromStorage(value, userKey)
1356
- );
1357
- };
1358
-
1359
- const maxStateValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0
1360
- ? ctx.maxStateValueBytes
1361
- : DEFAULT_MAX_STATE_VALUE_BYTES;
1362
-
1363
- /**
1364
- * writeState(namespace, entries) —— readState 的写入口,落库走的是
1365
- * `PUT /client-state` 那条路径的同一份实现(加密、大值分块、切片清理,见
1366
- * lib/client-state-store.js),所以 hook 写下的东西客户端 `GET /client-state`
1367
- * 能原样读回,反过来也一样。
1368
- *
1369
- * 典型用法是「大内容旁路」:一条 Web Push 的正文只有 4KB 出头(见
1370
- * lib/webpush-webcrypto.js 的 MAX_PUSH_PAYLOAD_BYTES),塞不下的内容先写进
1371
- * client_state,push 里只带一个引用键,客户端上线后按键取回。
1372
- *
1373
- * @param {string} namespace
1374
- * @param {Array<{ key: string, value: string|null, updatedAt?: number }>} entries
1375
- * `value` 是字符串 → 整条覆盖写(不是追加,宿主自己负责序列化);
1376
- * `value` 为 `null` → 删掉这个 key(含它的切片行)。
1377
- * `updatedAt` 默认取当前时刻,语义与客户端同步一致:比库里已有值旧的写入
1378
- * (或删除)不生效,客户端后写的数据不会被这次 fire 盖回去。
1379
- * @returns {Promise<{ upserted: number, skipped: number, deleted: number }>}
1380
- *
1381
- * 谁清、什么时候清:库不做 TTL 也不自动回收,写进去的东西一直在,直到有人
1382
- * 覆盖或删除它。旁路内容建议放在固定的少量 key 上(例如每个角色一个),下次
1383
- * 写同一个 key 直接覆盖,存量天然有上限;一次性的大内容在确认客户端取走后,
1384
- * 用 `{ key, value: null }` 删掉。`DELETE /client-state` 仍然是清空这个用户
1385
- * 全部状态的兜底。
1386
- */
1387
- const writeState = async (namespace, entries) => {
1388
- if (typeof namespace !== 'string' || !namespace.trim()) {
1389
- throw new TypeError('writeState(namespace, entries) requires a non-empty string namespace');
1390
- }
1391
- if (namespace.length > MAX_NAMESPACE_CHARS || INTERNAL_STATE_CHAR_RE.test(namespace)) {
1392
- throw new TypeError(
1393
- `writeState: namespace 必须是 1-${MAX_NAMESPACE_CHARS} 字符且不含控制字符(\\u0000-\\u001f 为库内部保留)`
1394
- );
1395
- }
1396
- if (!Array.isArray(entries)) {
1397
- throw new TypeError('writeState(namespace, entries) requires an array of { key, value }');
1398
- }
1399
- if (entries.length === 0) return { upserted: 0, skipped: 0, deleted: 0 };
1400
- if (entries.length > MAX_STATE_ENTRIES_PER_BATCH) {
1401
- throw new RangeError(`writeState: 单次最多 ${MAX_STATE_ENTRIES_PER_BATCH} 条,收到 ${entries.length} 条`);
1402
- }
1403
- // readState 在适配器不支持时返回空数组(读不到状态,hook 走自己的兜底)。
1404
- // 写不一样:静默成功会让 push 带上一个指向不存在数据的引用键,所以这里报错。
1405
- if (!ctx.db || typeof ctx.db.upsertClientState !== 'function') {
1406
- throw new Error('AGENTIC_STATE_WRITE_UNSUPPORTED: 当前数据库适配器不支持 client_state 写入');
1407
- }
1408
-
1409
- const now = nowFn();
1410
- const normalized = entries.map((entry, index) => {
1411
- if (!entry || typeof entry !== 'object') {
1412
- throw new TypeError(`writeState: entries[${index}] 必须是对象`);
1413
- }
1414
- if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
1415
- throw new TypeError(`writeState: entries[${index}].key 必须是 1-${MAX_KEY_CHARS} 字符的字符串`);
1416
- }
1417
- if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
1418
- throw new TypeError(`writeState: entries[${index}].key 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
1419
- }
1420
- if (entry.value !== null && typeof entry.value !== 'string') {
1421
- throw new TypeError(`writeState: entries[${index}].value 必须是字符串(宿主自行序列化),或 null 表示删除`);
1422
- }
1423
- if (typeof entry.value === 'string') {
1424
- const bytes = stateValueBytes(entry.value);
1425
- if (bytes > maxStateValueBytes) {
1426
- throw new RangeError(`writeState: entries[${index}].value 为 ${bytes} 字节,超过单条上限 ${maxStateValueBytes} 字节`);
1427
- }
1428
- }
1429
- if (entry.updatedAt !== undefined && (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0)) {
1430
- throw new TypeError(`writeState: entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
1431
- }
1432
- return {
1433
- namespace,
1434
- key: entry.key,
1435
- value: entry.value,
1436
- updatedAt: entry.updatedAt ?? now,
1437
- };
1438
- });
1439
-
1440
- return writeClientStateEntries({ db: ctx.db, userId: task.user_id, userKey, entries: normalized });
1441
- };
1442
-
1443
- const maxScheduledTasksPerFire =
1444
- Number.isInteger(ctx.maxScheduledTasksPerFire) && ctx.maxScheduledTasksPerFire >= 0
1445
- ? ctx.maxScheduledTasksPerFire
1446
- : DEFAULT_MAX_SCHEDULED_TASKS_PER_FIRE;
1447
- // 本次 fire 已经用掉的建任务额度。校验通过、真的要写库时才 +1,参数不合法的
1448
- // 调用不占额度;uuid 撞车(那条任务其实已经建出来了)照样占。
1449
- let scheduledTaskCount = 0;
1450
-
1451
- /**
1452
- * scheduleTask(options) —— 在这次 fire 里给**同一个用户**再建一条定时任务。
1453
- *
1454
- * 典型用法:角色发完这条,想过一个半小时再接着说一句。以前只能写进
1455
- * client_state 等客户端上线重放,用户一直不上线这条链就断了;建成任务行之后,
1456
- * 到点由 cron 直接触发,跟别的定时消息一样。
1457
- *
1458
- * 凭据与投递配置(pushSubscription / apiUrl / apiKey / primaryModel /
1459
- * maxTokens / temperature / splitPattern)以及 contactName / avatarUrl /
1460
- * messageSubtype / userMessage 从当前任务继承,宿主只说「什么时候、说什么
1461
- * 方向」——hook 全程看不到凭据。completePrompt / messages 不继承(都置 null),
1462
- * 见文件头。
1463
- *
1464
- * @param {Object} options
1465
- * @param {string} options.firstSendTime - 必填,ISO 8601;至少比现在晚 60 秒
1466
- * @param {'none'|'daily'|'weekly'} [options.recurrenceType='none']
1467
- * @param {'auto'|'prompted'|'fixed'} [options.messageType] - 默认继承当前任务
1468
- * @param {Object} [options.metadata] - 整体替换(不是深合并)当前任务的 metadata
1469
- * @param {string} [options.contactName] - 默认继承
1470
- * @param {string|null} [options.avatarUrl] - 默认继承
1471
- * @param {string} [options.messageSubtype] - 默认继承
1472
- * @param {string|null} [options.userMessage] - 默认继承;messageType 'fixed' 时必须有
1473
- * @param {string} [options.uuid] - 默认 randomUUID();传确定性 uuid 可做重试幂等
1474
- * @returns {Promise<{ created: true, id: number|null, uuid: string, nextSendAt: string }
1475
- * | { created: false, reason: 'duplicate', uuid: string }>}
1476
- */
1477
- const scheduleTask = async (options) => {
1478
- if (!options || typeof options !== 'object' || Array.isArray(options)) {
1479
- throw new TypeError('scheduleTask(options) 需要一个对象,至少包含 { firstSendTime }');
1480
- }
1481
-
1482
- // ---- 触发时刻 ----
1483
- if (typeof options.firstSendTime !== 'string' || !options.firstSendTime.trim()) {
1484
- throw new RangeError('scheduleTask: firstSendTime 必填,且必须是 ISO 8601 字符串');
1485
- }
1486
- const firstSendAt = new Date(options.firstSendTime);
1487
- if (Number.isNaN(firstSendAt.getTime())) {
1488
- throw new RangeError(`scheduleTask: firstSendTime 解析不出合法时间:${options.firstSendTime}`);
1489
- }
1490
- const earliest = nowFn() + MIN_SCHEDULE_LEAD_MS;
1491
- if (firstSendAt.getTime() < earliest) {
1492
- throw new RangeError(
1493
- `scheduleTask: firstSendTime 至少要比现在晚 ${MIN_SCHEDULE_LEAD_MS / 1000} 秒` +
1494
- `(最早可排 ${new Date(earliest).toISOString()},收到 ${firstSendAt.toISOString()})` +
1495
- '——cron 一分钟一跳,排得更近等于让下一跳立刻捡走'
1496
- );
1497
- }
1498
- const nextSendAt = firstSendAt.toISOString();
1499
-
1500
- // ---- 类型 ----
1501
- const inheritedType = options.messageType == null;
1502
- const messageType = inheritedType ? decryptedPayload.messageType : options.messageType;
1503
- if (messageType === 'instant') {
1504
- throw new TypeError(
1505
- "scheduleTask: messageType 不能是 'instant'——instant 的语义是「建行的那一刻就投递」," +
1506
- '那条路径归 POST /schedule-message 管;从 fire 里建一条 instant,投递时机反而说不清。' +
1507
- (inheritedType ? '(这个 instant 是从当前任务继承来的,显式传 auto / prompted / fixed 覆盖它。)' : '')
1508
- );
1509
- }
1510
- if (!SCHEDULABLE_MESSAGE_TYPES.has(messageType)) {
1511
- throw new TypeError(
1512
- `scheduleTask: messageType 只能是 auto / prompted / fixed,收到 ${JSON.stringify(messageType)}`
1513
- );
1514
- }
1515
-
1516
- const recurrenceType = options.recurrenceType == null ? 'none' : options.recurrenceType;
1517
- if (!SCHEDULABLE_RECURRENCE_TYPES.has(recurrenceType)) {
1518
- throw new TypeError(
1519
- `scheduleTask: recurrenceType 只能是 none / daily / weekly,收到 ${JSON.stringify(recurrenceType)}`
1520
- );
1521
- }
1522
-
1523
- // ---- 继承 + 覆盖 ----
1524
- const contactName = options.contactName === undefined ? decryptedPayload.contactName : options.contactName;
1525
- if (typeof contactName !== 'string' || !contactName.trim()) {
1526
- throw new TypeError('scheduleTask: contactName 必须是非空字符串(默认继承当前任务)');
1527
- }
1528
- const userMessage = options.userMessage === undefined
1529
- ? (decryptedPayload.userMessage ?? null)
1530
- : options.userMessage;
1531
- if (messageType === 'fixed' && (typeof userMessage !== 'string' || !userMessage.trim())) {
1532
- throw new TypeError(
1533
- "scheduleTask: messageType 'fixed' 必须有 userMessage(自己传,或从当前任务继承到)" +
1534
- '——固定文本任务没有正文,就是一条永远发空的任务'
1535
- );
1536
- }
1537
- const metadata = options.metadata === undefined ? (decryptedPayload.metadata || {}) : options.metadata;
1538
- if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
1539
- throw new TypeError('scheduleTask: metadata 必须是普通对象(整体替换当前任务的 metadata,不做深合并)');
1540
- }
1541
- const uuid = options.uuid == null ? randomUUID() : options.uuid;
1542
- if (typeof uuid !== 'string' || !uuid.trim()) {
1543
- throw new TypeError('scheduleTask: uuid 必须是非空字符串');
1544
- }
1545
-
1546
- // ---- 额度 ----
1547
- if (scheduledTaskCount >= maxScheduledTasksPerFire) {
1548
- throw new RangeError(
1549
- `scheduleTask: 单次 fire 最多建 ${maxScheduledTasksPerFire} 条任务` +
1550
- `(factory 配置 maxScheduledTasksPerFire 可调),这是第 ${scheduledTaskCount + 1} 条`
1551
- );
1552
- }
1553
- // readState 在适配器不支持时降级成空数组;建任务不一样,静默成功会让宿主
1554
- // 以为后续那条已经排上了,其实谁也不会触发它。
1555
- if (!ctx.db || typeof ctx.db.createTask !== 'function') {
1556
- throw new Error('AGENTIC_SCHEDULE_UNSUPPORTED: 当前数据库适配器不支持建任务(缺 createTask)');
1557
- }
1558
- scheduledTaskCount++;
1559
-
1560
- // 字段构成与 POST /schedule-message 落库的那份保持一致,只是走库内部、不经 HTTP。
1561
- const fullTaskData = {
1562
- contactName,
1563
- avatarUrl: options.avatarUrl === undefined ? (decryptedPayload.avatarUrl || null) : (options.avatarUrl || null),
1564
- messageType,
1565
- messageSubtype: (options.messageSubtype === undefined ? decryptedPayload.messageSubtype : options.messageSubtype) || 'chat',
1566
- userMessage: userMessage || null,
1567
- firstSendTime: nextSendAt,
1568
- recurrenceType,
1569
- apiUrl: decryptedPayload.apiUrl || null,
1570
- apiKey: decryptedPayload.apiKey || null,
1571
- primaryModel: decryptedPayload.primaryModel || null,
1572
- // 见文件头:fire-time hook 每次现场重组 prompt,旧 prompt 带过去只会在新
1573
- // 任务走回老链路时顶替宿主的意图。
1574
- completePrompt: null,
1575
- messages: null,
1576
- maxTokens: decryptedPayload.maxTokens ?? null,
1577
- temperature: decryptedPayload.temperature ?? null,
1578
- splitPattern: decryptedPayload.splitPattern ?? null,
1579
- pushSubscription: decryptedPayload.pushSubscription,
1580
- metadata,
1581
- };
1582
-
1583
- const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
1584
-
1585
- let created;
1586
- try {
1587
- created = await ctx.db.createTask({
1588
- user_id: task.user_id,
1589
- uuid,
1590
- encrypted_payload: encryptedPayload,
1591
- next_send_at: nextSendAt,
1592
- message_type: messageType,
1593
- });
1594
- } catch (error) {
1595
- // 撞 uuid 不算错:fire 失败会整条重跑,宿主用确定性 uuid(任务 id + 触发
1596
- // 时刻推出来的那种)就天然幂等,重试不会多排一条。
1597
- if (isUniqueViolation(error)) return { created: false, reason: 'duplicate', uuid };
1598
- throw error;
1599
- }
1600
- if (!created) {
1601
- throw new Error('AGENTIC_SCHEDULE_FAILED: createTask 没有返回新建的任务行');
1602
- }
1603
- return {
1604
- created: true,
1605
- id: created.id ?? null,
1606
- uuid: created.uuid ?? uuid,
1607
- nextSendAt: created.next_send_at ?? nextSendAt,
1608
- };
1609
- };
1610
-
1611
- // 单次 fire 的宿主便签:onBeforeFire 的 fireCtx 和同一次 fire 每轮的
1612
- // sessionCtx(onLLMOutput / executeToolCalls)拿到同一个对象引用,fire 结束
1613
- // (finish / skip-push / 抛错 / 轮数超限)随调用栈丢弃。库自己不读不写、
1614
- // 不落库、不打日志、不跨 fire 共享 —— 重试产生的新 fire 拿到的是新对象。
1615
- const scratch = {};
1616
-
1617
- const fireCtx = Object.freeze({
1618
- task: buildHookTask(task, decryptedPayload),
1619
- userId: task.user_id,
1620
- readState,
1621
- writeState,
1622
- scheduleTask,
1623
- now: new Date(nowFn()),
1624
- scratch,
1625
- });
1626
-
1627
- const before = await hooks.onBeforeFire(fireCtx);
1628
- if (before == null) return { handled: false };
1629
-
1630
- // Pre-LLM skip: the host judged this fire moot before generation (e.g. the
1631
- // conversation moved on after the task was scheduled). Shaped exactly like
1632
- // the post-LLM 'skipped' result, so run-tick's success handling (delete
1633
- // once-off / advance recurrence) applies unchanged — and zero tokens spent.
1634
- if (typeof before === 'object' && before.skip === true) {
1635
- return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: 0 } };
1636
- }
1637
-
1638
- const normalized = normalizeBeforeFireResult(before);
1639
- const maxToolIterations = firstPositiveInt(
1640
- [normalized.maxToolIterations, ctx.maxToolIterations],
1641
- DEFAULT_MAX_TOOL_ITERATIONS
1642
- );
1643
- const totalTimeoutMs = firstPositiveNumber(
1644
- [normalized.totalTimeoutMs, ctx.totalTimeoutMs],
1645
- DEFAULT_TOTAL_TIMEOUT_MS
1646
- );
1647
- const deadline = nowFn() + totalTimeoutMs;
1648
-
1649
- // Same sessionId scheme as the legacy path: pinned to the task id so a
1650
- // retried task reuses the same session and clients can group/dedupe.
1651
- const sessionId = task.id != null ? `sess_task_${task.id}` : `sess_${randomUUID()}`;
1652
- let messages = normalized.messages.slice();
1653
-
1654
- for (let iteration = 0; iteration < maxToolIterations; iteration++) {
1655
- if (nowFn() >= deadline) {
1656
- throw new Error(`AGENTIC_TOTAL_TIMEOUT: fire chain exceeded ${totalTimeoutMs}ms after ${iteration} LLM round(s)`);
1657
- }
1658
-
1659
- // Shrink each round's fetch timeout to the remaining wall-time budget
1660
- // (capped at the legacy 300s single-call ceiling) — a hung LLM request
1661
- // must not outlive totalTimeoutMs waiting for its own 300s abort.
1662
- const roundTimeoutMs = Math.max(1, Math.min(300_000, deadline - nowFn()));
1663
- const { response: llmResponse } = await callLlm(
1664
- {
1665
- ...decryptedPayload,
1666
- messages,
1667
- ...(normalized.tools ? { tools: normalized.tools, toolChoice: normalized.toolChoice } : {}),
1668
- },
1669
- { requireContent: false, timeoutMs: roundTimeoutMs }
1670
- );
1671
-
1672
- const assistantMessage = extractAssistantMessage(llmResponse);
1673
- messages = [...messages, assistantMessage];
1674
-
1675
- // 共享的 SessionContext(与 amsg-instant 同形状)之上,再挂两个状态访问器
1676
- // 和 scheduleTask:大内容要不要旁路存、要不要给自己排条后续,往往到工具跑完、
1677
- // 组 push 时才知道,而那正是 onLLMOutput / executeToolCalls 的位置,
1678
- // onBeforeFire 早就返回了。
1679
- const sessionCtx = Object.freeze({
1680
- ...buildSessionContext({
1681
- sessionId,
1682
- messages,
1683
- llmResponse,
1684
- iteration,
1685
- contactName: decryptedPayload.contactName,
1686
- avatarUrl: decryptedPayload.avatarUrl || undefined,
1687
- charId: decryptedPayload.charId,
1688
- metadata: decryptedPayload.metadata,
1689
- scratch,
1690
- }),
1691
- readState,
1692
- writeState,
1693
- scheduleTask,
1694
- });
1695
-
1696
- const decision = await hooks.onLLMOutput(sessionCtx);
1697
- assertValidDecision(decision, { inlineToolCalls: true });
1698
-
1699
- if (decision.decision === 'continue') {
1700
- messages = decision.nextHistory.slice();
1701
- continue;
1702
- }
1703
-
1704
- if (decision.decision === 'skip-push') {
1705
- return { handled: true, result: { success: true, messagesSent: 0, status: 'skipped', iterations: iteration + 1 } };
1706
- }
1707
-
1708
- if (decision.decision === 'finish') {
1709
- const messagesSent = await sendHookPushPayloads(decision.pushPayloads, decryptedPayload, ctx, sessionId, task, sleep);
1710
- return { handled: true, result: { success: true, messagesSent, status: 'finished', iterations: iteration + 1 } };
1711
- }
1712
-
1713
- // 'tool-request' — execute right here in the worker.
1714
- const toolCalls = extractToolCallsFromDecision(decision);
1715
- if (toolCalls.length === 0) {
1716
- throw new Error('AGENTIC_EMPTY_TOOL_REQUEST: tool-request decision carried no toolCalls (neither decision.toolCalls nor pushPayloads[].toolCalls)');
1717
- }
1718
- if (typeof hooks.executeToolCalls !== 'function') {
1719
- throw new Error('AGENTIC_CONFIG_ERROR: onLLMOutput returned tool-request but hooks.executeToolCalls is not configured');
1720
- }
1721
- if (iteration === maxToolIterations - 1) {
1722
- // No LLM round left to consume the results — executing tools now
1723
- // would only burn external calls. Fall straight to the exceeded error.
1724
- break;
1725
- }
1726
-
1727
- let toolResults;
1728
- try {
1729
- toolResults = await hooks.executeToolCalls(toolCalls, sessionCtx);
1730
- if (!Array.isArray(toolResults)) {
1731
- throw new TypeError('executeToolCalls must resolve to an array of { tool_call_id, role: "tool", content }');
1732
- }
1733
- } catch (error) {
1734
- // Feed the failure back as tool results and let the LLM talk its way
1735
- // out, instead of failing the whole fire.
1736
- toolResults = toolCalls.map((toolCall) => ({
1737
- tool_call_id: toolCall && typeof toolCall === 'object' && typeof toolCall.id === 'string' ? toolCall.id : '',
1738
- role: 'tool',
1739
- content: `Tool execution failed: ${error?.message ?? String(error)}`,
1740
- }));
1741
- }
1742
-
1743
- // Text-protocol classifiers synthesize toolCalls the raw assistant
1744
- // message doesn't carry; stamp them on so the appended role:'tool'
1745
- // results stay valid for OpenAI-compatible APIs. Merge rather than pick
1746
- // one side: when a native tool_call and a synthesized one land in the
1747
- // same round, both need their id on the assistant turn, or the half
1748
- // that's missing leaves an orphan role:'tool' and a strict relay
1749
- // rejects the next round.
1750
- //
1751
- // Contract for onLLMOutput: the toolCalls it returns must cover every
1752
- // native tool_call the model declared. A native call left out still
1753
- // gets declared on the assistant turn (it came from the model) but has
1754
- // no matching role:'tool' result, and a strict relay rejects the next
1755
- // round over the unanswered id. Drop a call on purpose → return a
1756
- // result for it saying so, don't just omit it.
1757
- const nativeCalls = Array.isArray(assistantMessage.tool_calls) ? assistantMessage.tool_calls : [];
1758
- const nativeIds = new Set(nativeCalls.map((tc) => tc && tc.id));
1759
- const synthesized = toolCalls.filter((tc) => !nativeIds.has(tc && tc.id));
1760
- const assistantWithTools = synthesized.length === 0
1761
- ? assistantMessage
1762
- : { ...assistantMessage, tool_calls: [...nativeCalls, ...synthesized] };
1763
- messages = [...messages.slice(0, -1), assistantWithTools, ...toolResults];
1764
- }
1765
-
1766
- throw new Error(`AGENTIC_LOOP_EXCEEDED: no finish/skip-push decision within ${maxToolIterations} LLM round(s)`);
1767
- }
1768
-
1769
- /**
1770
- * Deliver the hook's pushPayloads sequentially. Mirrors instant's
1771
- * sendPushesSequentially: force-overwrite messageIndex/totalMessages,
1772
- * stamp missing ids, pace with the same 1500ms spacing. Ids are
1773
- * deterministic per (task, index) so a retried task reuses the same ids
1774
- * and clients can dedupe.
1775
- */
1776
- async function sendHookPushPayloads(pushPayloads, decryptedPayload, ctx, sessionId, task, sleep) {
1777
- if (!ctx.vapid || !ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1778
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
1779
- }
1780
- const pushSubscription = decryptedPayload.pushSubscription;
1781
- const total = pushPayloads.length;
1782
- const messageIdBase = task.id != null ? `msg_task_${task.id}` : `msg_${randomUUID()}`;
1783
-
1784
- for (let i = 0; i < total; i++) {
1785
- const push = { ...pushPayloads[i] };
1786
- if (typeof push.messageId !== 'string' || !push.messageId) push.messageId = `${messageIdBase}_hook_${i}`;
1787
- if (typeof push.sessionId !== 'string' || !push.sessionId) push.sessionId = sessionId;
1788
- if (typeof push.timestamp !== 'string' || !push.timestamp) push.timestamp = new Date().toISOString();
1789
- push.messageIndex = i + 1;
1790
- push.totalMessages = total;
1791
-
1792
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(push));
1793
- if (i < total - 1) await sleep(SLEEP_BETWEEN_MESSAGES_MS$1);
1794
- }
1795
- return total;
1796
- }
1797
-
1798
- /**
1799
- * Message Processor (SDK version)
1800
- * ReiStandard amsg-server v2.4.0
1801
- *
1802
- * Handles single message content generation and Web Push delivery for
1803
- * scheduled tasks (`fixed` / `prompted` / `auto`) and the
1804
- * in-server instant path (`messageType: 'instant'`).
1805
- *
1806
- * Push wire shape comes from `@rei-standard/amsg-shared`'s
1807
- * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
1808
- * routes on `messageKind`. Server-driven pushes always carry
1809
- * `source: 'instant'` (for the in-server instant path) or
1810
- * `source: 'scheduled'` (for everything else).
1811
- *
1812
- * v2.4.0: when the LLM response carries non-empty
1813
- * `choices[0].message.reasoning_content`, the processor now emits a
1814
- * standalone `ReasoningPush` **before** the `ContentPush` burst.
1815
- * `messagesSent` in the return value continues to reflect the sentence
1816
- * count only (reasoning is an auxiliary push, not a sentence).
1817
- */
1818
-
1819
-
1820
- const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
1821
-
1822
- // Pacing between consecutive Web Push deliveries (reasoning → content, and
1823
- // between content sentences) so the client renders a natural typing cadence.
1824
- // Kept equal to amsg-instant's SLEEP_BETWEEN_MESSAGES_MS default.
1825
- const SLEEP_BETWEEN_MESSAGES_MS = 1500;
1826
-
1827
- /**
1828
- * Split a single chunk by one regex; on no-match return [chunk] so a later
1829
- * regex in a cascade can still take a swing at it.
1830
- */
1831
- function splitOnceByRegex(chunk, regex) {
1832
- const out = chunk
1833
- .split(regex)
1834
- .reduce((acc, part, i, arr) => {
1835
- if (i % 2 === 0 && part.trim()) {
1836
- const punctuation = arr[i + 1] || '';
1837
- acc.push(part.trim() + punctuation);
1838
- }
1839
- return acc;
1840
- }, [])
1841
- .filter(s => s.length > 0);
1842
- return out.length > 0 ? out : [chunk];
1843
- }
1844
-
1845
- /**
1846
- * Sentence splitter for amsg-server's scheduled `splitPattern` feature
1847
- * (see standards §6.1). Server-only: amsg-instant 0.8.0 dropped its
1848
- * request-level `splitPattern`, so there is no instant counterpart to keep
1849
- * in lockstep.
1850
- *
1851
- * @param {string} messageContent
1852
- * @param {string | string[] | null} [splitPattern=null]
1853
- * @returns {string[]}
1854
- */
1855
- function splitMessageIntoSentences(messageContent, splitPattern = null) {
1856
- const sources =
1857
- splitPattern == null ? null :
1858
- Array.isArray(splitPattern) ? splitPattern :
1859
- [splitPattern];
1860
-
1861
- const regexes = (sources && sources.length > 0)
1862
- ? sources.map(s => new RegExp(s))
1863
- : [DEFAULT_SPLIT_REGEX];
1864
-
1865
- let chunks = [messageContent];
1866
- for (const regex of regexes) {
1867
- chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
1868
- }
1869
-
1870
- return chunks.length > 0 ? chunks : [messageContent];
1871
- }
1872
-
1873
- /**
1874
- * @typedef {Object} ProcessorContext
1875
- * @property {Object} webpush - The web-push module instance (already VAPID-configured).
1876
- * @property {Object} vapid - { email, publicKey, privateKey }
1877
- * @property {import('../adapters/interface.js').DbAdapter} db
1878
- */
1879
-
1880
- /**
1881
- * Process a single database task row: decrypt → generate content → push.
1882
- *
1883
- * @param {import('../adapters/interface.js').TaskRow} task
1884
- * @param {ProcessorContext} ctx
1885
- * @param {string} [providedMasterKey]
1886
- * @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
1887
- */
1888
- async function processSingleMessage(task, ctx, providedMasterKey) {
1889
- try {
1890
- const masterKey = providedMasterKey || ctx.masterKey;
1891
- if (!masterKey) {
1892
- return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
1893
- }
1894
-
1895
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
1896
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
1897
-
1898
- // Fire-time hooks: when the host configured onBeforeFire and the task
1899
- // needs the LLM, offer the agentic path first. onBeforeFire → null
1900
- // falls straight through to the frozen-prompt chain below, and
1901
- // deployments without hooks never enter this branch — legacy behavior
1902
- // is byte-identical. onBeforeFire → { skip: true } completes the fire
1903
- // here as a zero-push success (no LLM call, no frozen-prompt fallback):
1904
- // use it when the host can tell at fire time the message is moot.
1905
- if (ctx.hooks && typeof ctx.hooks.onBeforeFire === 'function' && taskNeedsLlm(decryptedPayload)) {
1906
- const agentic = await runAgenticFire({ task, decryptedPayload, userKey, ctx });
1907
- if (agentic.handled) return agentic.result;
1908
- }
1909
-
1910
- let messageContent;
1911
- /** @type {unknown} */
1912
- let llmResponse = null;
1913
-
1914
- if (decryptedPayload.messageType === 'fixed') {
1915
- messageContent = decryptedPayload.userMessage;
1916
-
1917
- } else if (decryptedPayload.messageType === 'instant') {
1918
- const hasPrompt = !!decryptedPayload.completePrompt
1919
- || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
1920
- if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
1921
- const aiResult = await callLlm(decryptedPayload);
1922
- messageContent = aiResult.content;
1923
- llmResponse = aiResult.response;
1924
- } else if (decryptedPayload.userMessage) {
1925
- messageContent = decryptedPayload.userMessage;
1926
- } else {
1927
- throw new Error('Invalid instant message: no content source available');
1928
- }
1929
-
1930
- } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
1931
- const aiResult = await callLlm(decryptedPayload);
1932
- messageContent = aiResult.content;
1933
- llmResponse = aiResult.response;
1934
- } else {
1935
- throw new Error('Invalid message configuration: no content source available');
1936
- }
1937
-
1938
- // Auto-extract reasoning BEFORE the sentence split: when reasoning
1939
- // came from the `<think>` fallback inside message.content, the same
1940
- // span is still embedded in messageContent and would otherwise leak
1941
- // as raw markup into ContentPush.
1942
- const reasoning = readReasoningContent(llmResponse);
1943
- if (reasoning) {
1944
- messageContent = stripReasoningTags(messageContent);
1945
- }
1946
-
1947
- // Sentence splitting (mirrors @rei-standard/amsg-instant
1948
- // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
1949
- // override the default regex via decryptedPayload.splitPattern (string
1950
- // for a single regex, string[] for a cascade). Validation already enforces
1951
- // length cap + RegExp compilability upstream.
1952
- const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
1953
-
1954
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
1955
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
1956
- }
1957
-
1958
- const pushSubscription = decryptedPayload.pushSubscription;
1959
- // sessionId is shared across the optional ReasoningPush and every
1960
- // ContentPush from this LLM round. Pin it to the task id when
1961
- // available (scheduled tasks) so retries reuse the same id;
1962
- // otherwise mint a UUID.
1963
- const sessionId = task.id != null
1964
- ? `sess_task_${task.id}`
1965
- : `sess_${randomUUID()}`;
1966
- const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
1967
- const messageSubtype = decryptedPayload.messageSubtype || 'chat';
1968
- const avatarUrl = decryptedPayload.avatarUrl || null;
1969
- const metadata = decryptedPayload.metadata || {};
1970
-
1971
- // `messageId` format — deterministic when we have a task.id so a
1972
- // retry produces the same id for the same (task, sentence) pair
1973
- // (downstream dedupers can key on it). Falls back to a UUID for
1974
- // the in-server instant path that has no row id.
1975
- const messageIdBase = task.id != null
1976
- ? `msg_task_${task.id}`
1977
- : `msg_${randomUUID()}_instant`;
1978
-
1979
- // ReasoningPush — auto-emitted before the content burst when the
1980
- // LLM response carried non-empty reasoning_content. `fixed` and
1981
- // explicit-userMessage paths produce no LLM response, so this
1982
- // block is naturally skipped for them (llmResponse stays null).
1983
- if (reasoning) {
1984
- const reasoningPush = buildReasoningPush({
1985
- messageType: decryptedPayload.messageType,
1986
- source,
1987
- messageId: `${messageIdBase}_reasoning`,
1988
- sessionId,
1989
- reasoningContent: reasoning,
1990
- timestamp: new Date().toISOString(),
1991
- title: `来自 ${decryptedPayload.contactName}`,
1992
- contactName: decryptedPayload.contactName,
1993
- avatarUrl,
1994
- messageSubtype,
1995
- metadata,
1996
- });
1997
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
1998
- await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
1999
- }
2000
-
2001
- for (let i = 0; i < messages.length; i++) {
2002
- const contentPush = buildContentPush({
2003
- messageType: decryptedPayload.messageType,
2004
- source,
2005
- messageId: `${messageIdBase}_${i}`,
2006
- sessionId,
2007
- message: messages[i],
2008
- timestamp: new Date().toISOString(),
2009
- title: `来自 ${decryptedPayload.contactName}`,
2010
- contactName: decryptedPayload.contactName,
2011
- avatarUrl,
2012
- messageSubtype,
2013
- messageIndex: i + 1,
2014
- totalMessages: messages.length,
2015
- taskId: task.id || null,
2016
- metadata,
2017
- });
2018
-
2019
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
2020
-
2021
- if (i < messages.length - 1) {
2022
- await new Promise(resolve => setTimeout(resolve, SLEEP_BETWEEN_MESSAGES_MS));
2023
- }
2024
- }
2025
-
2026
- return { success: true, messagesSent: messages.length };
2027
-
2028
- } catch (error) {
2029
- return { success: false, messagesSent: 0, error: error.message };
2030
- }
2031
- }
2032
-
2033
- /**
2034
- * Process a single message identified by UUID (used for instant type).
2035
- *
2036
- * @param {string} uuid
2037
- * @param {ProcessorContext} ctx
2038
- * @param {number} [maxRetries=2]
2039
- * @param {string} [userId]
2040
- * @param {string} [providedMasterKey]
2041
- * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
2042
- */
2043
- async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
2044
- let retryCount = 0;
2045
- const masterKey = providedMasterKey || ctx.masterKey;
2046
-
2047
- if (!masterKey) {
2048
- return {
2049
- success: false,
2050
- error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
2051
- };
2052
- }
2053
-
2054
- while (retryCount <= maxRetries) {
2055
- let task;
2056
- try {
2057
- task = userId
2058
- ? await ctx.db.getTaskByUuid(uuid, userId)
2059
- : await ctx.db.getTaskByUuidOnly(uuid);
2060
- } catch (error) {
2061
- if (retryCount < maxRetries) {
2062
- retryCount++;
2063
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
2064
- continue;
2065
- }
2066
-
2067
- return {
2068
- success: false,
2069
- error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
2070
- };
2071
- }
2072
-
2073
- if (!task) {
2074
- return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
2075
- }
2076
-
2077
- const result = await processSingleMessage(task, ctx, masterKey);
2078
-
2079
- if (!result.success) {
2080
- if (retryCount < maxRetries) {
2081
- retryCount++;
2082
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
2083
- continue;
2084
- }
2085
-
2086
- try {
2087
- await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
2088
- } catch (_updateError) {
2089
- // best-effort status update; keep original processing error as primary signal
2090
- }
2091
-
2092
- return {
2093
- success: false,
2094
- error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
2095
- };
2096
- }
2097
-
2098
- try {
2099
- await ctx.db.deleteTaskById(task.id);
2100
- } catch (error) {
2101
- try {
2102
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
2103
- } catch (_markSentError) {
2104
- // best effort: avoid re-sending if storage mutation partially fails
2105
- }
2106
-
2107
- return {
2108
- success: false,
2109
- error: {
2110
- code: 'POST_SEND_CLEANUP_FAILED',
2111
- message: '消息已发送,但任务清理失败',
2112
- details: { error: error.message }
2113
- }
2114
- };
2115
- }
2116
-
2117
- return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
2118
- }
2119
- }
2120
-
2121
- /**
2122
- * Handler: schedule-message
2123
- * ReiStandard SDK v2.0.1
2124
- *
2125
- * @param {Object} ctx - Server context.
2126
- * @returns {{ POST: function }}
2127
- */
2128
-
2129
-
2130
- function createScheduleMessageHandler(ctx) {
2131
- async function POST(headers, body) {
2132
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
2133
- if (!tenantResult.ok) {
2134
- return tenantResult.error;
2135
- }
2136
-
2137
- const tenantCtx = tenantResult.context;
2138
- const db = tenantCtx.db;
2139
- const masterKey = tenantCtx.masterKey;
2140
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
2141
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
2142
- const userId = getHeader(headers, 'x-user-id');
2143
-
2144
- if (!isEncrypted) {
2145
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
2146
- }
2147
- if (!userId) {
2148
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
2149
- }
2150
- if (!isValidUUIDv4(userId)) {
2151
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
2152
- }
2153
- if (encryptionVersion !== '1') {
2154
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
2155
- }
2156
-
2157
- // Decrypt request body
2158
- const parsedBody = parseEncryptedBody(body);
2159
- if (!parsedBody.ok) {
2160
- return { status: 400, body: { success: false, error: parsedBody.error } };
2161
- }
2162
-
2163
- const encryptedBody = parsedBody.data;
2164
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
2165
-
2166
- let payload;
2167
- try {
2168
- payload = await decryptPayload(encryptedBody, userKey);
2169
- } catch (error) {
2170
- if (error instanceof SyntaxError) {
2171
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
2172
- }
2173
-
2174
- const message = typeof error.message === 'string' ? error.message : '';
2175
- if (message.includes('auth') || message.includes('Unsupported state')) {
2176
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
2177
- }
2178
-
2179
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
2180
- }
2181
-
2182
- if (!isPlainObject(payload)) {
2183
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
2184
- }
2185
-
2186
- // Validate
2187
- const validationResult = validateScheduleMessagePayload(payload);
2188
- if (!validationResult.valid) {
2189
- return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
2190
- }
2191
-
2192
- const taskUuid = payload.uuid || randomUUID();
2193
-
2194
- const fullTaskData = {
2195
- contactName: payload.contactName,
2196
- avatarUrl: payload.avatarUrl || null,
2197
- messageType: payload.messageType,
2198
- messageSubtype: payload.messageSubtype || 'chat',
2199
- userMessage: payload.userMessage || null,
2200
- firstSendTime: payload.firstSendTime,
2201
- recurrenceType: payload.recurrenceType || 'none',
2202
- apiUrl: payload.apiUrl || null,
2203
- apiKey: payload.apiKey || null,
2204
- primaryModel: payload.primaryModel || null,
2205
- // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
2206
- // style array). Validation has already enforced exactly-one-of, so
2207
- // exactly one of these will be non-null when an AI config is provided.
2208
- completePrompt: payload.completePrompt || null,
2209
- messages: Array.isArray(payload.messages) ? payload.messages : null,
2210
- maxTokens: payload.maxTokens ?? null,
2211
- temperature: payload.temperature ?? null,
2212
- // 0.6.0+: optional caller-provided regex (string or string[]) used by
2213
- // the message processor to chunk LLM output into individual pushes.
2214
- // null → processor falls back to the default /([。!?!?]+)/ regex.
2215
- splitPattern: payload.splitPattern ?? null,
2216
- pushSubscription: payload.pushSubscription,
2217
- metadata: payload.metadata || {}
2218
- };
2219
-
2220
- const encryptedPayload = await encryptForStorage(JSON.stringify(fullTaskData), userKey);
2221
-
2222
- /**
2223
- * In-server instant path. Delivers an instant message through this
2224
- * server's own task queue (create task → process by UUID → delete task).
2225
- * The task is written to the database before processing, so delivery is
2226
- * not tied to the request connection: even if the client disconnects, the
2227
- * row stays and the generation keeps running (and can be retried) for as
2228
- * long as it needs. Use this when you have a database and want long or
2229
- * guaranteed-complete generations with no dropped messages.
2230
- *
2231
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
2232
- * over SSE with a Web Push backup and needs no database, which makes it a
2233
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
2234
- * response connection, so after the client disconnects it only has the
2235
- * platform's brief grace window to finish (≈20-30s observed on Deno
2236
- * Deploy) — ideal for short instant messages that complete quickly.
2237
- */
2238
- // Instant type: check VAPID before creating the task to avoid orphaned rows
2239
- if (payload.messageType === 'instant') {
2240
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
2241
- return {
2242
- status: 500,
2243
- body: {
2244
- success: false,
2245
- error: {
2246
- code: 'VAPID_CONFIG_ERROR',
2247
- message: 'VAPID 配置缺失,无法发送即时消息',
2248
- details: {
2249
- missingKeys: [
2250
- !ctx.vapid.email && 'VAPID_EMAIL',
2251
- !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
2252
- !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
2253
- ].filter(Boolean)
2254
- }
2255
- }
2256
- }
2257
- };
2258
- }
2259
- }
2260
-
2261
- // Insert into database
2262
- let dbResult;
2263
- try {
2264
- dbResult = await db.createTask({
2265
- user_id: userId,
2266
- uuid: taskUuid,
2267
- encrypted_payload: encryptedPayload,
2268
- next_send_at: payload.firstSendTime,
2269
- message_type: payload.messageType
2270
- });
2271
- } catch (error) {
2272
- if (isUniqueViolation(error)) {
2273
- return {
2274
- status: 409,
2275
- body: {
2276
- success: false,
2277
- error: {
2278
- code: 'TASK_UUID_CONFLICT',
2279
- message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
2280
- }
2281
- }
2282
- };
2283
- }
2284
- throw error;
2285
- }
2286
-
2287
- if (!dbResult) {
2288
- return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
2289
- }
2290
-
2291
- /**
2292
- * In-server instant path. Delivers an instant message through this
2293
- * server's own task queue (create task → process by UUID → delete task).
2294
- * The task is written to the database before processing, so delivery is
2295
- * not tied to the request connection: even if the client disconnects, the
2296
- * row stays and the generation keeps running (and can be retried) for as
2297
- * long as it needs. Use this when you have a database and want long or
2298
- * guaranteed-complete generations with no dropped messages.
2299
- *
2300
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
2301
- * over SSE with a Web Push backup and needs no database, which makes it a
2302
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
2303
- * response connection, so after the client disconnects it only has the
2304
- * platform's brief grace window to finish (≈20-30s observed on Deno
2305
- * Deploy) — ideal for short instant messages that complete quickly.
2306
- */
2307
- // Instant type: send immediately
2308
- if (payload.messageType === 'instant') {
2309
- try {
2310
- const sendResult = await processMessagesByUuid(taskUuid, {
2311
- ...ctx,
2312
- db,
2313
- masterKey
2314
- }, 2, userId, masterKey);
2315
-
2316
- if (!sendResult.success) {
2317
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
2318
- }
2319
-
2320
- return {
2321
- status: 200,
2322
- body: {
2323
- success: true,
2324
- data: {
2325
- uuid: taskUuid,
2326
- contactName: payload.contactName,
2327
- messagesSent: sendResult.messagesSent,
2328
- sentAt: new Date().toISOString(),
2329
- status: 'sent',
2330
- retriesUsed: sendResult.retriesUsed || 0
2331
- }
2332
- }
2333
- };
2334
- } catch (error) {
2335
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
2336
- }
2337
- }
2338
-
2339
- // Non-instant: return scheduled response
2340
- return {
2341
- status: 201,
2342
- body: {
2343
- success: true,
2344
- data: {
2345
- id: dbResult.id,
2346
- uuid: dbResult.uuid,
2347
- contactName: payload.contactName,
2348
- nextSendAt: dbResult.next_send_at,
2349
- status: dbResult.status,
2350
- createdAt: dbResult.created_at
2351
- }
2352
- }
2353
- };
2354
- }
2355
-
2356
- return { POST };
2357
- }
2358
-
2359
- /**
2360
- * Scheduled tick core: fetch due tasks, deliver, reschedule/retry, cleanup.
2361
- * Extracted verbatim from the send-notifications handler so both the HTTP
2362
- * handler (multi-tenant) and the CF scheduled() path (single-user) share it.
2363
- *
2364
- * 每个 tick 是一次独立调用(cron 每分钟一跳,不会因为上一跳还没跑完就跳过
2365
- * 这一跳),而一次投递「组 prompt → 调 LLM → 跑工具 → 推送」可能要几分钟。
2366
- * 所以每条任务开跑前先占位:在这一行的 lease_until 上写下「归我管到什么时
2367
- * 候」,本次投递期间别的 tick 领不走它。占位失败(0 行)说明别人先领走了,
2368
- * 直接跳过。
2369
- *
2370
- * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
2371
- * 任务列表要读它、循环任务推进下一次也要拿它当基准。投递收尾时把租约放掉,
2372
- * 这条任务立刻可以被下一跳接手(失败重试的退避只有 2 分钟,比租期短得多,
2373
- * 不放掉的话退避就白设了)。
2374
- *
2375
- * 领了任务的 tick 中途没了(Worker 被回收之类)就没人来放租约,这条任务要
2376
- * 等租约到期才会被接手——这是租约本身的代价,把租期设得比最慢的一次投递长
2377
- * 一点就行。
2378
- *
2379
- * @param {Object} ctx - { db, masterKey, vapid, webpush, claimLeaseMs? }
2380
- * @returns {Promise<Object>} summary { totalTasks, successCount, failedCount, processedAt, executionTime, details }
2381
- */
2382
-
2383
-
2384
- // 占位租期:要盖住最慢的一次投递。老链路单次 LLM 调用上限 300s,agentic 链路
2385
- // 整链默认 240s,再留出推送节奏的余量。
2386
- const DEFAULT_CLAIM_LEASE_MS = 10 * 60 * 1000;
2387
- // 宿主把 totalTimeoutMs 调大时租期跟着抬,别让下一跳在投递还没跑完时就把行
2388
- // 捞回去。(onBeforeFire 里按次放宽的预算这里看不到,那种情况请显式设
2389
- // ctx.claimLeaseMs。)
2390
- const CLAIM_LEASE_MARGIN_MS = 2 * 60 * 1000;
2391
-
2392
- function positiveNumber(value) {
2393
- return (typeof value === 'number' && Number.isFinite(value) && value > 0) ? value : 0;
2394
- }
2395
-
2396
- function resolveClaimLeaseMs(ctx) {
2397
- return positiveNumber(ctx.claimLeaseMs)
2398
- || Math.max(DEFAULT_CLAIM_LEASE_MS, positiveNumber(ctx.totalTimeoutMs) + CLAIM_LEASE_MARGIN_MS);
2399
- }
2400
-
2401
- async function runScheduledTick(ctx) {
2402
- const db = ctx.db;
2403
- const masterKey = ctx.masterKey;
2404
- const claimLeaseMs = resolveClaimLeaseMs(ctx);
2405
-
2406
- const startTime = Date.now();
2407
- const tasks = await db.getPendingTasks(50);
2408
-
2409
- const MAX_CONCURRENT = 8;
2410
- const results = {
2411
- totalTasks: tasks.length,
2412
- successCount: 0,
2413
- failedCount: 0,
2414
- claimSkippedTasks: 0,
2415
- deletedOnceOffTasks: 0,
2416
- updatedRecurringTasks: 0,
2417
- failedTasks: []
2418
- };
2419
-
2420
- // 适配器没实现 claimTask(自定义适配器)→ 退回不占位的老行为:跑得动,只是
2421
- // 超过一跳间隔的慢任务仍可能被下一跳重复触发。
2422
- const supportsClaim = typeof db.claimTask === 'function';
2423
-
2424
- async function claimForThisTick(task) {
2425
- if (!supportsClaim) return true;
2426
- const leaseUntil = new Date(Date.now() + claimLeaseMs).toISOString();
2427
- return !!(await db.claimTask(task.id, task.next_send_at, leaseUntil));
2428
- }
2429
-
2430
- /**
2431
- * 投递收尾时写库,顺手把租约放掉。占位之后的每一次写库都要走这里,漏掉
2432
- * 一条那条任务就得等租约到期才动得了。
2433
- *
2434
- * 没实现 claimTask 的适配器不会有 lease_until 这一列,就别往它的
2435
- * updateTaskById 里塞这个字段了。
2436
- */
2437
- async function updateAndRelease(taskId, fields) {
2438
- return db.updateTaskById(taskId, supportsClaim ? { ...fields, lease_until: null } : fields);
2439
- }
2440
-
2441
- async function handleDeliveryFailure(task, reason) {
2442
- results.failedCount++;
2443
- try {
2444
- if (task.retry_count >= 3) {
2445
- await updateAndRelease(task.id, { status: 'failed' });
2446
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
2447
- } else {
2448
- const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
2449
- await updateAndRelease(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
2450
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
2451
- }
2452
- } catch (updateError) {
2453
- results.failedTasks.push({ taskId: task.id, reason, status: 'retry_update_failed', updateError: updateError.message });
2454
- }
2455
- }
2456
-
2457
- async function handlePostSendPersistenceFailure(task, reason) {
2458
- results.failedCount++;
2459
- let markedSent = false;
2460
- try {
2461
- await updateAndRelease(task.id, { status: 'sent', retry_count: 0 });
2462
- markedSent = true;
2463
- } catch (_markSentError) {
2464
- markedSent = false;
2465
- }
2466
- results.failedTasks.push({
2467
- taskId: task.id,
2468
- reason,
2469
- status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
2470
- messageDelivered: true
2471
- });
2472
- }
2473
-
2474
- async function processTask(task) {
2475
- let claimed;
2476
- try {
2477
- claimed = await claimForThisTick(task);
2478
- } catch (error) {
2479
- // 占位这一步就出错,说明库有问题——此时不知道别人有没有在跑这条,宁可
2480
- // 不发。行还是 pending,下一跳会重新捞。
2481
- results.failedCount++;
2482
- results.failedTasks.push({ taskId: task.id, reason: error.message || '任务占位失败', status: 'claim_failed' });
2483
- return;
2484
- }
2485
- if (!claimed) {
2486
- // 另一个 tick 已经领走了这条,本次什么都不做。
2487
- results.claimSkippedTasks++;
2488
- return;
2489
- }
2490
-
2491
- let sendResult;
2492
- try {
2493
- sendResult = await processSingleMessage(task, { ...ctx, db, masterKey }, masterKey);
2494
- } catch (error) {
2495
- await handleDeliveryFailure(task, error.message || '消息发送失败');
2496
- return;
2497
- }
2498
-
2499
- if (!sendResult.success) {
2500
- await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
2501
- return;
2502
- }
2503
-
2504
- try {
2505
- const userKey = await deriveUserEncryptionKey(task.user_id, masterKey);
2506
- const decryptedPayload = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
2507
-
2508
- if (decryptedPayload.recurrenceType === 'none') {
2509
- await db.deleteTaskById(task.id);
2510
- results.deletedOnceOffTasks++;
2511
- } else {
2512
- let nextSendAt;
2513
- // 以这条任务原本的触发时刻为基准往后推。
2514
- const currentSendAt = new Date(task.next_send_at);
2515
- if (decryptedPayload.recurrenceType === 'daily') {
2516
- nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
2517
- } else if (decryptedPayload.recurrenceType === 'weekly') {
2518
- nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
2519
- }
2520
- await updateAndRelease(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
2521
- results.updatedRecurringTasks++;
2522
- }
2523
-
2524
- results.successCount++;
2525
- } catch (error) {
2526
- await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
2527
- }
2528
- }
2529
-
2530
- const taskQueue = [...tasks];
2531
- const processing = [];
2532
-
2533
- while (taskQueue.length > 0 || processing.length > 0) {
2534
- while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
2535
- const task = taskQueue.shift();
2536
- const promise = processTask(task);
2537
- processing.push(promise);
2538
- promise.finally(() => {
2539
- const index = processing.indexOf(promise);
2540
- if (index > -1) processing.splice(index, 1);
2541
- });
2542
- }
2543
- if (processing.length > 0) {
2544
- await Promise.race(processing);
2545
- }
2546
- }
2547
-
2548
- await db.cleanupOldTasks(7);
2549
-
2550
- const executionTime = Date.now() - startTime;
2551
-
2552
- return {
2553
- totalTasks: results.totalTasks,
2554
- successCount: results.successCount,
2555
- failedCount: results.failedCount,
2556
- processedAt: new Date().toISOString(),
2557
- executionTime,
2558
- details: {
2559
- claimSkippedTasks: results.claimSkippedTasks,
2560
- deletedOnceOffTasks: results.deletedOnceOffTasks,
2561
- updatedRecurringTasks: results.updatedRecurringTasks,
2562
- failedTasks: results.failedTasks
2563
- }
2564
- };
2565
- }
2566
-
2567
- /**
2568
- * Handler: update-message
2569
- * ReiStandard SDK v2.0.1
2570
- *
2571
- * @param {Object} ctx - Server context.
2572
- * @returns {{ PUT: function }}
2573
- */
2574
-
2575
-
2576
- function createUpdateMessageHandler(ctx) {
2577
- async function PUT(url, headers, body) {
2578
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2579
- if (!tenantResult.ok) {
2580
- return tenantResult.error;
2581
- }
2582
-
2583
- const tenantCtx = tenantResult.context;
2584
- const db = tenantCtx.db;
2585
- const masterKey = tenantCtx.masterKey;
2586
- const u = new URL(url, 'https://dummy');
2587
- const taskUuid = u.searchParams.get('id');
2588
-
2589
- if (!taskUuid) {
2590
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
2591
- }
2592
-
2593
- const userId = getHeader(headers, 'x-user-id');
2594
- if (!userId) {
2595
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
2596
- }
2597
- if (!isValidUUIDv4(userId)) {
2598
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
2599
- }
2600
-
2601
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
2602
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
2603
-
2604
- if (!isEncrypted) {
2605
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
2606
- }
2607
-
2608
- if (encryptionVersion !== '1') {
2609
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
2610
- }
2611
-
2612
- const parsedBody = parseEncryptedBody(body);
2613
- if (!parsedBody.ok) {
2614
- return { status: 400, body: { success: false, error: parsedBody.error } };
2615
- }
2616
-
2617
- const encryptedBody = parsedBody.data;
2618
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
2619
- let updates;
2620
-
2621
- try {
2622
- updates = await decryptPayload(encryptedBody, userKey);
2623
- } catch (_error) {
2624
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
2625
- }
2626
-
2627
- if (!isPlainObject(updates)) {
2628
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
2629
- }
2630
-
2631
- if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
2632
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
2633
- }
2634
-
2635
- if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
2636
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
2637
- }
2638
-
2639
- if (
2640
- Object.prototype.hasOwnProperty.call(updates, 'maxTokens') &&
2641
- updates.maxTokens !== null &&
2642
- (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)
2643
- ) {
2644
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
2645
- }
2646
-
2647
- // Reject updates that try to set both completePrompt and messages at
2648
- // once. We don't enforce one-of-required here (callers may patch other
2649
- // fields), but the two prompt sources are mutually exclusive and must
2650
- // stay that way in storage too.
2651
- if (
2652
- updates.completePrompt &&
2653
- updates.messages !== undefined && updates.messages !== null
2654
- ) {
2655
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
2656
- }
2657
- if (updates.messages !== undefined && updates.messages !== null) {
2658
- const msgErr = validateLlmMessagesArray(updates.messages);
2659
- if (msgErr) {
2660
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
2661
- }
2662
- }
2663
- if (
2664
- Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
2665
- updates.temperature !== null &&
2666
- (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
2667
- ) {
2668
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
2669
- }
2670
- if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
2671
- const splitErr = validateSplitPattern(updates.splitPattern);
2672
- if (splitErr) {
2673
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
2674
- }
2675
- }
2676
- if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
2677
- const avatarErr = validateAvatarUrl(updates.avatarUrl);
2678
- if (avatarErr) {
2679
- // Soft-strip: drop the bad avatarUrl from the patch (keeps the
2680
- // existing stored avatar untouched) and continue applying the rest
2681
- // of the update. See standards §6.2.
2682
- console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
2683
- delete updates.avatarUrl;
2684
- }
2685
- }
2686
-
2687
- // Fetch existing task
2688
- const existingTask = await db.getTaskByUuid(taskUuid, userId);
2689
-
2690
- if (!existingTask) {
2691
- const taskStatus = await db.getTaskStatus(taskUuid, userId);
2692
- if (!taskStatus) {
2693
- return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
2694
- }
2695
- return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
2696
- }
2697
-
2698
- const existingData = JSON.parse(await decryptFromStorage(existingTask.encrypted_payload, userKey));
2699
-
2700
- // When the caller switches prompt source (completePrompt ↔ messages),
2701
- // null out the other so storage stays one-of (matches schedule-message
2702
- // shape and prevents buildAiRequestBody from accidentally seeing both).
2703
- const promptUpdates = {};
2704
- if (updates.completePrompt) {
2705
- promptUpdates.completePrompt = updates.completePrompt;
2706
- promptUpdates.messages = null;
2707
- } else if (updates.messages !== undefined && updates.messages !== null) {
2708
- promptUpdates.messages = updates.messages;
2709
- promptUpdates.completePrompt = null;
2710
- }
2711
-
2712
- const updatedData = {
2713
- ...existingData,
2714
- ...promptUpdates,
2715
- ...(updates.userMessage && { userMessage: updates.userMessage }),
2716
- ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
2717
- ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
2718
- ...(updates.metadata && { metadata: updates.metadata }),
2719
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
2720
- ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
2721
- // splitPattern: hasOwnProperty so that explicit `null` (= revert to
2722
- // default) doesn't get swallowed by truthy-spread the way the optional
2723
- // string fields above are.
2724
- ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
2725
- };
2726
-
2727
- const encryptedPayload = await encryptForStorage(JSON.stringify(updatedData), userKey);
2728
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
2729
-
2730
- const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
2731
-
2732
- if (!result) {
2733
- return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
2734
- }
2735
-
2736
- return {
2737
- status: 200,
2738
- body: {
2739
- success: true,
2740
- data: {
2741
- uuid: taskUuid,
2742
- updatedFields: Object.keys(updates),
2743
- updatedAt: result.updated_at
2744
- }
2745
- }
2746
- };
2747
- }
2748
-
2749
- return { PUT };
2750
- }
2751
-
2752
- /**
2753
- * Handler: cancel-message
2754
- * ReiStandard SDK v2.0.1
2755
- *
2756
- * @param {Object} ctx - Server context.
2757
- * @returns {{ DELETE: function }}
2758
- */
2759
-
2760
-
2761
- function createCancelMessageHandler(ctx) {
2762
- async function DELETE(url, headers) {
2763
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2764
- if (!tenantResult.ok) {
2765
- return tenantResult.error;
2766
- }
2767
-
2768
- const db = tenantResult.context.db;
2769
- const u = new URL(url, 'https://dummy');
2770
- const taskUuid = u.searchParams.get('id');
2771
-
2772
- if (!taskUuid) {
2773
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
2774
- }
2775
-
2776
- const userId = getHeader(headers, 'x-user-id');
2777
- if (!userId) {
2778
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
2779
- }
2780
- if (!isValidUUIDv4(userId)) {
2781
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
2782
- }
2783
-
2784
- const deleted = await db.deleteTaskByUuid(taskUuid, userId);
2785
-
2786
- if (!deleted) {
2787
- return {
2788
- status: 404,
2789
- body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
2790
- };
2791
- }
2792
-
2793
- return {
2794
- status: 200,
2795
- body: {
2796
- success: true,
2797
- data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
2798
- }
2799
- };
2800
- }
2801
-
2802
- return { DELETE };
2803
- }
2804
-
2805
- /**
2806
- * Handler: messages
2807
- * ReiStandard SDK v2.0.1
2808
- *
2809
- * @param {Object} ctx - Server context.
2810
- * @returns {{ GET: function }}
2811
- */
2812
-
2813
-
2814
- function createMessagesHandler(ctx) {
2815
- async function GET(url, headers) {
2816
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
2817
- if (!tenantResult.ok) {
2818
- return tenantResult.error;
2819
- }
2820
-
2821
- const tenantCtx = tenantResult.context;
2822
- const db = tenantCtx.db;
2823
- const masterKey = tenantCtx.masterKey;
2824
- const userId = getHeader(headers, 'x-user-id');
2825
-
2826
- if (!userId) {
2827
- return {
2828
- status: 400,
2829
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
2830
- };
2831
- }
2832
- if (!isValidUUIDv4(userId)) {
2833
- return {
2834
- status: 400,
2835
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
2836
- };
2837
- }
2838
-
2839
- const u = new URL(url, 'https://dummy');
2840
- const status = u.searchParams.get('status') || 'all';
2841
- const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
2842
- const offset = parseInt(u.searchParams.get('offset') || '0', 10);
2843
-
2844
- if (isNaN(limit) || limit < 1) {
2845
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
2846
- }
2847
-
2848
- if (isNaN(offset) || offset < 0) {
2849
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
2850
- }
2851
-
2852
- const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
2853
-
2854
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
2855
-
2856
- const decryptedTasks = await Promise.all(tasks.map(async task => {
2857
- const decrypted = JSON.parse(await decryptFromStorage(task.encrypted_payload, userKey));
2858
- return {
2859
- id: task.id,
2860
- uuid: task.uuid,
2861
- contactName: decrypted.contactName,
2862
- messageType: task.message_type,
2863
- messageSubtype: decrypted.messageSubtype,
2864
- nextSendAt: task.next_send_at,
2865
- recurrenceType: decrypted.recurrenceType,
2866
- status: task.status,
2867
- retryCount: task.retry_count,
2868
- createdAt: task.created_at,
2869
- updatedAt: task.updated_at,
2870
- // Character ownership / client-side task identity, pulled from the
2871
- // scheduling host's metadata so it can filter tasks by character
2872
- // (contactName can collide across characters). Only these two
2873
- // metadata fields are surfaced — the rest of metadata may hold
2874
- // host-private data and stays server-side. Absent → null.
2875
- charId: decrypted.metadata?.charId ?? null,
2876
- clientTaskId: decrypted.metadata?.amsgClientTaskId ?? null
2877
- };
2878
- }));
2879
-
2880
- const responsePayload = {
2881
- tasks: decryptedTasks,
2882
- pagination: { total, limit, offset, hasMore: offset + limit < total }
2883
- };
2884
- const encryptedResponse = await encryptPayload(responsePayload, userKey);
2885
-
2886
- return {
2887
- status: 200,
2888
- body: {
2889
- success: true,
2890
- encrypted: true,
2891
- version: 1,
2892
- data: encryptedResponse
2893
- }
2894
- };
2895
- }
2896
-
2897
- return { GET };
2898
- }
2899
-
2900
- /**
2901
- * SQLite (Cloudflare D1) dialect schema for scheduled_messages.
2902
- *
2903
- * Differences from the Postgres schema (adapters/schema.js):
2904
- * - id: INTEGER PRIMARY KEY AUTOINCREMENT (vs SERIAL)
2905
- * - timestamps stored as TEXT ISO8601 UTC (vs TIMESTAMP WITH TIME ZONE)
2906
- * - no NOW()/DEFAULT; the adapter always writes timestamps explicitly
2907
- * - retry_count is NOT NULL here (Postgres omits NOT NULL); every write path
2908
- * sets it explicitly, so the tighter constraint just documents that intent
2909
- * Partial indexes and CHECK constraints are native to SQLite, so they carry over.
2910
- * Index entries mirror the Postgres INDEXES shape ({ name, sql, description,
2911
- * critical }) so both adapters' initSchema() return the same index metadata.
2912
- */
2913
-
2914
- const SQLITE_TABLE_SQL = `
2915
- CREATE TABLE IF NOT EXISTS scheduled_messages (
2916
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2917
- user_id TEXT NOT NULL,
2918
- uuid TEXT,
2919
- encrypted_payload TEXT NOT NULL,
2920
- message_type TEXT NOT NULL CHECK (message_type IN ('fixed', 'prompted', 'auto', 'instant')),
2921
- next_send_at TEXT NOT NULL,
2922
- lease_until TEXT,
2923
- status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
2924
- retry_count INTEGER NOT NULL DEFAULT 0,
2925
- created_at TEXT NOT NULL,
2926
- updated_at TEXT NOT NULL
2927
- )
2928
- `;
2929
-
2930
- /**
2931
- * 建表语句用的是 CREATE TABLE IF NOT EXISTS,已经存在的表不会被改动,所以
2932
- * 后加的列要单独补。initSchema 每次都会跑一遍,列已经在了就跳过。
2933
- */
2934
- const SQLITE_MIGRATIONS = [
2935
- {
2936
- name: 'add_lease_until',
2937
- sql: 'ALTER TABLE scheduled_messages ADD COLUMN lease_until TEXT',
2938
- description: 'Task claim lease (2.6.0)'
2939
- }
2940
- ];
2941
-
2942
- const SQLITE_INDEXES = [
2943
- {
2944
- name: 'idx_pending_tasks_optimized',
2945
- sql: `CREATE INDEX IF NOT EXISTS idx_pending_tasks_optimized
2946
- ON scheduled_messages (status, next_send_at, id, retry_count)
2947
- WHERE status = 'pending'`,
2948
- description: 'Main query index (Cron Job finds pending tasks)',
2949
- critical: false
2950
- },
2951
- {
2952
- name: 'idx_cleanup_completed',
2953
- sql: `CREATE INDEX IF NOT EXISTS idx_cleanup_completed
2954
- ON scheduled_messages (status, updated_at)
2955
- WHERE status IN ('sent', 'failed')`,
2956
- description: 'Cleanup query index',
2957
- critical: false
2958
- },
2959
- {
2960
- name: 'idx_failed_retry',
2961
- sql: `CREATE INDEX IF NOT EXISTS idx_failed_retry
2962
- ON scheduled_messages (status, retry_count, next_send_at)
2963
- WHERE status = 'failed' AND retry_count < 3`,
2964
- description: 'Failed retry index',
2965
- critical: false
2966
- },
2967
- {
2968
- name: 'idx_user_id',
2969
- sql: `CREATE INDEX IF NOT EXISTS idx_user_id
2970
- ON scheduled_messages (user_id)`,
2971
- description: 'User task query index',
2972
- critical: false
2973
- },
2974
- {
2975
- name: 'uidx_uuid',
2976
- sql: `CREATE UNIQUE INDEX IF NOT EXISTS uidx_uuid
2977
- ON scheduled_messages (uuid)
2978
- WHERE uuid IS NOT NULL`,
2979
- description: 'UUID uniqueness guard',
2980
- critical: true
2981
- }
2982
- ];
2983
-
2984
- // client_state: cloud mirror of client-side state for the single-user
2985
- // deployment. One live copy per (user, namespace, key) — not per-task
2986
- // snapshots. The client is the only writer (batch upsert, last-write-wins
2987
- // on updated_at); fire-time hooks are the reader. `value` holds
2988
- // encryptForStorage ciphertext. `updated_at` is a caller-supplied epoch-ms
2989
- // INTEGER (unlike scheduled_messages' ISO TEXT) so conflict resolution
2990
- // compares without parsing. Single-user/SQLite only — no Postgres mirror.
2991
- const CLIENT_STATE_TABLE_SQL = `
2992
- CREATE TABLE IF NOT EXISTS client_state (
2993
- user_id TEXT NOT NULL,
2994
- namespace TEXT NOT NULL,
2995
- key TEXT NOT NULL,
2996
- value TEXT NOT NULL,
2997
- updated_at INTEGER NOT NULL,
2998
- PRIMARY KEY (user_id, namespace, key)
2999
- )
3000
- `;
3001
-
3002
- /**
3003
- * Cloudflare D1 (SQLite) Database Adapter.
3004
- *
3005
- * @implements {import('./interface.js').DbAdapter}
3006
- *
3007
- * Timestamps are stored as ISO8601 UTC TEXT. Every timestamp is normalized
3008
- * with new Date(v).toISOString() before store/compare so lexical ordering
3009
- * equals chronological ordering (mixed offsets like +08:00 vs Z are unified).
3010
- */
3011
-
3012
-
3013
- // Update methods build a dynamic SET clause from object keys. Callers pass only
3014
- // hardcoded column names today, but enforcing a whitelist keeps a future caller
3015
- // from ever turning a caller-supplied key into interpolated SQL.
3016
- const UPDATABLE_COLUMNS = new Set([
3017
- 'user_id', 'uuid', 'encrypted_payload', 'message_type',
3018
- 'next_send_at', 'lease_until', 'status', 'retry_count', 'created_at', 'updated_at'
3019
- ]);
3020
-
3021
- // LIKE 前缀转义:用户 key 里的 % _ \ 不能变成通配符/转义符。
3022
- function escapeLikePrefix(prefix) {
3023
- return prefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
3024
- }
3025
-
3026
- class D1Adapter {
3027
- /** @param {{ prepare: (sql: string) => any }} db - Cloudflare D1 binding */
3028
- constructor(db) {
3029
- /** @private */
3030
- this._db = db;
3031
- }
3032
-
3033
- /** @private */
3034
- _now() {
3035
- return new Date().toISOString();
3036
- }
3037
-
3038
- /** @private */
3039
- _iso(value) {
3040
- const d = new Date(value);
3041
- if (Number.isNaN(d.getTime())) {
3042
- throw new Error(`[amsg-server D1] invalid timestamp: ${value}`);
3043
- }
3044
- return d.toISOString();
3045
- }
3046
-
3047
- async initSchema() {
3048
- await this._db.prepare(SQLITE_TABLE_SQL).run();
3049
- await this._db.prepare(CLIENT_STATE_TABLE_SQL).run();
3050
-
3051
- // SQLite 的 ALTER TABLE 没有 ADD COLUMN IF NOT EXISTS,列已经在了就会
3052
- // 报 duplicate column name。那正是「这一步不用做」的意思,跳过即可;
3053
- // 其他错误照常抛出去。
3054
- for (const migration of SQLITE_MIGRATIONS) {
3055
- try {
3056
- await this._db.prepare(migration.sql).run();
3057
- } catch (error) {
3058
- if (!/duplicate column name/i.test(error.message || '')) throw error;
3059
- }
3060
- }
3061
-
3062
- const indexResults = [];
3063
- for (const index of SQLITE_INDEXES) {
3064
- try {
3065
- await this._db.prepare(index.sql).run();
3066
- indexResults.push({ name: index.name, status: 'success', description: index.description, critical: !!index.critical });
3067
- } catch (error) {
3068
- indexResults.push({ name: index.name, status: 'failed', description: index.description, critical: !!index.critical, error: error.message });
3069
- }
3070
- }
3071
-
3072
- const criticalFailures = indexResults.filter((i) => i.critical && i.status === 'failed');
3073
- if (criticalFailures.length > 0) {
3074
- const names = criticalFailures.map((i) => i.name).join(', ');
3075
- throw new Error(
3076
- `Critical index creation failed (${names}). ` +
3077
- 'Please remove duplicate UUID rows and run initSchema again.'
3078
- );
3079
- }
3080
-
3081
- return {
3082
- columnsCreated: 11,
3083
- indexesCreated: indexResults.filter((r) => r.status === 'success').length,
3084
- indexesFailed: indexResults.filter((r) => r.status === 'failed').length,
3085
- columns: [],
3086
- indexes: indexResults
3087
- };
3088
- }
3089
-
3090
- async dropSchema() {
3091
- await this._db.prepare('DROP TABLE IF EXISTS scheduled_messages').run();
3092
- await this._db.prepare('DROP TABLE IF EXISTS client_state').run();
3093
- }
3094
-
3095
- async createTask(params) {
3096
- const now = this._now();
3097
- const nextSendAt = this._iso(params.next_send_at);
3098
- const res = await this._db.prepare(
3099
- `INSERT INTO scheduled_messages
3100
- (user_id, uuid, encrypted_payload, next_send_at, message_type, status, retry_count, created_at, updated_at)
3101
- VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
3102
- ).bind(params.user_id, params.uuid, params.encrypted_payload, nextSendAt, params.message_type, now, now).run();
3103
-
3104
- const id = res.meta.last_row_id;
3105
- return this._db.prepare(
3106
- `SELECT id, uuid, next_send_at, status, created_at FROM scheduled_messages WHERE id = ?`
3107
- ).bind(id).first();
3108
- }
3109
-
3110
- async getTaskByUuid(uuid, userId) {
3111
- return this._db.prepare(
3112
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3113
- FROM scheduled_messages
3114
- WHERE uuid = ? AND user_id = ? AND status = 'pending'
3115
- LIMIT 1`
3116
- ).bind(uuid, userId).first();
3117
- }
3118
-
3119
- async getTaskByUuidOnly(uuid) {
3120
- return this._db.prepare(
3121
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3122
- FROM scheduled_messages
3123
- WHERE uuid = ? AND status = 'pending'
3124
- LIMIT 1`
3125
- ).bind(uuid).first();
3126
- }
3127
-
3128
- async updateTaskById(taskId, updates) {
3129
- const sets = [];
3130
- const values = [];
3131
- for (const [key, value] of Object.entries(updates)) {
3132
- if (!UPDATABLE_COLUMNS.has(key)) {
3133
- throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
3134
- }
3135
- sets.push(`${key} = ?`);
3136
- values.push(key === 'next_send_at' ? this._iso(value) : value);
3137
- }
3138
- // Callers may pass updated_at explicitly (tests); otherwise stamp now.
3139
- if (!Object.prototype.hasOwnProperty.call(updates, 'updated_at')) {
3140
- sets.push('updated_at = ?');
3141
- values.push(this._now());
3142
- }
3143
- values.push(taskId);
3144
-
3145
- await this._db.prepare(
3146
- `UPDATE scheduled_messages SET ${sets.join(', ')} WHERE id = ?`
3147
- ).bind(...values).run();
3148
-
3149
- return this._db.prepare('SELECT * FROM scheduled_messages WHERE id = ?').bind(taskId).first();
3150
- }
3151
-
3152
- async updateTaskByUuid(uuid, userId, encryptedPayload, extraFields) {
3153
- const now = this._now();
3154
- const sets = ['encrypted_payload = ?', 'updated_at = ?'];
3155
- const values = [encryptedPayload, now];
3156
- if (extraFields) {
3157
- for (const [key, value] of Object.entries(extraFields)) {
3158
- if (!UPDATABLE_COLUMNS.has(key)) {
3159
- throw new Error(`[amsg-server D1] rejected unknown update column: ${key}`);
3160
- }
3161
- sets.push(`${key} = ?`);
3162
- values.push(key === 'next_send_at' ? this._iso(value) : value);
3163
- }
3164
- }
3165
- values.push(uuid, userId);
3166
-
3167
- const res = await this._db.prepare(
3168
- `UPDATE scheduled_messages SET ${sets.join(', ')}
3169
- WHERE uuid = ? AND user_id = ? AND status = 'pending'`
3170
- ).bind(...values).run();
3171
-
3172
- if (!res.meta.changes) return null;
3173
- return { uuid, updated_at: now };
3174
- }
3175
-
3176
- async deleteTaskById(taskId) {
3177
- const res = await this._db.prepare('DELETE FROM scheduled_messages WHERE id = ?').bind(taskId).run();
3178
- return res.meta.changes > 0;
3179
- }
3180
-
3181
- async deleteTaskByUuid(uuid, userId) {
3182
- const res = await this._db.prepare(
3183
- 'DELETE FROM scheduled_messages WHERE uuid = ? AND user_id = ?'
3184
- ).bind(uuid, userId).run();
3185
- return res.meta.changes > 0;
3186
- }
3187
-
3188
- async getPendingTasks(limit = 50) {
3189
- const now = this._now();
3190
- const res = await this._db.prepare(
3191
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count
3192
- FROM scheduled_messages
3193
- WHERE status = 'pending' AND next_send_at <= ?
3194
- AND (lease_until IS NULL OR lease_until <= ?)
3195
- ORDER BY next_send_at ASC
3196
- LIMIT ?`
3197
- ).bind(now, now, limit).all();
3198
- return res.results || [];
3199
- }
3200
-
3201
- /**
3202
- * 领取一条到点的任务:在 lease_until 上写下「这条归我管到什么时候」,
3203
- * 本次投递期间别的 tick 领不走它。
3204
- *
3205
- * 租约写在自己的列上,next_send_at 全程不动——那一列是用户设的触发时刻,
3206
- * 任务列表要读它、循环任务推进下一次也要拿它当基准。
3207
- *
3208
- * 两个 tick 抢同一行时只有一个能改到行,另一个拿到 changes = 0,据此跳过。
3209
- * WHERE 里的两个条件各管一件事:
3210
- * - lease_until 为空或已过期:没人正在跑这条。领了任务的 tick 中途没了
3211
- * 也不会把行焊死,租约到期后自然可以被接手。
3212
- * - next_send_at 等于读这行时看到的值:读出来之后用户又改了排期的话,
3213
- * 这一跳就不该再按旧时刻发。
3214
- *
3215
- * 不加一个 'sending' 状态来表达「正在跑」:建表语句里 status 有
3216
- * CHECK (status IN ('pending','sent','failed')),加值要重建表。
3217
- *
3218
- * expectedNextSendAt 按读到的原样比对,不做时区归一化——老部署里可能还留
3219
- * 着非归一化写法的行(如 +08:00 结尾),归一化后反而对不上,那条任务会永
3220
- * 远领不到。
3221
- *
3222
- * @param {number} taskId
3223
- * @param {string} expectedNextSendAt - 读这行时拿到的 next_send_at 原值
3224
- * @param {string|Date} leaseUntil - 租期末尾
3225
- * @returns {Promise<boolean>} true = 领到了;false = 别人正拿着租约、排期被改过、或行已不是 pending
3226
- */
3227
- async claimTask(taskId, expectedNextSendAt, leaseUntil) {
3228
- const expected = typeof expectedNextSendAt === 'string'
3229
- ? expectedNextSendAt
3230
- : this._iso(expectedNextSendAt);
3231
- const res = await this._db.prepare(
3232
- `UPDATE scheduled_messages
3233
- SET lease_until = ?, updated_at = ?
3234
- WHERE id = ? AND status = 'pending' AND next_send_at = ?
3235
- AND (lease_until IS NULL OR lease_until <= ?)`
3236
- ).bind(this._iso(leaseUntil), this._now(), taskId, expected, this._now()).run();
3237
- return (res.meta.changes || 0) > 0;
3238
- }
3239
-
3240
- async listTasks(userId, opts = {}) {
3241
- const { status = 'all', limit = 20, offset = 0 } = opts;
3242
- const conditions = ['user_id = ?'];
3243
- const params = [userId];
3244
- if (status !== 'all') {
3245
- conditions.push('status = ?');
3246
- params.push(status);
3247
- }
3248
- const where = conditions.join(' AND ');
3249
-
3250
- const countRow = await this._db.prepare(
3251
- `SELECT COUNT(*) as count FROM scheduled_messages WHERE ${where}`
3252
- ).bind(...params).first();
3253
- const total = Number(countRow.count) || 0;
3254
-
3255
- const res = await this._db.prepare(
3256
- `SELECT id, user_id, uuid, encrypted_payload, message_type, next_send_at, status, retry_count, created_at, updated_at
3257
- FROM scheduled_messages
3258
- WHERE ${where}
3259
- ORDER BY next_send_at ASC
3260
- LIMIT ? OFFSET ?`
3261
- ).bind(...params, limit, offset).all();
3262
-
3263
- return { tasks: res.results || [], total };
3264
- }
3265
-
3266
- async cleanupOldTasks(days = 7) {
3267
- const safeDays = Math.max(1, Math.floor(Number(days)));
3268
- const cutoff = new Date(Date.now() - safeDays * 24 * 60 * 60 * 1000).toISOString();
3269
- const res = await this._db.prepare(
3270
- `DELETE FROM scheduled_messages
3271
- WHERE status IN ('sent', 'failed') AND updated_at < ?`
3272
- ).bind(cutoff).run();
3273
- return res.meta.changes || 0;
3274
- }
3275
-
3276
- async getTaskStatus(uuid, userId) {
3277
- const row = await this._db.prepare(
3278
- 'SELECT status FROM scheduled_messages WHERE uuid = ? AND user_id = ? LIMIT 1'
3279
- ).bind(uuid, userId).first();
3280
- return row ? row.status : null;
3281
- }
3282
-
3283
- // ── client_state (single-user cloud state mirror) ──────────────────────
3284
-
3285
- /**
3286
- * Batch upsert. Last-write-wins per (namespace, key): an entry older
3287
- * than the stored row (updatedAt strictly lower) is skipped; equal or
3288
- * newer overwrites. Values arrive pre-encrypted (the handler encrypts).
3289
- *
3290
- * `cleanups` 是删除项:在同一 batch 里先于 upsert 执行,`updated_at <= ?`
3291
- * 条件保证陈旧批次删不动更新写入的行。两种形态——
3292
- * - `{ namespace, keyPrefix, updatedAt }` 删 key 前缀下的所有行,用来清掉
3293
- * 大值旧写入留下的切片行(见 lib/state-chunks.js);
3294
- * - `{ namespace, key, updatedAt }` 删这一个 key,用来删整条状态(前缀会
3295
- * 连带删掉同前缀的兄弟 key,删单条必须走精确匹配)。
3296
- *
3297
- * 删掉一整条状态就是这两种各来一条(切片行走前缀、根行走精确 key),
3298
- * `entries` 传空数组即可——见 lib/client-state-store.js。
3299
- *
3300
- * Uses D1's batch() — one network round trip for the whole set (implicit
3301
- * transaction). The client calls this endpoint inside its few-seconds
3302
- * background window, so N sequential round trips could eat the whole
3303
- * window. Bindings without batch() (e.g. the sqlite test shim, custom
3304
- * adapters) fall back to a sequential loop.
3305
- *
3306
- * @param {string} userId
3307
- * @param {Array<{ namespace: string, key: string, value: string, updatedAt: number }>} entries
3308
- * @param {Array<{ namespace: string, key?: string, keyPrefix?: string, updatedAt: number }>} [cleanups]
3309
- * @returns {Promise<{ upserted: number, skipped: number, outcomes: boolean[] }>}
3310
- * `outcomes[i]` 对应 entries[i] 是否真的写入(changes > 0)。
3311
- */
3312
- async upsertClientState(userId, entries, cleanups = []) {
3313
- const UPSERT_SQL =
3314
- `INSERT INTO client_state (user_id, namespace, key, value, updated_at)
3315
- VALUES (?, ?, ?, ?, ?)
3316
- ON CONFLICT (user_id, namespace, key) DO UPDATE SET
3317
- value = excluded.value,
3318
- updated_at = excluded.updated_at
3319
- WHERE excluded.updated_at >= client_state.updated_at`;
3320
- const CLEANUP_PREFIX_SQL =
3321
- `DELETE FROM client_state
3322
- WHERE user_id = ? AND namespace = ? AND key LIKE ? ESCAPE '\\' AND updated_at <= ?`;
3323
- const CLEANUP_KEY_SQL =
3324
- `DELETE FROM client_state
3325
- WHERE user_id = ? AND namespace = ? AND key = ? AND updated_at <= ?`;
3326
-
3327
- const buildStatements = () => [
3328
- ...cleanups.map((c) => (
3329
- typeof c.key === 'string'
3330
- ? this._db.prepare(CLEANUP_KEY_SQL).bind(userId, c.namespace, c.key, c.updatedAt)
3331
- : this._db.prepare(CLEANUP_PREFIX_SQL).bind(userId, c.namespace, `${escapeLikePrefix(c.keyPrefix)}%`, c.updatedAt)
3332
- )),
3333
- ...entries.map((entry) =>
3334
- this._db.prepare(UPSERT_SQL).bind(userId, entry.namespace, entry.key, entry.value, entry.updatedAt)
3335
- ),
3336
- ];
3337
-
3338
- let results;
3339
- if (typeof this._db.batch === 'function') {
3340
- results = await this._db.batch(buildStatements());
3341
- } else {
3342
- results = [];
3343
- for (const stmt of buildStatements()) {
3344
- results.push(await stmt.run());
3345
- }
3346
- }
3347
-
3348
- // cleanup 语句不计数:upserted/skipped/outcomes 只看 entries 对应的语句。
3349
- const outcomes = results.slice(cleanups.length).map((res) => res.meta.changes > 0);
3350
- let upserted = 0;
3351
- let skipped = 0;
3352
- for (const wrote of outcomes) {
3353
- if (wrote) upserted++; else skipped++;
3354
- }
3355
- return { upserted, skipped, outcomes };
3356
- }
3357
-
3358
- /**
3359
- * All entries of one namespace (values still encrypted).
3360
- *
3361
- * @param {string} userId
3362
- * @param {string} namespace
3363
- * @returns {Promise<Array<{ namespace: string, key: string, value: string, updated_at: number }>>}
3364
- */
3365
- async getClientState(userId, namespace) {
3366
- const res = await this._db.prepare(
3367
- `SELECT namespace, key, value, updated_at
3368
- FROM client_state
3369
- WHERE user_id = ? AND namespace = ?
3370
- ORDER BY key ASC`
3371
- ).bind(userId, namespace).all();
3372
- return res.results || [];
3373
- }
3374
-
3375
- /**
3376
- * Wipe every entry of this user.
3377
- *
3378
- * @param {string} userId
3379
- * @returns {Promise<number>} rows deleted
3380
- */
3381
- async clearClientState(userId) {
3382
- const res = await this._db.prepare('DELETE FROM client_state WHERE user_id = ?').bind(userId).run();
3383
- return res.meta.changes || 0;
3384
- }
3385
- }
3386
-
3387
- /**
3388
- * Create a D1 adapter from a Cloudflare D1 binding (env.DB).
3389
- * @param {{ prepare: (sql: string) => any }} db
3390
- * @returns {import('./interface.js').DbAdapter}
3391
- */
3392
- function createD1Adapter(db) {
3393
- if (!db || typeof db.prepare !== 'function') {
3394
- throw new Error('[amsg-server] createD1Adapter requires a D1 database binding (env.DB)');
3395
- }
3396
- return new D1Adapter(db);
3397
- }
3398
-
3399
- /**
3400
- * Portable constant-time string comparison.
3401
- *
3402
- * Runs identically on Node (tests) and Cloudflare Workers (prod). We avoid
3403
- * both node:crypto's timingSafeEqual (undefined on Workers historically) and
3404
- * crypto.subtle.timingSafeEqual (absent on Node). Instead we HMAC-SHA256 both
3405
- * inputs under one fresh random key, then compare the two digests. Because the
3406
- * key is random per call, an attacker can't precompute or replay a stable
3407
- * timing oracle; digests are a fixed 32 bytes, so the XOR-accumulate compare
3408
- * runs the same number of steps regardless of input length, with no early-out.
3409
- *
3410
- * globalThis.crypto (Web Crypto) is available on Node >= 20 and on Workers.
3411
- */
3412
- async function constantTimeEqual(a, b) {
3413
- const enc = new TextEncoder();
3414
- const keyBytes = globalThis.crypto.getRandomValues(new Uint8Array(32));
3415
- const key = await globalThis.crypto.subtle.importKey(
3416
- 'raw',
3417
- keyBytes,
3418
- { name: 'HMAC', hash: 'SHA-256' },
3419
- false,
3420
- ['sign']
3421
- );
3422
- const da = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(a))));
3423
- const db = new Uint8Array(await globalThis.crypto.subtle.sign('HMAC', key, enc.encode(String(b))));
3424
-
3425
- let diff = 0;
3426
- for (let i = 0; i < da.length; i++) {
3427
- diff |= da[i] ^ db[i];
3428
- }
3429
- return diff === 0;
3430
- }
3431
-
3432
- /**
3433
- * Single-user tenant context manager.
3434
- *
3435
- * Interface-compatible with createTenantContextManager (resolveTenant /
3436
- * initializeTenant), so the existing business handlers reuse it unchanged.
3437
- * No blob registry, no tenant token — db and masterKey come from the caller
3438
- * (the Worker resolves them from env + D1 binding per request).
3439
- */
3440
-
3441
-
3442
- function createSingleUserContextManager({ db, masterKey, serverToken } = {}) {
3443
- if (!db) throw new Error('[amsg-server single-user] db (adapter) is required');
3444
- if (!masterKey) throw new Error('[amsg-server single-user] masterKey is required');
3445
- const token = String(serverToken || '').trim();
3446
-
3447
- async function isAuthorized(headers) {
3448
- if (!token) return true; // open when no shared secret configured
3449
- const provided = getHeader(headers, 'x-client-token');
3450
- if (!provided) return false;
3451
- return constantTimeEqual(provided, token);
3452
- }
3453
-
3454
- async function resolveTenant(headers) {
3455
- if (!(await isAuthorized(headers))) {
3456
- return {
3457
- ok: false,
3458
- error: {
3459
- status: 401,
3460
- body: { success: false, error: { code: 'INVALID_CLIENT_TOKEN', message: '共享密钥无效或缺失' } }
3461
- }
3462
- };
3463
- }
3464
- return {
3465
- ok: true,
3466
- context: { tenantId: 'single', tokenType: 'tenant', db, masterKey }
3467
- };
3468
- }
3469
-
3470
- async function initializeTenant() {
3471
- const schema = await db.initSchema();
3472
- return { tenantId: 'single', schema };
3473
- }
3474
-
3475
- return { resolveTenant, initializeTenant };
3476
- }
3477
-
3478
- /**
3479
- * Handler: single-user-init
3480
- *
3481
- * Idempotent "just create the tables" endpoint for single-user deployments
3482
- * (the degenerate form of init-tenant). Reuses resolveTenant purely to enforce
3483
- * the optional shared secret, then runs initSchema. Issues no token.
3484
- *
3485
- * @param {Object} ctx - Single-user server context (ctx.tenantManager).
3486
- * @returns {{ POST: function }}
3487
- */
3488
- function createSingleUserInitHandler(ctx) {
3489
- async function POST(headers /* , body */) {
3490
- const auth = await ctx.tenantManager.resolveTenant(headers || {});
3491
- if (!auth.ok) {
3492
- return auth.error;
3493
- }
3494
- try {
3495
- const result = await ctx.tenantManager.initializeTenant();
3496
- return {
3497
- status: 200,
3498
- body: { success: true, data: { tenantId: result.tenantId, schema: result.schema } }
3499
- };
3500
- } catch (error) {
3501
- return {
3502
- status: 500,
3503
- body: { success: false, error: { code: 'INIT_FAILED', message: error.message } }
3504
- };
3505
- }
3506
- }
3507
-
3508
- return { POST };
3509
- }
3510
-
3511
- /**
3512
- * Handler: vapid-public-key
3513
- *
3514
- * Exposes this worker's own VAPID public key so a browser frontend can build a
3515
- * Web Push subscription (`applicationServerKey`) at runtime. Each self-hosted
3516
- * worker owns its keypair, so the key can't be baked into the frontend — it
3517
- * pulls it from here.
3518
- *
3519
- * Auth funnels through the same resolveTenant as every other endpoint, so with
3520
- * a serverToken configured this route requires `X-Client-Token` too (the
3521
- * all-or-nothing contract). The public key itself is not a secret; gating it
3522
- * just keeps every endpoint consistent.
3523
- *
3524
- * @param {Object} ctx - Server context ({ vapid, tenantManager, ... }).
3525
- * @returns {{ GET: function }}
3526
- */
3527
-
3528
- function createVapidPublicKeyHandler(ctx) {
3529
- async function GET(url, headers) {
3530
- const effectiveHeaders = headers || url || {};
3531
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
3532
- if (!tenantResult.ok) {
3533
- return tenantResult.error;
3534
- }
3535
-
3536
- const publicKey = ctx.vapid && ctx.vapid.publicKey;
3537
- if (!publicKey) {
3538
- return {
3539
- status: 503,
3540
- body: {
3541
- success: false,
3542
- error: {
3543
- code: 'VAPID_NOT_CONFIGURED',
3544
- message: 'VAPID 公钥未配置:请为本 Worker 设置 VAPID_PUBLIC_KEY'
3545
- }
3546
- }
3547
- };
3548
- }
3549
-
3550
- return {
3551
- status: 200,
3552
- body: { success: true, publicKey }
3553
- };
3554
- }
3555
-
3556
- return { GET };
3557
- }
3558
-
3559
- /**
3560
- * Handler: client-state
3561
- *
3562
- * Cloud mirror of client-side state for the single-user deployment. The
3563
- * client batch-syncs entries up (PUT) whenever convenient — e.g. in the
3564
- * few-seconds window before iOS backgrounds the page — and fire-time
3565
- * hooks read them back via ctx.readState(namespace). One live copy per
3566
- * (user, namespace, key).
3567
- *
3568
- * 反方向也通:hook 用 ctx.writeState() 写进来的条目和这里写的完全同构
3569
- * (落库共用 lib/client-state-store.js),客户端 GET 回去读得到,读的时候
3570
- * 不区分是谁写的。
3571
- *
3572
- * PUT /client-state batch upsert, last-write-wins on updatedAt
3573
- * GET /client-state?namespace=<ns> one namespace's entries (decrypted, response re-encrypted)
3574
- * DELETE /client-state wipe every entry of this user
3575
- *
3576
- * 单条 value 超过 200KB 时由服务端透明分块(见 lib/state-chunks.js):写入时
3577
- * 切片跨行存储,GET / readState 返回拼好的原值,客户端与 hook 作者无感。
3578
- * 单条总上限默认 5MB,工厂配置 maxStateValueBytes 可调。批内某条超限/非法
3579
- * 只拒它自己:有拒绝时响应带 data.rejected 逐条给原因,全部成功时响应形状
3580
- * 与单值时代完全一致。
3581
- *
3582
- * Auth & crypto follow the existing endpoints exactly: X-Client-Token is
3583
- * all-or-nothing via resolveTenant, PUT bodies must be encrypted
3584
- * (X-Payload-Encrypted / X-Encryption-Version), values are stored as
3585
- * encryptForStorage ciphertext under the per-user key, and GET responses
3586
- * ride the existing encrypted-response envelope.
3587
- */
3588
-
3589
- // "a few dozen entries in one background-window request" is the design
3590
- // load; 200 bounds a single request with generous headroom.
3591
- const MAX_STATE_ENTRIES_PER_REQUEST = MAX_STATE_ENTRIES_PER_BATCH;
3592
-
3593
- function err(status, code, message, details) {
3594
- const error = details === undefined ? { code, message } : { code, message, details };
3595
- return { status, body: { success: false, error } };
3596
- }
3597
-
3598
- function requireUserId(headers) {
3599
- const userId = getHeader(headers, 'x-user-id');
3600
- if (!userId) return { error: err(400, 'USER_ID_REQUIRED', '缺少用户标识符') };
3601
- if (!isValidUUIDv4(userId)) return { error: err(400, 'INVALID_USER_ID_FORMAT', 'X-User-Id 必须是 UUID v4 格式') };
3602
- return { userId };
3603
- }
3604
-
3605
- function rejectEntry(entry, index, code, message, extra) {
3606
- const rejection = { index, code, message, ...(extra || {}) };
3607
- if (entry && typeof entry === 'object') {
3608
- if (typeof entry.namespace === 'string') rejection.namespace = entry.namespace;
3609
- if (typeof entry.key === 'string') rejection.key = entry.key;
3610
- }
3611
- return rejection;
3612
- }
3613
-
3614
- // 逐条校验:返回 null(合法)或拒绝对象(进 data.rejected,只拒这一条)。
3615
- function validateEntry(entry, index, maxValueBytes) {
3616
- if (!isPlainObject(entry)) {
3617
- return rejectEntry(entry, index, 'INVALID_STATE_ENTRY', `entries[${index}] 必须是对象`);
3618
- }
3619
- if (typeof entry.namespace !== 'string' || !entry.namespace.trim() || entry.namespace.length > MAX_NAMESPACE_CHARS) {
3620
- return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 必须是 1-${MAX_NAMESPACE_CHARS} 字符的字符串`);
3621
- }
3622
- if (INTERNAL_STATE_CHAR_RE.test(entry.namespace)) {
3623
- return rejectEntry(entry, index, 'INVALID_STATE_NAMESPACE', `entries[${index}].namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
3624
- }
3625
- if (typeof entry.key !== 'string' || !entry.key.trim() || entry.key.length > MAX_KEY_CHARS) {
3626
- return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 必须是 1-${MAX_KEY_CHARS} 字符的字符串`);
3627
- }
3628
- if (INTERNAL_STATE_CHAR_RE.test(entry.key)) {
3629
- return rejectEntry(entry, index, 'INVALID_STATE_KEY', `entries[${index}].key 不能包含控制字符(\\u0000-\\u001f 为库内部保留)`);
3630
- }
3631
- if (typeof entry.value !== 'string') {
3632
- return rejectEntry(entry, index, 'INVALID_STATE_VALUE', `entries[${index}].value 必须是字符串(宿主自行序列化)`);
3633
- }
3634
- const bytes = stateValueBytes(entry.value);
3635
- if (bytes > maxValueBytes) {
3636
- return rejectEntry(entry, index, 'STATE_VALUE_TOO_LARGE', `entries[${index}].value 超过单条总上限`, { bytes, maxBytes: maxValueBytes });
3637
- }
3638
- if (!Number.isInteger(entry.updatedAt) || entry.updatedAt <= 0) {
3639
- return rejectEntry(entry, index, 'INVALID_STATE_UPDATED_AT', `entries[${index}].updatedAt 必须是正整数(epoch 毫秒)`);
3640
- }
3641
- return null;
3642
- }
3643
-
3644
- function createClientStateHandler(ctx) {
3645
- async function PUT(headers, body) {
3646
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3647
- if (!tenantResult.ok) return tenantResult.error;
3648
- const { db, masterKey } = tenantResult.context;
3649
-
3650
- if (getHeader(headers, 'x-payload-encrypted') !== 'true') {
3651
- return err(400, 'ENCRYPTION_REQUIRED', '请求体必须加密');
3652
- }
3653
- const gate = requireUserId(headers);
3654
- if (gate.error) return gate.error;
3655
- const { userId } = gate;
3656
- if (getHeader(headers, 'x-encryption-version') !== '1') {
3657
- return err(400, 'UNSUPPORTED_ENCRYPTION_VERSION', '加密版本不支持');
3658
- }
3659
-
3660
- const parsedBody = parseEncryptedBody(body);
3661
- if (!parsedBody.ok) return { status: 400, body: { success: false, error: parsedBody.error } };
3662
-
3663
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
3664
- let payload;
3665
- try {
3666
- payload = await decryptPayload(parsedBody.data, userKey);
3667
- } catch (error) {
3668
- if (error instanceof SyntaxError) {
3669
- return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据不是有效 JSON');
3670
- }
3671
- return err(400, 'DECRYPTION_FAILED', '请求体解密失败');
3672
- }
3673
- if (!isPlainObject(payload)) return err(400, 'INVALID_PAYLOAD_FORMAT', '解密后的数据必须是 JSON 对象');
3674
-
3675
- const entries = payload.entries;
3676
- if (!Array.isArray(entries) || entries.length === 0) {
3677
- return err(400, 'INVALID_STATE_ENTRIES', 'entries 必须是非空数组');
3678
- }
3679
- if (entries.length > MAX_STATE_ENTRIES_PER_REQUEST) {
3680
- return err(400, 'TOO_MANY_STATE_ENTRIES', `单次最多 ${MAX_STATE_ENTRIES_PER_REQUEST} 条`, { count: entries.length });
3681
- }
3682
- const maxValueBytes = Number.isInteger(ctx.maxStateValueBytes) && ctx.maxStateValueBytes > 0
3683
- ? ctx.maxStateValueBytes
3684
- : DEFAULT_MAX_STATE_VALUE_BYTES;
3685
-
3686
- // 逐条校验:坏条目只拒它自己(进 rejected),好条目照常入库。
3687
- const accepted = [];
3688
- const rejected = [];
3689
- for (let i = 0; i < entries.length; i++) {
3690
- const rejection = validateEntry(entries[i], i, maxValueBytes);
3691
- if (rejection) rejected.push(rejection); else accepted.push(entries[i]);
3692
- }
3693
-
3694
- if (typeof db.upsertClientState !== 'function') {
3695
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3696
- }
3697
-
3698
- // 落库(加密 + 大值分块 + 旧切片清理)与 hook 的 ctx.writeState() 共用
3699
- // lib/client-state-store.js。这条路径只写不删,所以 deleted 恒为 0。
3700
- const { upserted, skipped } = await writeClientStateEntries({ db, userId, userKey, entries: accepted });
3701
-
3702
- const data = { upserted, skipped };
3703
- if (rejected.length > 0) data.rejected = rejected;
3704
- return { status: 200, body: { success: true, data } };
3705
- }
3706
-
3707
- async function GET(url, headers) {
3708
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3709
- if (!tenantResult.ok) return tenantResult.error;
3710
- const { db, masterKey } = tenantResult.context;
3711
-
3712
- const gate = requireUserId(headers);
3713
- if (gate.error) return gate.error;
3714
- const { userId } = gate;
3715
-
3716
- const namespace = new URL(url, 'https://dummy').searchParams.get('namespace') || '';
3717
- if (!namespace.trim()) return err(400, 'NAMESPACE_REQUIRED', '必须提供 namespace 查询参数');
3718
- if (INTERNAL_STATE_CHAR_RE.test(namespace)) {
3719
- return err(400, 'INVALID_STATE_NAMESPACE', 'namespace 不能包含控制字符(\\u0000-\\u001f 为库内部保留)');
3720
- }
3721
-
3722
- if (typeof db.getClientState !== 'function') {
3723
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3724
- }
3725
-
3726
- const userKey = await deriveUserEncryptionKey(userId, masterKey);
3727
- const rows = await db.getClientState(userId, namespace);
3728
- // 分块存储的值在这里拼回原文;块不齐全的 key 视为不存在(不抛错)。
3729
- const decrypted = await resolveClientStateEntries(
3730
- rows,
3731
- () => db.getClientState(userId, chunkNamespaceFor(namespace)),
3732
- (value) => decryptFromStorage(value, userKey)
3733
- );
3734
-
3735
- const encryptedResponse = await encryptPayload({ namespace, entries: decrypted }, userKey);
3736
- return { status: 200, body: { success: true, encrypted: true, version: 1, data: encryptedResponse } };
3737
- }
3738
-
3739
- async function DELETE(url, headers) {
3740
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
3741
- if (!tenantResult.ok) return tenantResult.error;
3742
- const { db } = tenantResult.context;
3743
-
3744
- const gate = requireUserId(headers);
3745
- if (gate.error) return gate.error;
3746
- const { userId } = gate;
3747
-
3748
- if (typeof db.clearClientState !== 'function') {
3749
- return err(501, 'CLIENT_STATE_NOT_SUPPORTED', '当前数据库适配器不支持 client_state');
3750
- }
3751
-
3752
- const deleted = await db.clearClientState(userId);
3753
- return { status: 200, body: { success: true, data: { deleted } } };
3754
- }
3755
-
3756
- return { PUT, GET, DELETE };
3757
- }
3758
-
3759
- /**
3760
- * 构建期注入的包版本。tsup 用 define 把 __AMSG_SERVER_VERSION__ 替换成
3761
- * package.json 的 version(见 tsup.config.js),发布产物里是真实版本号;
3762
- * 直接跑 src(node --test / 本地调试)没有这个替换,typeof 守卫落到
3763
- * '0.0.0-dev'。
3764
- */
3765
- /* global __AMSG_SERVER_VERSION__ */
3766
- const SERVER_VERSION =
3767
- typeof __AMSG_SERVER_VERSION__ !== 'undefined' ? __AMSG_SERVER_VERSION__ : '0.0.0-dev';
3768
-
3769
- /**
3770
- * Handler: capabilities
3771
- *
3772
- * GET /capabilities → { success, serverVersion, features }。前端用它做特性
3773
- * 探测:worker 部署版本落后时,新链路只是「探测不到」(而不是静默失效),
3774
- * 设置页可以据此提示重新部署 worker。老部署没有这个路由 → 404,客户端 SDK
3775
- * 的 getCapabilities() 把 404 归一成 null。
3776
- *
3777
- * features 表达「这份代码支持什么」,随版本静态演进追加;不反映部署配置——
3778
- * 例如 'agentic-hooks' 表示该版本认识 fire-time hooks,宿主配没配 hooks 不
3779
- * 影响它出现。
3780
- *
3781
- * 鉴权与 /vapid-public-key 同待遇:走 resolveTenant,配置 serverToken 后同样
3782
- * 要求 X-Client-Token。
3783
- */
3784
-
3785
-
3786
- const SERVER_FEATURES = Object.freeze([
3787
- 'client-state',
3788
- 'client-state-chunking',
3789
- 'client-state-partial-failure',
3790
- 'agentic-hooks',
3791
- 'agentic-scratch',
3792
- 'agentic-write-state',
3793
- 'agentic-fire-tools',
3794
- 'agentic-schedule-task',
3795
- 'vapid-public-key',
3796
- ]);
3797
-
3798
- function createCapabilitiesHandler(ctx) {
3799
- async function GET(url, headers) {
3800
- const effectiveHeaders = headers || url || {};
3801
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
3802
- if (!tenantResult.ok) {
3803
- return tenantResult.error;
3804
- }
3805
- return {
3806
- status: 200,
3807
- body: { success: true, serverVersion: SERVER_VERSION, features: [...SERVER_FEATURES] },
3808
- };
3809
- }
3810
- return { GET };
3811
- }
3812
-
3813
- /**
3814
- * Single-user ReiStandard server assembly.
3815
- *
3816
- * Same shape as createReiServer ({ handlers }), but wired for a single user:
3817
- * - tenant context comes from createSingleUserContextManager (db + masterKey
3818
- * supplied by the caller; no blob registry, no tenant token)
3819
- * - only the 5 business handlers + an idempotent init route are exposed
3820
- * - send-notifications is NOT exposed over HTTP (cron runs via CF scheduled())
3821
- *
3822
- * @param {Object} config
3823
- * @param {import('./adapters/interface.js').DbAdapter} config.db
3824
- * @param {string} config.masterKey
3825
- * @param {string} [config.serverToken] - optional shared secret (X-Client-Token)
3826
- * @param {{ email?: string, publicKey?: string, privateKey?: string }} [config.vapid]
3827
- * @param {{ sendNotification: function }} [config.webpush] - web-push-compatible sender
3828
- * @param {Object} [config.hooks] - optional fire-time hooks (see lib/agentic-fire.js):
3829
- * { onBeforeFire, onLLMOutput, executeToolCalls }. When omitted, AI tasks
3830
- * replay the schedule-time frozen prompt (legacy behavior, unchanged).
3831
- * @param {number} [config.maxToolIterations] - factory default LLM-round cap for the agentic loop (default 5).
3832
- * @param {number} [config.totalTimeoutMs] - factory default wall-time ceiling for the agentic loop (default 240000).
3833
- * @param {number} [config.maxStateValueBytes] - client_state 单条 value 的总上限(默认 5MB)。超过 200KB 的值由服务端透明分块存储(见 lib/state-chunks.js)。
3834
- * @param {number} [config.maxScheduledTasksPerFire] - 一次 fire 里 hook 用 ctx.scheduleTask() 最多能建几条后续任务(默认 2,0 表示不许自排)。
3835
- * @returns {{ handlers: Object, ctx: Object }}
3836
- */
3837
-
3838
-
3839
- function createSingleUserServer(config) {
3840
- if (!config || !config.db) throw new Error('[amsg-server single-user] config.db is required');
3841
- if (!config.masterKey) throw new Error('[amsg-server single-user] config.masterKey is required');
3842
-
3843
- const vapid = config.vapid || {};
3844
- const tenantManager = createSingleUserContextManager({
3845
- db: config.db,
3846
- masterKey: config.masterKey,
3847
- serverToken: config.serverToken
3848
- });
3849
-
3850
- const ctx = {
3851
- vapid: {
3852
- email: vapid.email || '',
3853
- publicKey: vapid.publicKey || '',
3854
- privateKey: vapid.privateKey || ''
3855
- },
3856
- webpush: config.webpush || null,
3857
- tenantManager,
3858
- // Fire-time hooks (optional): the in-server instant path fires through
3859
- // processMessagesByUuid with this ctx, so instant-type tasks can take
3860
- // the agentic path too.
3861
- hooks: config.hooks || null,
3862
- maxToolIterations: config.maxToolIterations,
3863
- totalTimeoutMs: config.totalTimeoutMs,
3864
- maxStateValueBytes: config.maxStateValueBytes,
3865
- // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
3866
- maxScheduledTasksPerFire: config.maxScheduledTasksPerFire
3867
- };
3868
-
3869
- return {
3870
- ctx,
3871
- handlers: {
3872
- init: createSingleUserInitHandler(ctx),
3873
- getUserKey: createGetUserKeyHandler(ctx),
3874
- scheduleMessage: createScheduleMessageHandler(ctx),
3875
- updateMessage: createUpdateMessageHandler(ctx),
3876
- cancelMessage: createCancelMessageHandler(ctx),
3877
- messages: createMessagesHandler(ctx),
3878
- vapidPublicKey: createVapidPublicKeyHandler(ctx),
3879
- clientState: createClientStateHandler(ctx),
3880
- capabilities: createCapabilitiesHandler(ctx)
3881
- }
3882
- };
3883
- }
3884
-
3885
- /**
3886
- * Web Push — RFC 8030 (transport) + RFC 8291 (aes128gcm payload encryption)
3887
- * + RFC 8292 (VAPID).
3888
- *
3889
- * Pure-WebCrypto implementation. Zero runtime dependencies. Runs natively on
3890
- * Cloudflare Workers, Vercel Edge, Netlify Edge, Deno, Bun, and Node ≥ 19.
3891
- * Node 18 deployments must go through the `adapters/node` entry which
3892
- * polyfills `globalThis.crypto` from `node:crypto.webcrypto`.
3893
- *
3894
- * The wire format produced here is byte-identical to the `web-push` npm
3895
- * package and to the Push API in any modern browser, so amsg-sw and any
3896
- * existing Web Push subscriptions keep working untouched.
3897
- */
3898
-
3899
-
3900
- // RFC 8291 fixed labels (each followed by a NUL byte per HKDF "info" framing).
3901
- const KEY_INFO_PREFIX = utf8$1('WebPush: info\0');
3902
- const CEK_INFO = utf8$1('Content-Encoding: aes128gcm\0');
3903
- const NONCE_INFO = utf8$1('Content-Encoding: nonce\0');
3904
-
3905
- const VAPID_DEFAULT_TTL = 60; // seconds — short, matches single-shot instant.
3906
- const VAPID_TOKEN_LIFETIME = 12 * 3600; // 12h — comfortably under the 24h RFC 8292 cap.
3907
- const RECORD_SIZE = 4096; // arbitrary — must be ≥ ciphertext length.
3908
-
3909
- // ─── Payload budget ────────────────────────────────────────────────────
3910
- //
3911
- // 推送服务(FCM / APNs / Mozilla autopush)限的是 POST 上去的**密文** body,
3912
- // 上限 4096 字节,超了当场 413,消息就没了。明文能塞多少要把 aes128gcm 的固
3913
- // 定开销减掉:
3914
- //
3915
- // 密文 body = header(86) + 明文 + 填充分隔符(1) + GCM auth tag(16)
3916
- // header = salt(16) + record size(4) + keyid 长度(1) + keyid(65)
3917
- //
3918
- // keyid 是应用服务器的 P-256 公钥,恒定 65 字节;单记录只带一个 0x02 分隔符
3919
- // (RFC 8188 §2),不额外填充。所以开销是固定的 103 字节,明文上限
3920
- // 4096 - 103 = 3993 字节(UTF-8 计,不是字符数)。
3921
- // 这个换算由 webpush-webcrypto.test.mjs 钉住:上限大小的 payload 编出来的
3922
- // body 恰好 4096 字节。
3923
-
3924
- /** 推送服务对加密后 body 的上限(字节)。 */
3925
- const WEB_PUSH_MAX_BODY_BYTES = 4096;
3926
- /** aes128gcm 固定开销:header 86 + 填充分隔符 1 + GCM tag 16。 */
3927
- const WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES = 16 + 4 + 1 + 65 + 1 + 16;
3928
- /** 一条 push 的明文(JSON 字符串)上限,UTF-8 字节数。 */
3929
- const MAX_PUSH_PAYLOAD_BYTES = WEB_PUSH_MAX_BODY_BYTES - WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES;
3930
-
3931
- const payloadEncoder = new TextEncoder();
3932
-
3933
- /**
3934
- * 组 payload 前做预算用:先量骨架有多大,剩下多少字节才是能塞附加数据的额度。
3935
- *
3936
- * @example
3937
- * const { remainingBytes } = measurePushPayload(JSON.stringify(basePush));
3938
- * const excerpt = remainingBytes > 0 ? text.slice(0, remainingBytes) : '';
3939
- *
3940
- * @param {string} payload - 待发送的 payload 字符串(通常是 JSON.stringify 的结果)。
3941
- * @returns {{ bytes: number, maxBytes: number, remainingBytes: number, withinLimit: boolean }}
3942
- * `bytes` 是 payload 的 UTF-8 字节数;`remainingBytes` 是还能加多少字节
3943
- * (已超限时为负);`withinLimit` 为 false 时 sendWebPush 会抛
3944
- * `PUSH_PAYLOAD_TOO_LARGE`。
3945
- */
3946
- function measurePushPayload(payload) {
3947
- const bytes = payloadEncoder.encode(typeof payload === 'string' ? payload : String(payload)).length;
3948
- return {
3949
- bytes,
3950
- maxBytes: MAX_PUSH_PAYLOAD_BYTES,
3951
- remainingBytes: MAX_PUSH_PAYLOAD_BYTES - bytes,
3952
- withinLimit: bytes <= MAX_PUSH_PAYLOAD_BYTES,
3953
- };
3954
- }
3955
-
3956
- /**
3957
- * Send a single Web Push notification.
3958
- *
3959
- * @param {Object} args
3960
- * @param {Object} args.subscription - Standard PushSubscription JSON.
3961
- * @param {string} args.subscription.endpoint
3962
- * @param {Object} args.subscription.keys
3963
- * @param {string} args.subscription.keys.p256dh - base64url, 65 B uncompressed P-256 point.
3964
- * @param {string} args.subscription.keys.auth - base64url, 16 B auth secret.
3965
- * @param {string} args.payload - Already-stringified JSON to deliver.
3966
- * @param {Object} args.vapid
3967
- * @param {string} args.vapid.email - VAPID `sub` (mailto: auto-prepended if missing).
3968
- * @param {string} args.vapid.publicKey - base64url, 65 B uncompressed P-256 point.
3969
- * @param {string} args.vapid.privateKey - base64url, 32 B scalar.
3970
- * @param {number} [args.ttl=60] - Push service TTL header, seconds.
3971
- * @param {typeof fetch} [args.fetch] - Override fetch impl (testing / proxy).
3972
- * @returns {Promise<{ statusCode: number, body: string, headers: Headers }>}
3973
- * @throws {Error} err.code = 'PUSH_PAYLOAD_TOO_LARGE' when the payload
3974
- * exceeds MAX_PUSH_PAYLOAD_BYTES (nothing is sent);
3975
- * err.code = 'PUSH_SEND_FAILED' on push-service error.
3976
- */
3977
- async function sendWebPush({ subscription, payload, vapid, ttl, fetch: fetchImpl }) {
3978
- if (!subscription || typeof subscription.endpoint !== 'string') {
3979
- throw new Error('sendWebPush: invalid subscription');
3980
- }
3981
- if (typeof payload !== 'string') {
3982
- throw new Error('sendWebPush: payload must be a string');
3983
- }
3984
- if (!vapid || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
3985
- throw new Error('VAPID_CONFIG_MISSING');
3986
- }
3987
-
3988
- const subscriptionKeys = subscription.keys || {};
3989
- if (typeof subscriptionKeys.p256dh !== 'string' || typeof subscriptionKeys.auth !== 'string') {
3990
- throw new Error('sendWebPush: subscription.keys.p256dh and .auth are required');
3991
- }
3992
-
3993
- // 超限的 payload 发出去只会被推送服务 413 掉,用户什么也收不到。加密之前就
3994
- // 拦下来,让调用方拿到一个说得清的错误(宿主可以退回一条短消息 + 引用键)。
3995
- const size = measurePushPayload(payload);
3996
- if (!size.withinLimit) {
3997
- const err = new Error(
3998
- `sendWebPush: payload is ${size.bytes} bytes, over the ${MAX_PUSH_PAYLOAD_BYTES}-byte limit ` +
3999
- `(push services cap the encrypted body at ${WEB_PUSH_MAX_BODY_BYTES} bytes; ` +
4000
- `aes128gcm adds ${WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES} bytes)`
4001
- );
4002
- err.code = 'PUSH_PAYLOAD_TOO_LARGE';
4003
- err.bytes = size.bytes;
4004
- err.maxBytes = MAX_PUSH_PAYLOAD_BYTES;
4005
- throw err;
4006
- }
4007
-
4008
- const encryptedBody = await encryptPushPayload({
4009
- plaintext: utf8$1(payload),
4010
- uaPublicKey: base64UrlToBytes(subscriptionKeys.p256dh),
4011
- authSecret: base64UrlToBytes(subscriptionKeys.auth),
4012
- });
4013
-
4014
- const jwt = await buildVapidJwt({
4015
- audience: originOf(subscription.endpoint),
4016
- subject: normalizeVapidSubject(vapid.email),
4017
- publicKey: vapid.publicKey,
4018
- privateKey: vapid.privateKey,
4019
- });
4020
-
4021
- const fetchFn = fetchImpl || globalThis.fetch;
4022
- if (typeof fetchFn !== 'function') {
4023
- throw new Error('sendWebPush: no fetch implementation available');
4024
- }
4025
-
4026
- const res = await fetchFn(subscription.endpoint, {
4027
- method: 'POST',
4028
- headers: {
4029
- 'Content-Type': 'application/octet-stream',
4030
- 'Content-Encoding': 'aes128gcm',
4031
- 'TTL': String(Number.isFinite(ttl) ? ttl : VAPID_DEFAULT_TTL),
4032
- 'Authorization': `vapid t=${jwt}, k=${vapid.publicKey}`,
4033
- },
4034
- body: encryptedBody,
4035
- });
4036
-
4037
- // Push services return 201 (RFC 8030) or 200/202 depending on implementation.
4038
- if (!res.ok) {
4039
- const text = await safeReadText(res);
4040
- const err = new Error(
4041
- `Web Push delivery failed: ${res.status} ${res.statusText || ''}${text ? ` — ${text}` : ''}`
4042
- );
4043
- err.code = 'PUSH_SEND_FAILED';
4044
- err.statusCode = res.status;
4045
- throw err;
4046
- }
4047
-
4048
- return {
4049
- statusCode: res.status,
4050
- body: await safeReadText(res),
4051
- headers: res.headers,
4052
- };
4053
- }
4054
-
4055
- // ─── RFC 8291: aes128gcm payload encryption ────────────────────────────
4056
-
4057
- /**
4058
- * @param {Object} args
4059
- * @param {Uint8Array} args.plaintext
4060
- * @param {Uint8Array} args.uaPublicKey - recipient p256dh, 65 B uncompressed.
4061
- * @param {Uint8Array} args.authSecret - recipient auth, 16 B.
4062
- * @returns {Promise<Uint8Array>} encryption header || ciphertext
4063
- */
4064
- async function encryptPushPayload({ plaintext, uaPublicKey, authSecret }) {
4065
- // 1. Ephemeral ECDH key pair (as = "application server" per RFC 8291).
4066
- const asKeyPair = await globalThis.crypto.subtle.generateKey(
4067
- { name: 'ECDH', namedCurve: 'P-256' },
4068
- true,
4069
- ['deriveBits']
4070
- );
4071
- const asPublicRaw = new Uint8Array(
4072
- await globalThis.crypto.subtle.exportKey('raw', asKeyPair.publicKey)
4073
- );
4074
-
4075
- // 2. ECDH shared secret with recipient's p256dh.
4076
- const uaPublicCryptoKey = await globalThis.crypto.subtle.importKey(
4077
- 'raw',
4078
- uaPublicKey,
4079
- { name: 'ECDH', namedCurve: 'P-256' },
4080
- false,
4081
- []
4082
- );
4083
- const ecdhSecret = new Uint8Array(
4084
- await globalThis.crypto.subtle.deriveBits(
4085
- { name: 'ECDH', public: uaPublicCryptoKey },
4086
- asKeyPair.privateKey,
4087
- 256
4088
- )
4089
- );
4090
-
4091
- // 3. IKM = HKDF-SHA256(salt=auth_secret, ikm=ecdh_secret,
4092
- // info="WebPush: info\0" || ua_public || as_public, L=32)
4093
- const keyInfo = concatBytes(KEY_INFO_PREFIX, uaPublicKey, asPublicRaw);
4094
- const ikm = await hkdfSha256(authSecret, ecdhSecret, keyInfo, 32);
4095
-
4096
- // 4. encryption_salt (random, 16 B). Goes into the header so the recipient
4097
- // can re-derive CEK / NONCE.
4098
- const salt = randomBytes(16);
4099
-
4100
- // 5. CEK = HKDF-SHA256(salt, ikm, "Content-Encoding: aes128gcm\0", 16)
4101
- const cekBytes = await hkdfSha256(salt, ikm, CEK_INFO, 16);
4102
- const cek = await globalThis.crypto.subtle.importKey(
4103
- 'raw',
4104
- cekBytes,
4105
- { name: 'AES-GCM' },
4106
- false,
4107
- ['encrypt']
4108
- );
4109
-
4110
- // 6. NONCE = HKDF-SHA256(salt, ikm, "Content-Encoding: nonce\0", 12)
4111
- const nonce = await hkdfSha256(salt, ikm, NONCE_INFO, 12);
4112
-
4113
- // 7. Single-record AES-128-GCM. Padding delimiter 0x02 marks the final
4114
- // (and only) record per RFC 8188 §2.
4115
- const padded = concatBytes(plaintext, new Uint8Array([0x02]));
4116
- const ciphertext = new Uint8Array(
4117
- await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, cek, padded)
4118
- );
4119
-
4120
- // 8. aes128gcm content-encoding framing (RFC 8188 §2.1):
4121
- // salt(16) || rs(4 BE) || idlen(1) || keyid(idlen) || ciphertext
4122
- // For Web Push, keyid is the application-server public key (65 B).
4123
- const header = new Uint8Array(16 + 4 + 1 + asPublicRaw.byteLength);
4124
- header.set(salt, 0);
4125
- writeUint32BE(header, 16, RECORD_SIZE);
4126
- header[20] = asPublicRaw.byteLength;
4127
- header.set(asPublicRaw, 21);
4128
-
4129
- return concatBytes(header, ciphertext);
4130
- }
4131
-
4132
- /**
4133
- * HKDF-SHA-256 (extract-then-expand) via WebCrypto.
4134
- *
4135
- * @param {Uint8Array} salt
4136
- * @param {Uint8Array} ikm
4137
- * @param {Uint8Array} info
4138
- * @param {number} length - desired output length in bytes (≤ 32 in our usage).
4139
- * @returns {Promise<Uint8Array>}
4140
- */
4141
- async function hkdfSha256(salt, ikm, info, length) {
4142
- const baseKey = await globalThis.crypto.subtle.importKey(
4143
- 'raw',
4144
- toUint8(ikm),
4145
- { name: 'HKDF' },
4146
- false,
4147
- ['deriveBits']
4148
- );
4149
- const bits = await globalThis.crypto.subtle.deriveBits(
4150
- {
4151
- name: 'HKDF',
4152
- hash: 'SHA-256',
4153
- salt: toUint8(salt),
4154
- info: toUint8(info),
4155
- },
4156
- baseKey,
4157
- length * 8
4158
- );
4159
- return new Uint8Array(bits);
4160
- }
4161
-
4162
- // ─── RFC 8292: VAPID JWT ───────────────────────────────────────────────
4163
-
4164
- /**
4165
- * Build a VAPID `Authorization` JWT for a single push.
4166
- *
4167
- * @param {Object} args
4168
- * @param {string} args.audience - Origin of the push endpoint (e.g. https://fcm.googleapis.com).
4169
- * @param {string} args.subject - VAPID `sub` claim, typically `mailto:you@example.com`.
4170
- * @param {string} args.publicKey - base64url, 65 B uncompressed P-256 point.
4171
- * @param {string} args.privateKey - base64url, 32 B scalar.
4172
- * @returns {Promise<string>} compact JWS (three base64url segments).
4173
- */
4174
- async function buildVapidJwt({ audience, subject, publicKey, privateKey }) {
4175
- const header = jsonToBase64Url({ typ: 'JWT', alg: 'ES256' });
4176
- const payload = jsonToBase64Url({
4177
- aud: audience,
4178
- exp: Math.floor(Date.now() / 1000) + VAPID_TOKEN_LIFETIME,
4179
- sub: subject,
4180
- });
4181
-
4182
- const signingInput = utf8$1(`${header}.${payload}`);
4183
-
4184
- const pubBytes = base64UrlToBytes(publicKey);
4185
- const privBytes = base64UrlToBytes(privateKey);
4186
- if (pubBytes.length !== 65 || pubBytes[0] !== 0x04) {
4187
- throw new Error('VAPID publicKey must be a 65-byte uncompressed P-256 point (base64url).');
4188
- }
4189
- if (privBytes.length !== 32) {
4190
- throw new Error('VAPID privateKey must be a 32-byte scalar (base64url).');
4191
- }
4192
-
4193
- // Import as JWK so we can supply both private scalar (d) and public point
4194
- // (x, y) in one step — required by WebCrypto for ECDSA signing.
4195
- const jwk = {
4196
- kty: 'EC',
4197
- crv: 'P-256',
4198
- d: bytesToBase64Url(privBytes),
4199
- x: bytesToBase64Url(pubBytes.subarray(1, 33)),
4200
- y: bytesToBase64Url(pubBytes.subarray(33, 65)),
4201
- ext: true,
4202
- };
4203
- const key = await globalThis.crypto.subtle.importKey(
4204
- 'jwk',
4205
- jwk,
4206
- { name: 'ECDSA', namedCurve: 'P-256' },
4207
- false,
4208
- ['sign']
4209
- );
4210
-
4211
- // WebCrypto ECDSA produces a raw 64-byte (r || s) signature — exactly the
4212
- // wire format JOSE/JWS expects, so no DER unwrapping is needed.
4213
- const sig = await globalThis.crypto.subtle.sign(
4214
- { name: 'ECDSA', hash: 'SHA-256' },
4215
- key,
4216
- signingInput
4217
- );
4218
-
4219
- return `${header}.${payload}.${bytesToBase64Url(sig)}`;
4220
- }
4221
-
4222
- // ─── Helpers ───────────────────────────────────────────────────────────
4223
-
4224
- function originOf(endpoint) {
4225
- return new URL(endpoint).origin;
4226
- }
4227
-
4228
- function writeUint32BE(buf, offset, value) {
4229
- buf[offset] = (value >>> 24) & 0xff;
4230
- buf[offset + 1] = (value >>> 16) & 0xff;
4231
- buf[offset + 2] = (value >>> 8) & 0xff;
4232
- buf[offset + 3] = value & 0xff;
4233
- }
4234
-
4235
- async function safeReadText(res) {
4236
- try {
4237
- return await res.text();
4238
- } catch {
4239
- return '';
4240
- }
4241
- }
4242
-
4243
- // Scheduled reminders must survive an offline device, so default to the same
4244
- // 4-week TTL the web-push npm backend applies. sendWebPush's module-level
4245
- // VAPID_DEFAULT_TTL (60s) is tuned for single-shot instant pushes; using it for
4246
- // durable schedules would drop any reminder whose device was offline > 1 min.
4247
- const SCHEDULED_DEFAULT_TTL = 2419200; // 4 weeks, in seconds
4248
-
4249
- /**
4250
- * web-push-compatible sender backed by the Web Crypto implementation above.
4251
- * message-processor calls `ctx.webpush.sendNotification(subscription, payloadString)`,
4252
- * so we only need that one method. VAPID keys are baked in at construction.
4253
- *
4254
- * @param {{ email: string, publicKey: string, privateKey: string }} [vapid]
4255
- * @param {{ ttl?: number }} [options] - Push TTL in seconds; defaults to 4 weeks
4256
- * (matches the web-push backend) so scheduled pushes outlive an offline device.
4257
- * @returns {{ sendNotification: (subscription: Object, payload: string) => Promise<any> }}
4258
- */
4259
- function createWebCryptoWebPush(vapid = {}, { ttl = SCHEDULED_DEFAULT_TTL } = {}) {
4260
- return {
4261
- async sendNotification(subscription, payload) {
4262
- return sendWebPush({
4263
- subscription,
4264
- payload,
4265
- ttl,
4266
- vapid: {
4267
- email: vapid.email,
4268
- publicKey: vapid.publicKey,
4269
- privateKey: vapid.privateKey
4270
- },
4271
- fetch: globalThis.fetch
4272
- });
4273
- }
4274
- };
4275
- }
4276
-
4277
- /**
4278
- * Cloudflare Worker factory for the single-user amsg-server.
4279
- *
4280
- * Mirrors instant's createCloudflareWorker: you pass a buildConfig(env) that
4281
- * returns the single-user config; we build the server per request (cheap) and
4282
- * dispatch. Returns { fetch, scheduled } for `export default`.
4283
- *
4284
- * Routes (server endpoints only — NO /send-notifications; cron is scheduled()):
4285
- * POST /init-tenant → build tables (idempotent)
4286
- * GET /get-user-key → derive user key
4287
- * POST /schedule-message → create task
4288
- * GET /messages → list
4289
- * PUT /update-message → patch
4290
- * DELETE /cancel-message → delete
4291
- * GET /vapid-public-key → this worker's VAPID public key (for the frontend's
4292
- * Web Push subscription); 503 if VAPID_PUBLIC_KEY unset
4293
- * GET /capabilities → { serverVersion, features }(特性探测;老部署无此路由 → 404)
4294
- * PUT /client-state → batch upsert client state (last-write-wins on updatedAt)
4295
- * GET /client-state → read one namespace's entries (?namespace=<ns>)
4296
- * DELETE /client-state → wipe this user's client state
4297
- *
4298
- * CORS is opt-in: pass `cors: { origin }` in the config (a fixed origin, '*', or
4299
- * an (origin) => allowedOrigin function) to answer OPTIONS preflights and echo
4300
- * Access-Control-* on responses. With no `cors` the Worker stays same-origin.
4301
- *
4302
- * Fire-time hooks are opt-in too: pass `hooks: { onBeforeFire, onLLMOutput,
4303
- * executeToolCalls }` (+ optional `maxToolIterations` / `totalTimeoutMs` /
4304
- * `maxScheduledTasksPerFire`) in the config to let scheduled AI tasks assemble
4305
- * their prompt and run a server-side tool loop at fire time. Omit them and AI
4306
- * tasks replay the schedule-time frozen prompt exactly as before. See
4307
- * lib/agentic-fire.js.
4308
- *
4309
- * scheduled() 每次触发都会先给任务占位(在行的 lease_until 上写租约),同一
4310
- * 条任务不会被相邻两跳重复触发(见 lib/run-tick.js)。租期默认 10 分钟,可以
4311
- * 用 config 里的 `claimLeaseMs` 调整。
4312
- */
4313
-
4314
-
4315
- function headersToObject(h) {
4316
- const out = {};
4317
- for (const [k, v] of h) out[k] = v;
4318
- return out;
4319
- }
4320
-
4321
- function jsonResponse(status, body, extraHeaders) {
4322
- return new Response(JSON.stringify(body), {
4323
- status,
4324
- headers: { 'Content-Type': 'application/json; charset=utf-8', ...(extraHeaders || {}) }
4325
- });
4326
- }
4327
-
4328
- // The custom headers the amsg-client sends; browsers preflight any request
4329
- // carrying them, so cross-origin callers need them echoed in the CORS response.
4330
- const CORS_ALLOW_HEADERS =
4331
- 'Content-Type, X-User-Id, X-Payload-Encrypted, X-Encryption-Version, X-Response-Encrypted, X-Client-Token';
4332
- const CORS_ALLOW_METHODS = 'GET, POST, PUT, DELETE, OPTIONS';
4333
-
4334
- /**
4335
- * Resolve the CORS response headers for a request, or null when CORS is off.
4336
- * Opt-in: with no `cors` config the Worker stays same-origin (no headers, and
4337
- * OPTIONS falls through to 404) — so nothing is exposed unless asked for.
4338
- *
4339
- * @param {undefined | { origin: string | ((requestOrigin: string) => string|null|undefined), allowHeaders?: string, maxAge?: number }} cors
4340
- * @param {string} requestOrigin - the request's Origin header (may be '')
4341
- */
4342
- function corsHeadersFor(cors, requestOrigin) {
4343
- if (!cors || cors.origin == null) return null;
4344
- const allowOrigin = typeof cors.origin === 'function'
4345
- ? cors.origin(requestOrigin) || null
4346
- : cors.origin; // e.g. '*' or a fixed origin like 'https://app.example.com'
4347
- if (!allowOrigin) return null;
4348
-
4349
- const headers = {
4350
- 'Access-Control-Allow-Origin': allowOrigin,
4351
- 'Access-Control-Allow-Methods': CORS_ALLOW_METHODS,
4352
- 'Access-Control-Allow-Headers': cors.allowHeaders || CORS_ALLOW_HEADERS,
4353
- 'Access-Control-Max-Age': String(cors.maxAge ?? 86400)
4354
- };
4355
- // A per-origin echo must vary the cache by Origin; '*' does not.
4356
- if (allowOrigin !== '*') headers['Vary'] = 'Origin';
4357
- return headers;
4358
- }
4359
-
4360
- function createSingleUserCloudflareWorker(buildConfig) {
4361
- async function resolveConfig(env) {
4362
- const cfg = await buildConfig(env);
4363
- if (!cfg.db) cfg.db = createD1Adapter(env.DB);
4364
- return cfg;
4365
- }
4366
-
4367
- async function fetch(request, env /* , ctx */) {
4368
- // Error boundary: a handler (or config build) may throw — e.g.
4369
- // schedule-message re-throws a non-unique DB error. Keep the client-facing
4370
- // contract consistent (a JSON envelope, not the runtime's HTML error page).
4371
- try {
4372
- const cfg = await resolveConfig(env);
4373
- const cors = corsHeadersFor(cfg.cors, request.headers.get('origin') || '');
4374
- const method = request.method.toUpperCase();
4375
-
4376
- // CORS preflight: answer OPTIONS directly when CORS is configured.
4377
- if (method === 'OPTIONS') {
4378
- return cors
4379
- ? new Response(null, { status: 204, headers: cors })
4380
- : jsonResponse(404, { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } });
4381
- }
4382
-
4383
- const server = createSingleUserServer(cfg);
4384
-
4385
- const url = request.url;
4386
- // Strip trailing slash(es) so `/init-tenant/` routes like `/init-tenant`
4387
- // (endsWith matching is kept so a prefixed mount still resolves).
4388
- const pathname = new URL(url).pathname.replace(/\/+$/, '') || '/';
4389
- const headers = headersToObject(request.headers);
4390
-
4391
- let result;
4392
- if (method === 'POST' && pathname.endsWith('/init-tenant')) {
4393
- result = await server.handlers.init.POST(headers, await request.text());
4394
- } else if (method === 'GET' && pathname.endsWith('/get-user-key')) {
4395
- result = await server.handlers.getUserKey.GET(url, headers);
4396
- } else if (method === 'POST' && pathname.endsWith('/schedule-message')) {
4397
- result = await server.handlers.scheduleMessage.POST(headers, await request.text());
4398
- } else if (method === 'GET' && pathname.endsWith('/messages')) {
4399
- result = await server.handlers.messages.GET(url, headers);
4400
- } else if (method === 'PUT' && pathname.endsWith('/update-message')) {
4401
- result = await server.handlers.updateMessage.PUT(url, headers, await request.text());
4402
- } else if (method === 'DELETE' && pathname.endsWith('/cancel-message')) {
4403
- result = await server.handlers.cancelMessage.DELETE(url, headers);
4404
- } else if (method === 'GET' && pathname.endsWith('/vapid-public-key')) {
4405
- result = await server.handlers.vapidPublicKey.GET(url, headers);
4406
- } else if (method === 'GET' && pathname.endsWith('/capabilities')) {
4407
- result = await server.handlers.capabilities.GET(url, headers);
4408
- } else if (method === 'PUT' && pathname.endsWith('/client-state')) {
4409
- result = await server.handlers.clientState.PUT(headers, await request.text());
4410
- } else if (method === 'GET' && pathname.endsWith('/client-state')) {
4411
- result = await server.handlers.clientState.GET(url, headers);
4412
- } else if (method === 'DELETE' && pathname.endsWith('/client-state')) {
4413
- result = await server.handlers.clientState.DELETE(url, headers);
4414
- } else {
4415
- result = { status: 404, body: { success: false, error: { code: 'NOT_FOUND', message: 'Unknown route' } } };
4416
- }
4417
-
4418
- return jsonResponse(result.status, result.body, cors);
4419
- } catch (error) {
4420
- console.error('[amsg single-user] fetch() unhandled error:', error && error.message);
4421
- return jsonResponse(500, { success: false, error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } });
4422
- }
4423
- }
4424
-
4425
- async function scheduled(event, env /* , ctx */) {
4426
- const cfg = await resolveConfig(env);
4427
- const vapid = cfg.vapid || {};
4428
- if (!cfg.webpush || !vapid.email || !vapid.publicKey || !vapid.privateKey) {
4429
- console.error('[amsg single-user] scheduled(): VAPID/webpush not configured; skipping tick');
4430
- return;
4431
- }
4432
- // Swallow tick failures: pending tasks stay pending, so the next cron tick
4433
- // retries them. Logging keeps the failure visible in the tail log.
4434
- try {
4435
- await runScheduledTick({
4436
- db: cfg.db,
4437
- masterKey: cfg.masterKey,
4438
- vapid,
4439
- webpush: cfg.webpush,
4440
- // Fire-time hooks (optional; see lib/agentic-fire.js). runScheduledTick
4441
- // spreads its ctx into processSingleMessage, so these ride along.
4442
- hooks: cfg.hooks || null,
4443
- maxToolIterations: cfg.maxToolIterations,
4444
- totalTimeoutMs: cfg.totalTimeoutMs,
4445
- // hook 的 ctx.writeState() 用它判断单条 value 的上限,和 PUT /client-state 同一个配置。
4446
- maxStateValueBytes: cfg.maxStateValueBytes,
4447
- // hook 的 ctx.scheduleTask() 单次 fire 建任务的条数上限(默认 2)。
4448
- maxScheduledTasksPerFire: cfg.maxScheduledTasksPerFire,
4449
- // 任务占位租期(默认 10 分钟,随 totalTimeoutMs 抬高)。
4450
- claimLeaseMs: cfg.claimLeaseMs
4451
- });
4452
- } catch (error) {
4453
- console.error('[amsg single-user] scheduled(): tick failed:', error && error.message);
4454
- }
4455
- }
4456
-
4457
- return { fetch, scheduled };
4458
- }
4459
-
4460
- export { MAX_PUSH_PAYLOAD_BYTES as M, WEB_PUSH_ENCRYPTION_OVERHEAD_BYTES as W, createCancelMessageHandler as a, createUpdateMessageHandler as b, createMessagesHandler as c, createScheduleMessageHandler as d, createGetUserKeyHandler as e, WEB_PUSH_MAX_BODY_BYTES as f, getHeader as g, createD1Adapter as h, isValidUUIDv4 as i, createSingleUserCloudflareWorker as j, createSingleUserServer as k, createWebCryptoWebPush as l, decryptFromStorage as m, decryptPayload as n, deriveUserEncryptionKey as o, parseJsonBody as p, encryptForStorage as q, runScheduledTick as r, isValidISO8601 as s, isValidUUID as t, isValidUrl as u, measurePushPayload as v, validateLlmMessagesArray as w, validateScheduleMessagePayload as x, validateSplitPattern as y };