@rei-standard/amsg-server 2.5.2 → 2.6.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
- import { createDecipheriv, createHash, randomBytes, createCipheriv, randomUUID, timingSafeEqual, createHmac } from 'crypto';
2
- import { buildReasoningPush, buildContentPush } from '@rei-standard/amsg-shared';
1
+ import { g as getHeader, p as parseJsonBody, i as isValidUUIDv4, r as runScheduledTick, c as createMessagesHandler, a as createCancelMessageHandler, b as createUpdateMessageHandler, d as createScheduleMessageHandler, e as createGetUserKeyHandler } from './cloudflare-BwOfBtLF.js';
2
+ export { f as createD1Adapter, h as createSingleUserCloudflareWorker, j as createSingleUserServer, k as createWebCryptoWebPush, l as decryptFromStorage, m as decryptPayload, n as deriveUserEncryptionKey, o as encryptForStorage, q as isValidISO8601, s as isValidUUID, t as isValidUrl, v as validateLlmMessagesArray, u as validateScheduleMessagePayload, w as validateSplitPattern } from './cloudflare-BwOfBtLF.js';
3
+ import { createHash, randomBytes, createCipheriv, createDecipheriv, timingSafeEqual, createHmac, randomUUID } from 'crypto';
4
+ import { normalizeVapidSubject } from '@rei-standard/amsg-shared';
5
+ export { validateAvatarUrl } from '@rei-standard/amsg-shared';
3
6
 
4
7
  /**
5
8
  * Adapter Factory
@@ -53,442 +56,6 @@ async function createAdapter(config) {
53
56
  }
54
57
  }
55
58
 
56
- /**
57
- * Validation utility library (SDK version)
58
- * ReiStandard SDK v2.0.1
59
- */
60
-
61
- /**
62
- * Validate ISO 8601 date string.
63
- * @param {string} dateString
64
- * @returns {boolean}
65
- */
66
- function isValidISO8601(dateString) {
67
- const date = new Date(dateString);
68
- return date instanceof Date && !isNaN(date.getTime());
69
- }
70
-
71
- /**
72
- * Validate URL format.
73
- * @param {string} urlString
74
- * @returns {boolean}
75
- */
76
- function isValidUrl(urlString) {
77
- try {
78
- new URL(urlString);
79
- return true;
80
- } catch {
81
- return false;
82
- }
83
- }
84
-
85
- /**
86
- * Validate UUID format.
87
- * @param {string} uuid
88
- * @returns {boolean}
89
- */
90
- function isValidUUID(uuid) {
91
- const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
92
- return uuidRegex.test(uuid);
93
- }
94
-
95
- /**
96
- * Validate UUID v4 format.
97
- * @param {string} uuid
98
- * @returns {boolean}
99
- */
100
- function isValidUUIDv4(uuid) {
101
- 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;
102
- return uuidV4Regex.test(uuid);
103
- }
104
-
105
- const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
106
-
107
- const AVATAR_URL_MAX_LENGTH = 2048;
108
-
109
- /**
110
- * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
111
- * base64-encoded inline images) and anything longer than 2048 chars, both
112
- * of which are the dominant trigger for downstream 413 / Web Push 4 KB
113
- * payload errors. Returns an error message string, or null when valid.
114
- *
115
- * Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
116
- * on purpose — both packages forward `avatarUrl` to the same SW push payload).
117
- *
118
- * @param {unknown} value
119
- * @returns {string | null}
120
- */
121
- function validateAvatarUrl(value) {
122
- if (value === undefined || value === null) return null;
123
- if (typeof value !== 'string') {
124
- return 'avatarUrl 必须是字符串';
125
- }
126
- if (/^data:/i.test(value)) {
127
- return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
128
- }
129
- if (value.length > AVATAR_URL_MAX_LENGTH) {
130
- return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
131
- }
132
- if (!isValidUrl(value)) {
133
- return 'avatarUrl 不是合法 URL';
134
- }
135
- return null;
136
- }
137
-
138
- const SPLIT_PATTERN_MAX_LENGTH = 200;
139
- const SPLIT_PATTERN_MAX_ITEMS = 10;
140
-
141
- /**
142
- * Validate the optional `splitPattern` field. Mirrors
143
- * @rei-standard/amsg-instant's `validateSplitPattern` (kept in lockstep).
144
- * Accepts `string`, `string[]`, or absent/null. Returns an error message
145
- * string, or null when valid.
146
- *
147
- * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
148
- * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
149
- * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
150
- * backtracking. The real backstop is Worker / runtime CPU limits + the
151
- * fact that splitPattern is stored under the user's own encrypted task
152
- * and matched against output from the user's own LLM API key, so the
153
- * blast radius is self-inflicted only (no cross-tenant attack surface).
154
- *
155
- * @param {unknown} value
156
- * @returns {string | null}
157
- */
158
- function validateSplitPattern(value) {
159
- if (value === undefined || value === null) return null;
160
- const isArray = Array.isArray(value);
161
- const items = isArray ? value : [value];
162
- if (isArray && items.length === 0) return null; // empty array = use default
163
- if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
164
- return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
165
- }
166
- for (let i = 0; i < items.length; i++) {
167
- const s = items[i];
168
- const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
169
- if (typeof s !== 'string') return `${label} 必须是字符串`;
170
- if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
171
- return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
172
- }
173
- try { new RegExp(s); }
174
- catch (_) { return `${label} 不是有效正则表达式`; }
175
- }
176
- return null;
177
- }
178
-
179
- /**
180
- * Validate an OpenAI-style messages array. Same shape contract as
181
- * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
182
- * end up forwarding this to the same LLM body).
183
- *
184
- * @param {unknown} messages
185
- * @returns {string | null} Error message, or null if valid.
186
- */
187
- function validateLlmMessagesArray(messages) {
188
- if (!Array.isArray(messages) || messages.length === 0) {
189
- return 'messages must be a non-empty array';
190
- }
191
- for (let i = 0; i < messages.length; i++) {
192
- const m = messages[i];
193
- if (!m || typeof m !== 'object' || Array.isArray(m)) {
194
- return `messages[${i}] must be an object`;
195
- }
196
- if (!VALID_LLM_MESSAGE_ROLES.has(m.role)) {
197
- return `messages[${i}].role must be one of system / user / assistant / tool`;
198
- }
199
- if (typeof m.content === 'string') {
200
- if (!m.content) return `messages[${i}].content must be a non-empty string`;
201
- } else if (Array.isArray(m.content)) {
202
- if (m.content.length === 0) return `messages[${i}].content array must be non-empty`;
203
- // Element schema is intentionally not enforced — passed through to LLM as-is.
204
- } else {
205
- return `messages[${i}].content must be a non-empty string or a non-empty array`;
206
- }
207
- }
208
- return null;
209
- }
210
-
211
- /**
212
- * Validate the schedule-message request payload.
213
- *
214
- * @param {Object} payload
215
- * @returns {{ valid: boolean, errorCode?: string, errorMessage?: string, details?: Object }}
216
- */
217
- function validateScheduleMessagePayload(payload) {
218
- if (!payload.contactName || typeof payload.contactName !== 'string') {
219
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['contactName'] } };
220
- }
221
-
222
- if (!payload.messageType || !['fixed', 'prompted', 'auto', 'instant'].includes(payload.messageType)) {
223
- return { valid: false, errorCode: 'INVALID_MESSAGE_TYPE', errorMessage: '消息类型无效', details: { providedType: payload.messageType, allowedTypes: ['fixed', 'prompted', 'auto', 'instant'] } };
224
- }
225
-
226
- if (!payload.firstSendTime || !isValidISO8601(payload.firstSendTime)) {
227
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间格式无效', details: { field: 'firstSendTime' } };
228
- }
229
-
230
- if (payload.firstSendTime && new Date(payload.firstSendTime) <= new Date()) {
231
- return { valid: false, errorCode: 'INVALID_TIMESTAMP', errorMessage: '时间必须在未来', details: { field: 'firstSendTime', reason: 'must be in the future' } };
232
- }
233
-
234
- if (!payload.pushSubscription || typeof payload.pushSubscription !== 'object') {
235
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['pushSubscription'] } };
236
- }
237
-
238
- if (payload.recurrenceType && !['none', 'daily', 'weekly'].includes(payload.recurrenceType)) {
239
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['recurrenceType'] } };
240
- }
241
-
242
- if (
243
- payload.maxTokens !== undefined &&
244
- payload.maxTokens !== null &&
245
- (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0)
246
- ) {
247
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['maxTokens'] } };
248
- }
249
-
250
- if (payload.messageType === 'fixed') {
251
- if (!payload.userMessage) {
252
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: ['userMessage (required for fixed type)'] } };
253
- }
254
- }
255
-
256
- // ─── Prompt schema (shared by prompted / auto / instant AI configs) ──
257
- //
258
- // Callers provide *exactly one of* `completePrompt` (string) or `messages`
259
- // (OpenAI-style array). Same contract as @rei-standard/amsg-instant; the
260
- // server's LLM path forwards either verbatim.
261
- const promptCheck = (() => {
262
- const hasCompletePrompt = payload.completePrompt !== undefined && payload.completePrompt !== null && payload.completePrompt !== '';
263
- const hasMessages = payload.messages !== undefined && payload.messages !== null;
264
- if (hasCompletePrompt && hasMessages) {
265
- return {
266
- error: { code: 'INVALID_PARAMETERS', message: 'exactly one of `completePrompt` or `messages` must be provided(两者不能同时出现)', details: { invalidFields: ['completePrompt', 'messages'] } },
267
- hasCompletePrompt: true, hasMessages: true,
268
- };
269
- }
270
- if (hasMessages) {
271
- const err = validateLlmMessagesArray(payload.messages);
272
- if (err) {
273
- return {
274
- error: { code: 'INVALID_PARAMETERS', message: err, details: { invalidFields: ['messages'] } },
275
- hasCompletePrompt: false, hasMessages: true,
276
- };
277
- }
278
- }
279
- return { error: null, hasCompletePrompt, hasMessages };
280
- })();
281
-
282
- if (promptCheck.error) {
283
- return { valid: false, errorCode: promptCheck.error.code, errorMessage: promptCheck.error.message, details: promptCheck.error.details };
284
- }
285
- const hasPrompt = promptCheck.hasCompletePrompt || promptCheck.hasMessages;
286
-
287
- if (payload.messageType === 'prompted' || payload.messageType === 'auto') {
288
- const missingAiFields = [];
289
- if (!hasPrompt) missingAiFields.push('completePrompt or messages');
290
- if (!payload.apiUrl) missingAiFields.push('apiUrl');
291
- if (!payload.apiKey) missingAiFields.push('apiKey');
292
- if (!payload.primaryModel) missingAiFields.push('primaryModel');
293
- if (missingAiFields.length > 0) {
294
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { missingFields: missingAiFields } };
295
- }
296
- }
297
-
298
- if (payload.messageType === 'instant') {
299
- if (payload.recurrenceType && payload.recurrenceType !== 'none') {
300
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型的 recurrenceType 必须为 none', details: { invalidFields: ['recurrenceType (must be "none" for instant type)'] } };
301
- }
302
- const hasAiConfig = hasPrompt && payload.apiUrl && payload.apiKey && payload.primaryModel;
303
- const hasUserMessage = payload.userMessage;
304
- if (!hasAiConfig && !hasUserMessage) {
305
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: 'instant 类型必须提供 userMessage 或完整的 AI 配置', details: { missingFields: ['userMessage or ((completePrompt | messages) + apiUrl + apiKey + primaryModel)'] } };
306
- }
307
- }
308
-
309
- if (
310
- payload.temperature !== undefined &&
311
- payload.temperature !== null &&
312
- (typeof payload.temperature !== 'number' || !Number.isFinite(payload.temperature))
313
- ) {
314
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
315
- }
316
-
317
- const avatarErr = validateAvatarUrl(payload.avatarUrl);
318
- if (avatarErr) {
319
- // Soft-strip: a bad avatarUrl (data: URI / oversized / malformed) used to
320
- // 400 the whole schedule. Avatar is cosmetic — drop the field, log, and
321
- // let the rest of the task ship. See standards §6.2.
322
- console.warn('[amsg-server] avatarUrl 不合法,已置空:', avatarErr);
323
- payload.avatarUrl = null;
324
- }
325
- if (payload.uuid && !isValidUUID(payload.uuid)) {
326
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
327
- }
328
- // messageSubtype is a free-form string tag forwarded to SW-side push payload
329
- // for classification. Only the type is enforced; the taxonomy is the
330
- // consumer's call (previously the enum was chat/forum/moment).
331
- if (
332
- payload.messageSubtype !== undefined &&
333
- payload.messageSubtype !== null &&
334
- typeof payload.messageSubtype !== 'string'
335
- ) {
336
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
337
- }
338
-
339
- const splitErr = validateSplitPattern(payload.splitPattern);
340
- if (splitErr) {
341
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
342
- }
343
-
344
- return { valid: true };
345
- }
346
-
347
- /**
348
- * Request payload utilities.
349
- * Keeps body parsing and shape validation consistent across handlers.
350
- */
351
-
352
- const REQUEST_ERRORS = {
353
- INVALID_JSON: { code: 'INVALID_JSON', message: '请求体不是有效的 JSON' },
354
- INVALID_REQUEST_BODY: { code: 'INVALID_REQUEST_BODY', message: '请求体格式无效' },
355
- INVALID_ENCRYPTED_PAYLOAD: { code: 'INVALID_ENCRYPTED_PAYLOAD', message: '加密数据格式错误' }
356
- };
357
-
358
- /**
359
- * @typedef {{ code: string, message: string }} ValidationError
360
- */
361
-
362
- /**
363
- * @typedef {{
364
- * invalidJson?: ValidationError,
365
- * invalidType?: ValidationError
366
- * }} ParseBodyOptions
367
- */
368
-
369
- /**
370
- * @typedef {{
371
- * ok: true,
372
- * data: Record<string, any>
373
- * } | {
374
- * ok: false,
375
- * error: ValidationError
376
- * }} ParseBodyResult
377
- */
378
-
379
- /**
380
- * Parse body into a JSON object.
381
- *
382
- * @param {unknown} body
383
- * @param {ParseBodyOptions} [options]
384
- * @returns {ParseBodyResult}
385
- */
386
- function parseBodyAsObject(body, options = {}) {
387
- const invalidJson = options.invalidJson || REQUEST_ERRORS.INVALID_JSON;
388
- const invalidType = options.invalidType || REQUEST_ERRORS.INVALID_REQUEST_BODY;
389
-
390
- let parsed = body;
391
- if (typeof parsed === 'string') {
392
- try {
393
- parsed = JSON.parse(parsed);
394
- } catch {
395
- return { ok: false, error: invalidJson };
396
- }
397
- }
398
-
399
- if (!isPlainObject(parsed)) {
400
- return { ok: false, error: invalidType };
401
- }
402
-
403
- return { ok: true, data: parsed };
404
- }
405
-
406
- /**
407
- * Parse a standard JSON object body.
408
- *
409
- * @param {unknown} body
410
- * @returns {ParseBodyResult}
411
- */
412
- function parseJsonBody(body) {
413
- return parseBodyAsObject(body, {
414
- invalidJson: REQUEST_ERRORS.INVALID_JSON,
415
- invalidType: REQUEST_ERRORS.INVALID_REQUEST_BODY
416
- });
417
- }
418
-
419
- /**
420
- * Check if a value is a plain object (and not null/array).
421
- *
422
- * @param {unknown} value
423
- * @returns {value is Record<string, any>}
424
- */
425
- function isPlainObject(value) {
426
- return typeof value === 'object' && value !== null && !Array.isArray(value);
427
- }
428
-
429
- /**
430
- * Check if an object follows the encrypted payload envelope shape.
431
- *
432
- * @param {unknown} payload
433
- * @returns {payload is { iv: string, authTag: string, encryptedData: string }}
434
- */
435
- function isEncryptedEnvelope(payload) {
436
- if (!isPlainObject(payload)) return false;
437
-
438
- return (
439
- typeof payload.iv === 'string' &&
440
- typeof payload.authTag === 'string' &&
441
- typeof payload.encryptedData === 'string'
442
- );
443
- }
444
-
445
- /**
446
- * Parse and validate an encrypted payload envelope.
447
- *
448
- * @param {unknown} body
449
- * @returns {ParseBodyResult}
450
- */
451
- function parseEncryptedBody(body) {
452
- const parsedBody = parseBodyAsObject(body, {
453
- invalidJson: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD,
454
- invalidType: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD
455
- });
456
-
457
- if (!parsedBody.ok) {
458
- return parsedBody;
459
- }
460
-
461
- if (!isEncryptedEnvelope(parsedBody.data)) {
462
- return { ok: false, error: REQUEST_ERRORS.INVALID_ENCRYPTED_PAYLOAD };
463
- }
464
-
465
- return parsedBody;
466
- }
467
-
468
- /**
469
- * Read a header value case-insensitively.
470
- *
471
- * @param {Record<string, any>} headers
472
- * @param {string} name
473
- * @returns {string}
474
- */
475
- function getHeader(headers = {}, name) {
476
- if (!headers || typeof headers !== 'object') {
477
- return '';
478
- }
479
-
480
- const lowerName = String(name || '').toLowerCase();
481
- if (!lowerName) return '';
482
-
483
- for (const [key, value] of Object.entries(headers)) {
484
- if (String(key).toLowerCase() === lowerName) {
485
- return String(value || '').trim();
486
- }
487
- }
488
-
489
- return '';
490
- }
491
-
492
59
  /**
493
60
  * Handler: init-tenant
494
61
  * ReiStandard SDK v2.0.1
@@ -527,1008 +94,99 @@ function createInitTenantHandler(ctx) {
527
94
  };
528
95
  }
529
96
 
530
- const payload = parsed.data;
531
- const tenantId = payload.tenantId || undefined;
532
- const driver = payload.driver;
533
- const databaseUrl = payload.databaseUrl || payload.connectionString;
534
-
535
- if (tenantId && !isValidUUIDv4(tenantId)) {
536
- return {
537
- status: 400,
538
- body: {
539
- success: false,
540
- error: {
541
- code: 'INVALID_TENANT_ID',
542
- message: 'tenantId 必须是 UUID v4 格式'
543
- }
544
- }
545
- };
546
- }
547
-
548
- if (!databaseUrl || !driver) {
549
- return {
550
- status: 400,
551
- body: {
552
- success: false,
553
- error: {
554
- code: 'INVALID_PARAMETERS',
555
- message: '缺少 driver 或 databaseUrl 参数'
556
- }
557
- }
558
- };
559
- }
560
-
561
- try {
562
- const result = await ctx.tenantManager.initializeTenant({
563
- tenantId,
564
- driver,
565
- connectionString: databaseUrl
566
- });
567
-
568
- return {
569
- status: 201,
570
- body: {
571
- success: true,
572
- data: {
573
- tenantId: result.tenantId,
574
- tenantToken: result.tenantToken,
575
- cronToken: result.cronToken,
576
- cronWebhookUrl: result.cronWebhookUrl,
577
- masterKeyFingerprint: result.masterKeyFingerprint
578
- }
579
- }
580
- };
581
- } catch (error) {
582
- if (error.message === 'INVALID_DRIVER') {
583
- return {
584
- status: 400,
585
- body: {
586
- success: false,
587
- error: {
588
- code: 'INVALID_DRIVER',
589
- message: 'driver 必须是 neon 或 pg'
590
- }
591
- }
592
- };
593
- }
594
-
595
- if (error.message === 'INVALID_DATABASE_URL') {
596
- return {
597
- status: 400,
598
- body: {
599
- success: false,
600
- error: {
601
- code: 'INVALID_DATABASE_URL',
602
- message: 'databaseUrl 不能为空'
603
- }
604
- }
605
- };
606
- }
607
-
608
- if (error.message === 'TENANT_ALREADY_INITIALIZED') {
609
- return {
610
- status: 409,
611
- body: {
612
- success: false,
613
- error: {
614
- code: 'TENANT_ALREADY_INITIALIZED',
615
- message: 'tenantId 已存在,不能重复初始化'
616
- }
617
- }
618
- };
619
- }
620
-
621
- throw error;
622
- }
623
- }
624
-
625
- return { POST };
626
- }
627
-
628
- /**
629
- * Encryption utility library (SDK version)
630
- * ReiStandard SDK v2.0.1
631
- *
632
- * Wraps AES-256-GCM operations for request/response and storage encryption.
633
- */
634
-
635
-
636
- /**
637
- * Derive a user-specific encryption key from the master key.
638
- *
639
- * @param {string} userId - Unique user identifier.
640
- * @param {string} masterKey - 64-char hex master key.
641
- * @returns {string} 64-char hex key.
642
- */
643
- function deriveUserEncryptionKey(userId, masterKey) {
644
- return createHash('sha256')
645
- .update(masterKey + userId)
646
- .digest('hex')
647
- .slice(0, 64);
648
- }
649
-
650
- /**
651
- * Decrypt a client-encrypted request body (AES-256-GCM, base64 encoded).
652
- *
653
- * @param {{ iv: string, authTag: string, encryptedData: string }} encryptedPayload
654
- * @param {string} encryptionKey - 64-char hex key.
655
- * @returns {Object} Decrypted JSON object.
656
- */
657
- function decryptPayload(encryptedPayload, encryptionKey) {
658
- const { iv, authTag, encryptedData } = encryptedPayload;
659
-
660
- const decipher = createDecipheriv(
661
- 'aes-256-gcm',
662
- Buffer.from(encryptionKey, 'hex'),
663
- Buffer.from(iv, 'base64')
664
- );
665
-
666
- decipher.setAuthTag(Buffer.from(authTag, 'base64'));
667
-
668
- const decrypted = Buffer.concat([
669
- decipher.update(Buffer.from(encryptedData, 'base64')),
670
- decipher.final()
671
- ]);
672
-
673
- return JSON.parse(decrypted.toString('utf8'));
674
- }
675
-
676
- /**
677
- * Encrypt a JSON payload for API transfer (AES-256-GCM, base64 encoded).
678
- *
679
- * @param {string|Object} payload
680
- * @param {string} encryptionKey - 64-char hex key.
681
- * @returns {{ iv: string, authTag: string, encryptedData: string }}
682
- */
683
- function encryptPayload(payload, encryptionKey) {
684
- const plaintext = typeof payload === 'string' ? payload : JSON.stringify(payload);
685
- const iv = randomBytes(12);
686
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
687
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
688
- const authTag = cipher.getAuthTag();
689
-
690
- return {
691
- iv: iv.toString('base64'),
692
- authTag: authTag.toString('base64'),
693
- encryptedData: encrypted.toString('base64')
694
- };
695
- }
696
-
697
- /**
698
- * Encrypt data for database storage (hex encoded, colon-separated).
699
- *
700
- * @param {string} text - Plaintext string.
701
- * @param {string} encryptionKey - 64-char hex key.
702
- * @returns {string} Format: iv:authTag:encryptedData
703
- */
704
- function encryptForStorage(text, encryptionKey) {
705
- const iv = randomBytes(16);
706
- const cipher = createCipheriv('aes-256-gcm', Buffer.from(encryptionKey, 'hex'), iv);
707
- const encrypted = cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
708
- const authTag = cipher.getAuthTag();
709
- return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
710
- }
711
-
712
- /**
713
- * Decrypt data from database storage format.
714
- *
715
- * @param {string} encryptedText - Format: iv:authTag:encryptedData
716
- * @param {string} encryptionKey - 64-char hex key.
717
- * @returns {string} Plaintext string.
718
- */
719
- function decryptFromStorage(encryptedText, encryptionKey) {
720
- const [ivHex, authTagHex, encryptedDataHex] = encryptedText.split(':');
721
- const decipher = createDecipheriv(
722
- 'aes-256-gcm',
723
- Buffer.from(encryptionKey, 'hex'),
724
- Buffer.from(ivHex, 'hex')
725
- );
726
- decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
727
- return decipher.update(encryptedDataHex, 'hex', 'utf8') + decipher.final('utf8');
728
- }
729
-
730
- /**
731
- * Handler: get-user-key
732
- * ReiStandard SDK v2.0.1
733
- *
734
- * @param {Object} ctx - Server context.
735
- * @returns {{ GET: function }}
736
- */
737
-
738
-
739
- function createGetUserKeyHandler(ctx) {
740
- async function GET(url, headers) {
741
- const effectiveHeaders = headers || url || {};
742
- const tenantResult = await ctx.tenantManager.resolveTenant(effectiveHeaders);
743
- if (!tenantResult.ok) {
744
- return tenantResult.error;
745
- }
746
-
747
- const { masterKey } = tenantResult.context;
748
- const userId = getHeader(effectiveHeaders, 'x-user-id');
749
-
750
- if (!userId) {
751
- return {
752
- status: 400,
753
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } }
754
- };
755
- }
756
-
757
- if (!isValidUUIDv4(userId)) {
758
- return {
759
- status: 400,
760
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
761
- };
762
- }
763
-
764
- return {
765
- status: 200,
766
- body: {
767
- success: true,
768
- data: {
769
- userKey: deriveUserEncryptionKey(userId, masterKey),
770
- version: 1
771
- }
772
- }
773
- };
774
- }
775
-
776
- return { GET };
777
- }
778
-
779
- /**
780
- * Database error helpers.
781
- */
782
-
783
- /**
784
- * Check whether an error is caused by a unique-constraint violation.
785
- *
786
- * @param {unknown} error
787
- * @returns {boolean}
788
- */
789
- function isUniqueViolation(error) {
790
- if (!error || typeof error !== 'object') return false;
791
-
792
- const code = error.code;
793
- if (code === '23505') return true;
794
-
795
- const message = typeof error.message === 'string' ? error.message.toLowerCase() : '';
796
- return message.includes('duplicate key') || message.includes('unique constraint');
797
- }
798
-
799
- /**
800
- * Message Processor (SDK version)
801
- * ReiStandard amsg-server v2.4.0
802
- *
803
- * Handles single message content generation and Web Push delivery for
804
- * scheduled tasks (`fixed` / `prompted` / `auto`) and the
805
- * in-server instant path (`messageType: 'instant'`).
806
- *
807
- * Push wire shape comes from `@rei-standard/amsg-shared`'s
808
- * discriminated union (`AmsgPush`). The SW (`@rei-standard/amsg-sw`)
809
- * routes on `messageKind`. Server-driven pushes always carry
810
- * `source: 'instant'` (for the in-server instant path) or
811
- * `source: 'scheduled'` (for everything else).
812
- *
813
- * v2.4.0: when the LLM response carries non-empty
814
- * `choices[0].message.reasoning_content`, the processor now emits a
815
- * standalone `ReasoningPush` **before** the `ContentPush` burst.
816
- * `messagesSent` in the return value continues to reflect the sentence
817
- * count only (reasoning is an auxiliary push, not a sentence).
818
- */
819
-
820
-
821
- const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
822
-
823
- /**
824
- * Split a single chunk by one regex; on no-match return [chunk] so a later
825
- * regex in a cascade can still take a swing at it.
826
- */
827
- function splitOnceByRegex(chunk, regex) {
828
- const out = chunk
829
- .split(regex)
830
- .reduce((acc, part, i, arr) => {
831
- if (i % 2 === 0 && part.trim()) {
832
- const punctuation = arr[i + 1] || '';
833
- acc.push(part.trim() + punctuation);
834
- }
835
- return acc;
836
- }, [])
837
- .filter(s => s.length > 0);
838
- return out.length > 0 ? out : [chunk];
839
- }
840
-
841
- /**
842
- * Sentence splitter — kept byte-for-byte equivalent to
843
- * `@rei-standard/amsg-instant`'s `splitMessageIntoSentences`. Server carries
844
- * its own copy to avoid an architectural dependency on the instant package.
845
- * Name matches the instant export on purpose so cross-package grep finds
846
- * both copies; if you fix a bug here, fix it in the instant copy too.
847
- *
848
- * @param {string} messageContent
849
- * @param {string | string[] | null} [splitPattern=null]
850
- * @returns {string[]}
851
- */
852
- function splitMessageIntoSentences(messageContent, splitPattern = null) {
853
- const sources =
854
- splitPattern == null ? null :
855
- Array.isArray(splitPattern) ? splitPattern :
856
- [splitPattern];
857
-
858
- const regexes = (sources && sources.length > 0)
859
- ? sources.map(s => new RegExp(s))
860
- : [DEFAULT_SPLIT_REGEX];
861
-
862
- let chunks = [messageContent];
863
- for (const regex of regexes) {
864
- chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
865
- }
866
-
867
- return chunks.length > 0 ? chunks : [messageContent];
868
- }
869
-
870
- /**
871
- * Read `choices[0].message.reasoning_content` as a non-empty trimmed
872
- * string, or null when absent / empty. Mirrors
873
- * `amsg-instant/src/message-processor.js#readReasoningContent`.
874
- *
875
- * @param {unknown} llmResponse
876
- * @returns {string | null}
877
- */
878
- function readReasoningContent(llmResponse) {
879
- if (!llmResponse || typeof llmResponse !== 'object') return null;
880
- const choices = /** @type {{ choices?: unknown }} */ (llmResponse).choices;
881
- if (!Array.isArray(choices) || choices.length === 0) return null;
882
- const message = /** @type {{ message?: { reasoning_content?: unknown, content?: unknown } }} */ (choices[0])?.message;
883
-
884
- const raw = message?.reasoning_content;
885
- if (typeof raw === 'string') {
886
- const trimmed = raw.trim();
887
- if (trimmed.length > 0) return trimmed;
888
- }
889
-
890
- const content = message?.content;
891
- if (typeof content === 'string') {
892
- const match = content.match(REASONING_TAG_RE);
893
- if (match) {
894
- const trimmed = match[2].trim();
895
- if (trimmed.length > 0) return trimmed;
896
- }
897
- }
898
-
899
- return null;
900
- }
901
-
902
- const REASONING_TAG_RE = /<(think|thinking|thought)>([\s\S]*?)<\/\1>/i;
903
- const REASONING_TAG_RE_G = /<(think|thinking|thought)>[\s\S]*?<\/\1>/gi;
904
-
905
- /**
906
- * Mirrors `amsg-instant/src/message-processor.js#stripReasoningTags`.
907
- * Removes any private chain-of-thought markup leaking through
908
- * `message.content` so it does not also ship inside ContentPush.
909
- *
910
- * @param {string} content
911
- * @returns {string}
912
- */
913
- function stripReasoningTags(content) {
914
- if (typeof content !== 'string' || !content.includes('<')) return content;
915
- return content.replace(REASONING_TAG_RE_G, '').trim();
916
- }
917
-
918
- /**
919
- * @typedef {Object} ProcessorContext
920
- * @property {Object} webpush - The web-push module instance (already VAPID-configured).
921
- * @property {Object} vapid - { email, publicKey, privateKey }
922
- * @property {import('../adapters/interface.js').DbAdapter} db
923
- */
924
-
925
- /**
926
- * Process a single database task row: decrypt → generate content → push.
927
- *
928
- * @param {import('../adapters/interface.js').TaskRow} task
929
- * @param {ProcessorContext} ctx
930
- * @param {string} [providedMasterKey]
931
- * @returns {Promise<{ success: boolean, messagesSent: number, error?: string }>}
932
- */
933
- async function processSingleMessage(task, ctx, providedMasterKey) {
934
- try {
935
- const masterKey = providedMasterKey || ctx.masterKey;
936
- if (!masterKey) {
937
- return { success: false, messagesSent: 0, error: 'TENANT_MASTER_KEY_MISSING' };
938
- }
939
-
940
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
941
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
942
-
943
- let messageContent;
944
- /** @type {unknown} */
945
- let llmResponse = null;
946
-
947
- if (decryptedPayload.messageType === 'fixed') {
948
- messageContent = decryptedPayload.userMessage;
949
-
950
- } else if (decryptedPayload.messageType === 'instant') {
951
- const hasPrompt = !!decryptedPayload.completePrompt
952
- || (Array.isArray(decryptedPayload.messages) && decryptedPayload.messages.length > 0);
953
- if (hasPrompt && decryptedPayload.apiUrl && decryptedPayload.apiKey && decryptedPayload.primaryModel) {
954
- const aiResult = await _callAI(decryptedPayload);
955
- messageContent = aiResult.content;
956
- llmResponse = aiResult.response;
957
- } else if (decryptedPayload.userMessage) {
958
- messageContent = decryptedPayload.userMessage;
959
- } else {
960
- throw new Error('Invalid instant message: no content source available');
961
- }
962
-
963
- } else if (decryptedPayload.messageType === 'prompted' || decryptedPayload.messageType === 'auto') {
964
- const aiResult = await _callAI(decryptedPayload);
965
- messageContent = aiResult.content;
966
- llmResponse = aiResult.response;
967
- } else {
968
- throw new Error('Invalid message configuration: no content source available');
969
- }
970
-
971
- // Auto-extract reasoning BEFORE the sentence split: when reasoning
972
- // came from the `<think>` fallback inside message.content, the same
973
- // span is still embedded in messageContent and would otherwise leak
974
- // as raw markup into ContentPush.
975
- const reasoning = readReasoningContent(llmResponse);
976
- if (reasoning) {
977
- messageContent = stripReasoningTags(messageContent);
978
- }
979
-
980
- // Sentence splitting (mirrors @rei-standard/amsg-instant
981
- // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
982
- // override the default regex via decryptedPayload.splitPattern (string
983
- // for a single regex, string[] for a cascade). Validation already enforces
984
- // length cap + RegExp compilability upstream.
985
- const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
986
-
987
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
988
- throw new Error('VAPID configuration missing - push notifications cannot be sent');
989
- }
990
-
991
- const pushSubscription = decryptedPayload.pushSubscription;
992
- // sessionId is shared across the optional ReasoningPush and every
993
- // ContentPush from this LLM round. Pin it to the task id when
994
- // available (scheduled tasks) so retries reuse the same id;
995
- // otherwise mint a UUID.
996
- const sessionId = task.id != null
997
- ? `sess_task_${task.id}`
998
- : `sess_${randomUUID()}`;
999
- const source = decryptedPayload.messageType === 'instant' ? 'instant' : 'scheduled';
1000
- const messageSubtype = decryptedPayload.messageSubtype || 'chat';
1001
- const avatarUrl = decryptedPayload.avatarUrl || null;
1002
- const metadata = decryptedPayload.metadata || {};
1003
-
1004
- // `messageId` format — deterministic when we have a task.id so a
1005
- // retry produces the same id for the same (task, sentence) pair
1006
- // (downstream dedupers can key on it). Falls back to a UUID for
1007
- // the in-server instant path that has no row id.
1008
- const messageIdBase = task.id != null
1009
- ? `msg_task_${task.id}`
1010
- : `msg_${randomUUID()}_instant`;
1011
-
1012
- // ReasoningPush — auto-emitted before the content burst when the
1013
- // LLM response carried non-empty reasoning_content. `fixed` and
1014
- // explicit-userMessage paths produce no LLM response, so this
1015
- // block is naturally skipped for them (llmResponse stays null).
1016
- if (reasoning) {
1017
- const reasoningPush = buildReasoningPush({
1018
- messageType: decryptedPayload.messageType,
1019
- source,
1020
- messageId: `${messageIdBase}_reasoning`,
1021
- sessionId,
1022
- reasoningContent: reasoning,
1023
- timestamp: new Date().toISOString(),
1024
- title: `来自 ${decryptedPayload.contactName}`,
1025
- contactName: decryptedPayload.contactName,
1026
- avatarUrl,
1027
- messageSubtype,
1028
- metadata,
1029
- });
1030
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(reasoningPush));
1031
- await new Promise(resolve => setTimeout(resolve, 1500));
1032
- }
1033
-
1034
- for (let i = 0; i < messages.length; i++) {
1035
- const contentPush = buildContentPush({
1036
- messageType: decryptedPayload.messageType,
1037
- source,
1038
- messageId: `${messageIdBase}_${i}`,
1039
- sessionId,
1040
- message: messages[i],
1041
- timestamp: new Date().toISOString(),
1042
- title: `来自 ${decryptedPayload.contactName}`,
1043
- contactName: decryptedPayload.contactName,
1044
- avatarUrl,
1045
- messageSubtype,
1046
- messageIndex: i + 1,
1047
- totalMessages: messages.length,
1048
- taskId: task.id || null,
1049
- metadata,
1050
- });
1051
-
1052
- await ctx.webpush.sendNotification(pushSubscription, JSON.stringify(contentPush));
1053
-
1054
- if (i < messages.length - 1) {
1055
- await new Promise(resolve => setTimeout(resolve, 1500));
1056
- }
1057
- }
1058
-
1059
- return { success: true, messagesSent: messages.length };
1060
-
1061
- } catch (error) {
1062
- return { success: false, messagesSent: 0, error: error.message };
1063
- }
1064
- }
1065
-
1066
- /**
1067
- * Process a single message identified by UUID (used for instant type).
1068
- *
1069
- * @param {string} uuid
1070
- * @param {ProcessorContext} ctx
1071
- * @param {number} [maxRetries=2]
1072
- * @param {string} [userId]
1073
- * @param {string} [providedMasterKey]
1074
- * @returns {Promise<{ success: boolean, messagesSent?: number, retriesUsed?: number, error?: Object }>}
1075
- */
1076
- async function processMessagesByUuid(uuid, ctx, maxRetries = 2, userId, providedMasterKey) {
1077
- let retryCount = 0;
1078
- const masterKey = providedMasterKey || ctx.masterKey;
1079
-
1080
- if (!masterKey) {
1081
- return {
1082
- success: false,
1083
- error: { code: 'TENANT_MASTER_KEY_MISSING', message: '租户主密钥不存在或配置异常' }
1084
- };
1085
- }
1086
-
1087
- while (retryCount <= maxRetries) {
1088
- let task;
1089
- try {
1090
- task = userId
1091
- ? await ctx.db.getTaskByUuid(uuid, userId)
1092
- : await ctx.db.getTaskByUuidOnly(uuid);
1093
- } catch (error) {
1094
- if (retryCount < maxRetries) {
1095
- retryCount++;
1096
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1097
- continue;
1098
- }
1099
-
1100
- return {
1101
- success: false,
1102
- error: { code: 'INTERNAL_ERROR', message: error.message, retriesAttempted: retryCount }
1103
- };
1104
- }
1105
-
1106
- if (!task) {
1107
- return { success: false, error: { code: 'TASK_NOT_FOUND', message: '任务不存在或已处理' } };
1108
- }
1109
-
1110
- const result = await processSingleMessage(task, ctx, masterKey);
1111
-
1112
- if (!result.success) {
1113
- if (retryCount < maxRetries) {
1114
- retryCount++;
1115
- await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
1116
- continue;
1117
- }
1118
-
1119
- try {
1120
- await ctx.db.updateTaskById(task.id, { status: 'failed', retry_count: retryCount });
1121
- } catch (_updateError) {
1122
- // best-effort status update; keep original processing error as primary signal
1123
- }
1124
-
1125
- return {
1126
- success: false,
1127
- error: { code: 'PROCESSING_ERROR', message: result.error, retriesAttempted: retryCount }
1128
- };
1129
- }
1130
-
1131
- try {
1132
- await ctx.db.deleteTaskById(task.id);
1133
- } catch (error) {
1134
- try {
1135
- await ctx.db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
1136
- } catch (_markSentError) {
1137
- // best effort: avoid re-sending if storage mutation partially fails
1138
- }
1139
-
1140
- return {
1141
- success: false,
1142
- error: {
1143
- code: 'POST_SEND_CLEANUP_FAILED',
1144
- message: '消息已发送,但任务清理失败',
1145
- details: { error: error.message }
1146
- }
1147
- };
1148
- }
1149
-
1150
- return { success: true, messagesSent: result.messagesSent, retriesUsed: retryCount };
1151
- }
1152
- }
1153
-
1154
- /**
1155
- * Call an OpenAI-compatible API.
1156
- *
1157
- * Returns the full response object alongside the extracted (trimmed)
1158
- * `content` string. Callers that only need the text can ignore
1159
- * `response`; callers that want `reasoning_content` / `tool_calls`
1160
- * read from `response.choices[0].message`.
1161
- *
1162
- * @private
1163
- * @param {Object} payload
1164
- * @returns {Promise<{ response: unknown, content: string }>}
1165
- */
1166
- async function _callAI(payload) {
1167
- const normalizedApiUrl = normalizeAiApiUrl(payload.apiUrl);
1168
- const requestBody = buildAiRequestBody(payload);
1169
-
1170
- const aiResponse = await fetch(normalizedApiUrl, {
1171
- method: 'POST',
1172
- headers: {
1173
- 'Content-Type': 'application/json',
1174
- 'Authorization': `Bearer ${payload.apiKey}`
1175
- },
1176
- body: JSON.stringify(requestBody),
1177
- signal: AbortSignal.timeout(300000)
1178
- });
1179
-
1180
- if (!aiResponse.ok) {
1181
- if (aiResponse.status === 405) {
1182
- throw new Error(
1183
- `AI API error: 405 Method Not Allowed. ` +
1184
- `apiUrl must point to a full chat endpoint (for example: /chat/completions). ` +
1185
- `Received: ${normalizedApiUrl}`
1186
- );
1187
- }
1188
-
1189
- throw new Error(
1190
- `AI API error: ${aiResponse.status} ${aiResponse.statusText || 'Unknown Error'}. ` +
1191
- `Request URL: ${normalizedApiUrl}`
1192
- );
1193
- }
1194
-
1195
- const aiData = await aiResponse.json();
1196
- const content = aiData?.choices?.[0]?.message?.content;
1197
- if (typeof content !== 'string' || !content.trim()) {
1198
- throw new Error('AI API error: response missing choices[0].message.content');
1199
- }
1200
-
1201
- return { response: aiData, content: content.trim() };
1202
- }
1203
-
1204
- /**
1205
- * Build OpenAI-compatible request body.
1206
- *
1207
- * `max_tokens` is optional:
1208
- * - include it only when payload.maxTokens is provided
1209
- * - omit it when payload.maxTokens is undefined / null
1210
- *
1211
- * @param {Object} payload
1212
- * @returns {Object}
1213
- */
1214
- function buildAiRequestBody(payload) {
1215
- // messages mode (added in v2.2.0): forward the caller's OpenAI-style array
1216
- // verbatim — same contract as @rei-standard/amsg-instant 0.5.0+. No auto
1217
- // role injection, no concatenation back to a single user message. Lets
1218
- // the upstream app preserve system / multi-turn context byte-for-byte
1219
- // across the schedule-message path.
1220
- const llmMessages = Array.isArray(payload.messages) && payload.messages.length > 0
1221
- ? payload.messages
1222
- : [{ role: 'user', content: payload.completePrompt }];
1223
-
1224
- const requestBody = {
1225
- model: payload.primaryModel,
1226
- messages: llmMessages,
1227
- };
1228
-
1229
- // Match the instant package's behavior: only inject default temperature
1230
- // for the legacy completePrompt path; messages mode forwards whatever the
1231
- // upstream app set (or nothing) so behavior matches their main chat path.
1232
- if (payload.temperature !== undefined && payload.temperature !== null) {
1233
- requestBody.temperature = payload.temperature;
1234
- } else if (!Array.isArray(payload.messages)) {
1235
- requestBody.temperature = 0.8;
1236
- }
1237
-
1238
- if (payload.maxTokens === undefined || payload.maxTokens === null) {
1239
- return requestBody;
1240
- }
1241
-
1242
- if (!Number.isInteger(payload.maxTokens) || payload.maxTokens <= 0) {
1243
- throw new Error('Invalid maxTokens: maxTokens must be a positive integer when provided.');
1244
- }
1245
-
1246
- requestBody.max_tokens = payload.maxTokens;
1247
- return requestBody;
1248
- }
1249
-
1250
- /**
1251
- * Normalize AI API URL for OpenAI-compatible chat endpoints.
1252
- *
1253
- * **Keep in sync** with `@rei-standard/amsg-instant`'s
1254
- * `src/message-processor.js` `normalizeAiApiUrl` — same rules, same
1255
- * tests. The two packages share this logic but each carry their own copy
1256
- * to avoid an architectural dependency (server should not depend on the
1257
- * stateless worker package).
1258
- *
1259
- * @param {string} apiUrl
1260
- * @returns {string}
1261
- */
1262
- function normalizeAiApiUrl(apiUrl) {
1263
- if (typeof apiUrl !== 'string' || !apiUrl.trim()) {
1264
- throw new Error(
1265
- 'Invalid apiUrl: apiUrl is required. ' +
1266
- 'Please provide a chat endpoint URL ' +
1267
- '(for example: https://api.openai.com or https://api.openai.com/v1/chat/completions).'
1268
- );
1269
- }
1270
-
1271
- const trimmedApiUrl = apiUrl.trim();
1272
- let parsedUrl;
1273
-
1274
- try {
1275
- parsedUrl = new URL(trimmedApiUrl);
1276
- } catch {
1277
- throw new Error(
1278
- `Invalid apiUrl: "${apiUrl}". Please provide a valid absolute URL.`
1279
- );
1280
- }
1281
-
1282
- let path = parsedUrl.pathname.replace(/\/+$/, '') || '/';
1283
-
1284
- if (/\/chat\/completions$/.test(path)) ; else if (path === '/') {
1285
- // Bare host → assume OpenAI shape.
1286
- path = '/v1/chat/completions';
1287
- } else if (/\/v\d+$/.test(path)) {
1288
- // Path ends in `/v1`, `/v2`, … — caller already versioned the URL.
1289
- // Append only `/chat/completions`; never re-add `/v1`.
1290
- path = `${path}/chat/completions`;
1291
- }
1292
- // Any other custom path is left untouched on purpose.
1293
-
1294
- parsedUrl.pathname = path;
1295
- return parsedUrl.toString();
1296
- }
1297
-
1298
- /**
1299
- * Handler: schedule-message
1300
- * ReiStandard SDK v2.0.1
1301
- *
1302
- * @param {Object} ctx - Server context.
1303
- * @returns {{ POST: function }}
1304
- */
1305
-
1306
-
1307
- function createScheduleMessageHandler(ctx) {
1308
- async function POST(headers, body) {
1309
- const tenantResult = await ctx.tenantManager.resolveTenant(headers);
1310
- if (!tenantResult.ok) {
1311
- return tenantResult.error;
1312
- }
1313
-
1314
- const tenantCtx = tenantResult.context;
1315
- const db = tenantCtx.db;
1316
- const masterKey = tenantCtx.masterKey;
1317
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
1318
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
1319
- const userId = getHeader(headers, 'x-user-id');
1320
-
1321
- if (!isEncrypted) {
1322
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
1323
- }
1324
- if (!userId) {
1325
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1326
- }
1327
- if (!isValidUUIDv4(userId)) {
1328
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1329
- }
1330
- if (encryptionVersion !== '1') {
1331
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
1332
- }
1333
-
1334
- // Decrypt request body
1335
- const parsedBody = parseEncryptedBody(body);
1336
- if (!parsedBody.ok) {
1337
- return { status: 400, body: { success: false, error: parsedBody.error } };
1338
- }
1339
-
1340
- const encryptedBody = parsedBody.data;
1341
-
1342
- let payload;
1343
- try {
1344
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1345
- payload = decryptPayload(encryptedBody, userKey);
1346
- } catch (error) {
1347
- if (error instanceof SyntaxError) {
1348
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据不是有效 JSON' } } };
1349
- }
1350
-
1351
- const message = typeof error.message === 'string' ? error.message : '';
1352
- if (message.includes('auth') || message.includes('Unsupported state')) {
1353
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1354
- }
1355
-
1356
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1357
- }
97
+ const payload = parsed.data;
98
+ const tenantId = payload.tenantId || undefined;
99
+ const driver = payload.driver;
100
+ const databaseUrl = payload.databaseUrl || payload.connectionString;
1358
101
 
1359
- if (!isPlainObject(payload)) {
1360
- return { status: 400, body: { success: false, error: { code: 'INVALID_PAYLOAD_FORMAT', message: '解密后的数据必须是 JSON 对象' } } };
102
+ if (tenantId && !isValidUUIDv4(tenantId)) {
103
+ return {
104
+ status: 400,
105
+ body: {
106
+ success: false,
107
+ error: {
108
+ code: 'INVALID_TENANT_ID',
109
+ message: 'tenantId 必须是 UUID v4 格式'
110
+ }
111
+ }
112
+ };
1361
113
  }
1362
114
 
1363
- // Validate
1364
- const validationResult = validateScheduleMessagePayload(payload);
1365
- if (!validationResult.valid) {
1366
- return { status: 400, body: { success: false, error: { code: validationResult.errorCode, message: validationResult.errorMessage, details: validationResult.details } } };
115
+ if (!databaseUrl || !driver) {
116
+ return {
117
+ status: 400,
118
+ body: {
119
+ success: false,
120
+ error: {
121
+ code: 'INVALID_PARAMETERS',
122
+ message: '缺少 driver 或 databaseUrl 参数'
123
+ }
124
+ }
125
+ };
1367
126
  }
1368
127
 
1369
- const taskUuid = payload.uuid || randomUUID();
1370
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1371
-
1372
- const fullTaskData = {
1373
- contactName: payload.contactName,
1374
- avatarUrl: payload.avatarUrl || null,
1375
- messageType: payload.messageType,
1376
- messageSubtype: payload.messageSubtype || 'chat',
1377
- userMessage: payload.userMessage || null,
1378
- firstSendTime: payload.firstSendTime,
1379
- recurrenceType: payload.recurrenceType || 'none',
1380
- apiUrl: payload.apiUrl || null,
1381
- apiKey: payload.apiKey || null,
1382
- primaryModel: payload.primaryModel || null,
1383
- // Prompt is one-of: legacy completePrompt (string) OR messages (OpenAI-
1384
- // style array). Validation has already enforced exactly-one-of, so
1385
- // exactly one of these will be non-null when an AI config is provided.
1386
- completePrompt: payload.completePrompt || null,
1387
- messages: Array.isArray(payload.messages) ? payload.messages : null,
1388
- maxTokens: payload.maxTokens ?? null,
1389
- temperature: payload.temperature ?? null,
1390
- // 0.6.0+: optional caller-provided regex (string or string[]) used by
1391
- // the message processor to chunk LLM output into individual pushes.
1392
- // null → processor falls back to the default /([。!?!?]+)/ regex.
1393
- splitPattern: payload.splitPattern ?? null,
1394
- pushSubscription: payload.pushSubscription,
1395
- metadata: payload.metadata || {}
1396
- };
128
+ try {
129
+ const result = await ctx.tenantManager.initializeTenant({
130
+ tenantId,
131
+ driver,
132
+ connectionString: databaseUrl
133
+ });
1397
134
 
1398
- const encryptedPayload = encryptForStorage(JSON.stringify(fullTaskData), userKey);
1399
-
1400
- /**
1401
- * In-server instant path. Delivers an instant message through this
1402
- * server's own task queue (create task → process by UUID → delete task).
1403
- * The task is written to the database before processing, so delivery is
1404
- * not tied to the request connection: even if the client disconnects, the
1405
- * row stays and the generation keeps running (and can be retried) for as
1406
- * long as it needs. Use this when you have a database and want long or
1407
- * guaranteed-complete generations with no dropped messages.
1408
- *
1409
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
1410
- * over SSE with a Web Push backup and needs no database, which makes it a
1411
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
1412
- * response connection, so after the client disconnects it only has the
1413
- * platform's brief grace window to finish (≈20-30s observed on Deno
1414
- * Deploy) — ideal for short instant messages that complete quickly.
1415
- */
1416
- // Instant type: check VAPID before creating the task to avoid orphaned rows
1417
- if (payload.messageType === 'instant') {
1418
- if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
135
+ return {
136
+ status: 201,
137
+ body: {
138
+ success: true,
139
+ data: {
140
+ tenantId: result.tenantId,
141
+ tenantToken: result.tenantToken,
142
+ cronToken: result.cronToken,
143
+ cronWebhookUrl: result.cronWebhookUrl,
144
+ masterKeyFingerprint: result.masterKeyFingerprint
145
+ }
146
+ }
147
+ };
148
+ } catch (error) {
149
+ if (error.message === 'INVALID_DRIVER') {
1419
150
  return {
1420
- status: 500,
151
+ status: 400,
1421
152
  body: {
1422
153
  success: false,
1423
154
  error: {
1424
- code: 'VAPID_CONFIG_ERROR',
1425
- message: 'VAPID 配置缺失,无法发送即时消息',
1426
- details: {
1427
- missingKeys: [
1428
- !ctx.vapid.email && 'VAPID_EMAIL',
1429
- !ctx.vapid.publicKey && 'NEXT_PUBLIC_VAPID_PUBLIC_KEY',
1430
- !ctx.vapid.privateKey && 'VAPID_PRIVATE_KEY'
1431
- ].filter(Boolean)
1432
- }
155
+ code: 'INVALID_DRIVER',
156
+ message: 'driver 必须是 neon 或 pg'
1433
157
  }
1434
158
  }
1435
159
  };
1436
160
  }
1437
- }
1438
161
 
1439
- // Insert into database
1440
- let dbResult;
1441
- try {
1442
- dbResult = await db.createTask({
1443
- user_id: userId,
1444
- uuid: taskUuid,
1445
- encrypted_payload: encryptedPayload,
1446
- next_send_at: payload.firstSendTime,
1447
- message_type: payload.messageType
1448
- });
1449
- } catch (error) {
1450
- if (isUniqueViolation(error)) {
162
+ if (error.message === 'INVALID_DATABASE_URL') {
1451
163
  return {
1452
- status: 409,
164
+ status: 400,
1453
165
  body: {
1454
166
  success: false,
1455
167
  error: {
1456
- code: 'TASK_UUID_CONFLICT',
1457
- message: '任务 UUID 已存在,请使用新的 uuid 重新提交'
168
+ code: 'INVALID_DATABASE_URL',
169
+ message: 'databaseUrl 不能为空'
1458
170
  }
1459
171
  }
1460
172
  };
1461
173
  }
1462
- throw error;
1463
- }
1464
-
1465
- if (!dbResult) {
1466
- return { status: 500, body: { success: false, error: { code: 'TASK_CREATE_FAILED', message: '创建任务失败' } } };
1467
- }
1468
-
1469
- /**
1470
- * In-server instant path. Delivers an instant message through this
1471
- * server's own task queue (create task → process by UUID → delete task).
1472
- * The task is written to the database before processing, so delivery is
1473
- * not tied to the request connection: even if the client disconnects, the
1474
- * row stays and the generation keeps running (and can be retried) for as
1475
- * long as it needs. Use this when you have a database and want long or
1476
- * guaranteed-complete generations with no dropped messages.
1477
- *
1478
- * The stateless alternative is `@rei-standard/amsg-instant`: it streams
1479
- * over SSE with a Web Push backup and needs no database, which makes it a
1480
- * good fit for edge runtimes (e.g. Cloudflare Workers). Its work rides the
1481
- * response connection, so after the client disconnects it only has the
1482
- * platform's brief grace window to finish (≈20-30s observed on Deno
1483
- * Deploy) — ideal for short instant messages that complete quickly.
1484
- */
1485
- // Instant type: send immediately
1486
- if (payload.messageType === 'instant') {
1487
- try {
1488
- const sendResult = await processMessagesByUuid(taskUuid, {
1489
- ...ctx,
1490
- db,
1491
- masterKey
1492
- }, 2, userId, masterKey);
1493
-
1494
- if (!sendResult.success) {
1495
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: sendResult.error } } };
1496
- }
1497
174
 
175
+ if (error.message === 'TENANT_ALREADY_INITIALIZED') {
1498
176
  return {
1499
- status: 200,
177
+ status: 409,
1500
178
  body: {
1501
- success: true,
1502
- data: {
1503
- uuid: taskUuid,
1504
- contactName: payload.contactName,
1505
- messagesSent: sendResult.messagesSent,
1506
- sentAt: new Date().toISOString(),
1507
- status: 'sent',
1508
- retriesUsed: sendResult.retriesUsed || 0
179
+ success: false,
180
+ error: {
181
+ code: 'TENANT_ALREADY_INITIALIZED',
182
+ message: 'tenantId 已存在,不能重复初始化'
1509
183
  }
1510
184
  }
1511
185
  };
1512
- } catch (error) {
1513
- return { status: 500, body: { success: false, error: { code: 'MESSAGE_SEND_FAILED', message: '消息发送失败', details: { error: error.message } } } };
1514
186
  }
1515
- }
1516
187
 
1517
- // Non-instant: return scheduled response
1518
- return {
1519
- status: 201,
1520
- body: {
1521
- success: true,
1522
- data: {
1523
- id: dbResult.id,
1524
- uuid: dbResult.uuid,
1525
- contactName: payload.contactName,
1526
- nextSendAt: dbResult.next_send_at,
1527
- status: dbResult.status,
1528
- createdAt: dbResult.created_at
1529
- }
1530
- }
1531
- };
188
+ throw error;
189
+ }
1532
190
  }
1533
191
 
1534
192
  return { POST };
@@ -1580,476 +238,13 @@ function createSendNotificationsHandler(ctx) {
1580
238
  };
1581
239
  }
1582
240
 
1583
- const startTime = Date.now();
1584
- const tasks = await db.getPendingTasks(50);
1585
-
1586
- const MAX_CONCURRENT = 8;
1587
- const results = {
1588
- totalTasks: tasks.length,
1589
- successCount: 0,
1590
- failedCount: 0,
1591
- deletedOnceOffTasks: 0,
1592
- updatedRecurringTasks: 0,
1593
- failedTasks: []
1594
- };
1595
-
1596
- async function handleDeliveryFailure(task, reason) {
1597
- results.failedCount++;
1598
-
1599
- try {
1600
- if (task.retry_count >= 3) {
1601
- await db.updateTaskById(task.id, { status: 'failed' });
1602
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count, status: 'permanently_failed' });
1603
- } else {
1604
- const nextRetryTime = new Date(Date.now() + (task.retry_count + 1) * 2 * 60 * 1000);
1605
- await db.updateTaskById(task.id, { next_send_at: nextRetryTime.toISOString(), retry_count: task.retry_count + 1 });
1606
- results.failedTasks.push({ taskId: task.id, reason, retryCount: task.retry_count + 1, nextRetryAt: nextRetryTime.toISOString() });
1607
- }
1608
- } catch (updateError) {
1609
- results.failedTasks.push({
1610
- taskId: task.id,
1611
- reason,
1612
- status: 'retry_update_failed',
1613
- updateError: updateError.message
1614
- });
1615
- }
1616
- }
1617
-
1618
- async function handlePostSendPersistenceFailure(task, reason) {
1619
- results.failedCount++;
1620
-
1621
- let markedSent = false;
1622
- try {
1623
- await db.updateTaskById(task.id, { status: 'sent', retry_count: 0 });
1624
- markedSent = true;
1625
- } catch (_markSentError) {
1626
- markedSent = false;
1627
- }
1628
-
1629
- results.failedTasks.push({
1630
- taskId: task.id,
1631
- reason,
1632
- status: markedSent ? 'post_send_cleanup_failed_marked_sent' : 'post_send_cleanup_failed',
1633
- messageDelivered: true
1634
- });
1635
- }
1636
-
1637
- async function processTask(task) {
1638
- let sendResult;
1639
- try {
1640
- sendResult = await processSingleMessage(task, {
1641
- ...ctx,
1642
- db,
1643
- masterKey
1644
- }, masterKey);
1645
- } catch (error) {
1646
- await handleDeliveryFailure(task, error.message || '消息发送失败');
1647
- return;
1648
- }
1649
-
1650
- if (!sendResult.success) {
1651
- await handleDeliveryFailure(task, sendResult.error || '消息发送失败');
1652
- return;
1653
- }
1654
-
1655
- try {
1656
- const userKey = deriveUserEncryptionKey(task.user_id, masterKey);
1657
- const decryptedPayload = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
1658
-
1659
- if (decryptedPayload.recurrenceType === 'none') {
1660
- await db.deleteTaskById(task.id);
1661
- results.deletedOnceOffTasks++;
1662
- } else {
1663
- let nextSendAt;
1664
- const currentSendAt = new Date(task.next_send_at);
1665
- if (decryptedPayload.recurrenceType === 'daily') {
1666
- nextSendAt = new Date(currentSendAt.getTime() + 24 * 60 * 60 * 1000);
1667
- } else if (decryptedPayload.recurrenceType === 'weekly') {
1668
- nextSendAt = new Date(currentSendAt.getTime() + 7 * 24 * 60 * 60 * 1000);
1669
- }
1670
- await db.updateTaskById(task.id, { next_send_at: nextSendAt.toISOString(), retry_count: 0 });
1671
- results.updatedRecurringTasks++;
1672
- }
1673
-
1674
- results.successCount++;
1675
- } catch (error) {
1676
- await handlePostSendPersistenceFailure(task, error.message || '发送后状态更新失败');
1677
- }
1678
- }
1679
-
1680
- // Dynamic task pool
1681
- const taskQueue = [...tasks];
1682
- const processing = [];
1683
-
1684
- while (taskQueue.length > 0 || processing.length > 0) {
1685
- while (processing.length < MAX_CONCURRENT && taskQueue.length > 0) {
1686
- const task = taskQueue.shift();
1687
- const promise = processTask(task);
1688
- processing.push(promise);
1689
- promise.finally(() => {
1690
- const index = processing.indexOf(promise);
1691
- if (index > -1) processing.splice(index, 1);
1692
- });
1693
- }
1694
- if (processing.length > 0) {
1695
- await Promise.race(processing);
1696
- }
1697
- }
1698
-
1699
- // Cleanup old tasks
1700
- await db.cleanupOldTasks(7);
1701
-
1702
- const executionTime = Date.now() - startTime;
1703
-
1704
- return {
1705
- status: 200,
1706
- body: {
1707
- success: true,
1708
- data: {
1709
- totalTasks: results.totalTasks,
1710
- successCount: results.successCount,
1711
- failedCount: results.failedCount,
1712
- processedAt: new Date().toISOString(),
1713
- executionTime,
1714
- details: {
1715
- deletedOnceOffTasks: results.deletedOnceOffTasks,
1716
- updatedRecurringTasks: results.updatedRecurringTasks,
1717
- failedTasks: results.failedTasks
1718
- }
1719
- }
1720
- }
1721
- };
241
+ const data = await runScheduledTick({ ...ctx, db, masterKey });
242
+ return { status: 200, body: { success: true, data } };
1722
243
  }
1723
244
 
1724
245
  return { POST };
1725
246
  }
1726
247
 
1727
- /**
1728
- * Handler: update-message
1729
- * ReiStandard SDK v2.0.1
1730
- *
1731
- * @param {Object} ctx - Server context.
1732
- * @returns {{ PUT: function }}
1733
- */
1734
-
1735
-
1736
- function createUpdateMessageHandler(ctx) {
1737
- async function PUT(url, headers, body) {
1738
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
1739
- if (!tenantResult.ok) {
1740
- return tenantResult.error;
1741
- }
1742
-
1743
- const tenantCtx = tenantResult.context;
1744
- const db = tenantCtx.db;
1745
- const masterKey = tenantCtx.masterKey;
1746
- const u = new URL(url, 'https://dummy');
1747
- const taskUuid = u.searchParams.get('id');
1748
-
1749
- if (!taskUuid) {
1750
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
1751
- }
1752
-
1753
- const userId = getHeader(headers, 'x-user-id');
1754
- if (!userId) {
1755
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1756
- }
1757
- if (!isValidUUIDv4(userId)) {
1758
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1759
- }
1760
-
1761
- const isEncrypted = getHeader(headers, 'x-payload-encrypted') === 'true';
1762
- const encryptionVersion = getHeader(headers, 'x-encryption-version');
1763
-
1764
- if (!isEncrypted) {
1765
- return { status: 400, body: { success: false, error: { code: 'ENCRYPTION_REQUIRED', message: '请求体必须加密' } } };
1766
- }
1767
-
1768
- if (encryptionVersion !== '1') {
1769
- return { status: 400, body: { success: false, error: { code: 'UNSUPPORTED_ENCRYPTION_VERSION', message: '加密版本不支持' } } };
1770
- }
1771
-
1772
- const parsedBody = parseEncryptedBody(body);
1773
- if (!parsedBody.ok) {
1774
- return { status: 400, body: { success: false, error: parsedBody.error } };
1775
- }
1776
-
1777
- const encryptedBody = parsedBody.data;
1778
- const userKey = deriveUserEncryptionKey(userId, masterKey);
1779
- let updates;
1780
-
1781
- try {
1782
- updates = decryptPayload(encryptedBody, userKey);
1783
- } catch (_error) {
1784
- return { status: 400, body: { success: false, error: { code: 'DECRYPTION_FAILED', message: '请求体解密失败' } } };
1785
- }
1786
-
1787
- if (!isPlainObject(updates)) {
1788
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误' } } };
1789
- }
1790
-
1791
- if (updates.nextSendAt && !isValidISO8601(updates.nextSendAt)) {
1792
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['nextSendAt'] } } } };
1793
- }
1794
-
1795
- if (updates.recurrenceType && !['none', 'daily', 'weekly'].includes(updates.recurrenceType)) {
1796
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['recurrenceType'] } } } };
1797
- }
1798
-
1799
- if (
1800
- Object.prototype.hasOwnProperty.call(updates, 'maxTokens') &&
1801
- updates.maxTokens !== null &&
1802
- (!Number.isInteger(updates.maxTokens) || updates.maxTokens <= 0)
1803
- ) {
1804
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['maxTokens'] } } } };
1805
- }
1806
-
1807
- // Reject updates that try to set both completePrompt and messages at
1808
- // once. We don't enforce one-of-required here (callers may patch other
1809
- // fields), but the two prompt sources are mutually exclusive and must
1810
- // stay that way in storage too.
1811
- if (
1812
- updates.completePrompt &&
1813
- updates.messages !== undefined && updates.messages !== null
1814
- ) {
1815
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: 'completePrompt 与 messages 不能同时更新(二选一)', details: { invalidFields: ['completePrompt', 'messages'] } } } };
1816
- }
1817
- if (updates.messages !== undefined && updates.messages !== null) {
1818
- const msgErr = validateLlmMessagesArray(updates.messages);
1819
- if (msgErr) {
1820
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: msgErr, details: { invalidFields: ['messages'] } } } };
1821
- }
1822
- }
1823
- if (
1824
- Object.prototype.hasOwnProperty.call(updates, 'temperature') &&
1825
- updates.temperature !== null &&
1826
- (typeof updates.temperature !== 'number' || !Number.isFinite(updates.temperature))
1827
- ) {
1828
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1829
- }
1830
- if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
1831
- const splitErr = validateSplitPattern(updates.splitPattern);
1832
- if (splitErr) {
1833
- return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
1834
- }
1835
- }
1836
- if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1837
- const avatarErr = validateAvatarUrl(updates.avatarUrl);
1838
- if (avatarErr) {
1839
- // Soft-strip: drop the bad avatarUrl from the patch (keeps the
1840
- // existing stored avatar untouched) and continue applying the rest
1841
- // of the update. See standards §6.2.
1842
- console.warn('[amsg-server] update-message avatarUrl 不合法,已忽略:', avatarErr);
1843
- delete updates.avatarUrl;
1844
- }
1845
- }
1846
-
1847
- // Fetch existing task
1848
- const existingTask = await db.getTaskByUuid(taskUuid, userId);
1849
-
1850
- if (!existingTask) {
1851
- const taskStatus = await db.getTaskStatus(taskUuid, userId);
1852
- if (!taskStatus) {
1853
- return { status: 404, body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } } };
1854
- }
1855
- return { status: 409, body: { success: false, error: { code: 'TASK_ALREADY_COMPLETED', message: '任务已完成或已失败,无法更新' } } };
1856
- }
1857
-
1858
- const existingData = JSON.parse(decryptFromStorage(existingTask.encrypted_payload, userKey));
1859
-
1860
- // When the caller switches prompt source (completePrompt ↔ messages),
1861
- // null out the other so storage stays one-of (matches schedule-message
1862
- // shape and prevents buildAiRequestBody from accidentally seeing both).
1863
- const promptUpdates = {};
1864
- if (updates.completePrompt) {
1865
- promptUpdates.completePrompt = updates.completePrompt;
1866
- promptUpdates.messages = null;
1867
- } else if (updates.messages !== undefined && updates.messages !== null) {
1868
- promptUpdates.messages = updates.messages;
1869
- promptUpdates.completePrompt = null;
1870
- }
1871
-
1872
- const updatedData = {
1873
- ...existingData,
1874
- ...promptUpdates,
1875
- ...(updates.userMessage && { userMessage: updates.userMessage }),
1876
- ...(updates.recurrenceType && { recurrenceType: updates.recurrenceType }),
1877
- ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1878
- ...(updates.metadata && { metadata: updates.metadata }),
1879
- ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1880
- ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
1881
- // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1882
- // default) doesn't get swallowed by truthy-spread the way the optional
1883
- // string fields above are.
1884
- ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
1885
- };
1886
-
1887
- const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
1888
- const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : undefined;
1889
-
1890
- const result = await db.updateTaskByUuid(taskUuid, userId, encryptedPayload, extraFields);
1891
-
1892
- if (!result) {
1893
- return { status: 409, body: { success: false, error: { code: 'UPDATE_CONFLICT', message: '任务更新失败,任务可能已被修改或删除' } } };
1894
- }
1895
-
1896
- return {
1897
- status: 200,
1898
- body: {
1899
- success: true,
1900
- data: {
1901
- uuid: taskUuid,
1902
- updatedFields: Object.keys(updates),
1903
- updatedAt: result.updated_at
1904
- }
1905
- }
1906
- };
1907
- }
1908
-
1909
- return { PUT };
1910
- }
1911
-
1912
- /**
1913
- * Handler: cancel-message
1914
- * ReiStandard SDK v2.0.1
1915
- *
1916
- * @param {Object} ctx - Server context.
1917
- * @returns {{ DELETE: function }}
1918
- */
1919
-
1920
-
1921
- function createCancelMessageHandler(ctx) {
1922
- async function DELETE(url, headers) {
1923
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
1924
- if (!tenantResult.ok) {
1925
- return tenantResult.error;
1926
- }
1927
-
1928
- const db = tenantResult.context.db;
1929
- const u = new URL(url, 'https://dummy');
1930
- const taskUuid = u.searchParams.get('id');
1931
-
1932
- if (!taskUuid) {
1933
- return { status: 400, body: { success: false, error: { code: 'TASK_ID_REQUIRED', message: '缺少任务ID' } } };
1934
- }
1935
-
1936
- const userId = getHeader(headers, 'x-user-id');
1937
- if (!userId) {
1938
- return { status: 400, body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '缺少用户标识符' } } };
1939
- }
1940
- if (!isValidUUIDv4(userId)) {
1941
- return { status: 400, body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } } };
1942
- }
1943
-
1944
- const deleted = await db.deleteTaskByUuid(taskUuid, userId);
1945
-
1946
- if (!deleted) {
1947
- return {
1948
- status: 404,
1949
- body: { success: false, error: { code: 'TASK_NOT_FOUND', message: '指定的任务不存在或已被删除' } }
1950
- };
1951
- }
1952
-
1953
- return {
1954
- status: 200,
1955
- body: {
1956
- success: true,
1957
- data: { uuid: taskUuid, message: '任务已成功取消', deletedAt: new Date().toISOString() }
1958
- }
1959
- };
1960
- }
1961
-
1962
- return { DELETE };
1963
- }
1964
-
1965
- /**
1966
- * Handler: messages
1967
- * ReiStandard SDK v2.0.1
1968
- *
1969
- * @param {Object} ctx - Server context.
1970
- * @returns {{ GET: function }}
1971
- */
1972
-
1973
-
1974
- function createMessagesHandler(ctx) {
1975
- async function GET(url, headers) {
1976
- const tenantResult = await ctx.tenantManager.resolveTenant(headers, { url });
1977
- if (!tenantResult.ok) {
1978
- return tenantResult.error;
1979
- }
1980
-
1981
- const tenantCtx = tenantResult.context;
1982
- const db = tenantCtx.db;
1983
- const masterKey = tenantCtx.masterKey;
1984
- const userId = getHeader(headers, 'x-user-id');
1985
-
1986
- if (!userId) {
1987
- return {
1988
- status: 400,
1989
- body: { success: false, error: { code: 'USER_ID_REQUIRED', message: '必须提供 X-User-Id 请求头' } }
1990
- };
1991
- }
1992
- if (!isValidUUIDv4(userId)) {
1993
- return {
1994
- status: 400,
1995
- body: { success: false, error: { code: 'INVALID_USER_ID_FORMAT', message: 'X-User-Id 必须是 UUID v4 格式' } }
1996
- };
1997
- }
1998
-
1999
- const u = new URL(url, 'https://dummy');
2000
- const status = u.searchParams.get('status') || 'all';
2001
- const limit = Math.min(parseInt(u.searchParams.get('limit') || '20', 10), 100);
2002
- const offset = parseInt(u.searchParams.get('offset') || '0', 10);
2003
-
2004
- if (isNaN(limit) || limit < 1) {
2005
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'limit 参数无效,必须为正整数' } } };
2006
- }
2007
-
2008
- if (isNaN(offset) || offset < 0) {
2009
- return { status: 400, body: { success: false, error: { code: 'INVALID_PARAMETERS', message: 'offset 参数无效,必须为非负整数' } } };
2010
- }
2011
-
2012
- const { tasks, total } = await db.listTasks(userId, { status, limit, offset });
2013
-
2014
- const userKey = deriveUserEncryptionKey(userId, masterKey);
2015
-
2016
- const decryptedTasks = tasks.map(task => {
2017
- const decrypted = JSON.parse(decryptFromStorage(task.encrypted_payload, userKey));
2018
- return {
2019
- id: task.id,
2020
- uuid: task.uuid,
2021
- contactName: decrypted.contactName,
2022
- messageType: task.message_type,
2023
- messageSubtype: decrypted.messageSubtype,
2024
- nextSendAt: task.next_send_at,
2025
- recurrenceType: decrypted.recurrenceType,
2026
- status: task.status,
2027
- retryCount: task.retry_count,
2028
- createdAt: task.created_at,
2029
- updatedAt: task.updated_at
2030
- };
2031
- });
2032
-
2033
- const responsePayload = {
2034
- tasks: decryptedTasks,
2035
- pagination: { total, limit, offset, hasMore: offset + limit < total }
2036
- };
2037
- const encryptedResponse = encryptPayload(responsePayload, userKey);
2038
-
2039
- return {
2040
- status: 200,
2041
- body: {
2042
- success: true,
2043
- encrypted: true,
2044
- version: 1,
2045
- data: encryptedResponse
2046
- }
2047
- };
2048
- }
2049
-
2050
- return { GET };
2051
- }
2052
-
2053
248
  const inMemoryNamespaces = new Map();
2054
249
 
2055
250
  function base64UrlEncode$1(input) {
@@ -2498,12 +693,6 @@ function createTenantContextManager(options) {
2498
693
  */
2499
694
 
2500
695
 
2501
- function normalizeVapidSubject(email) {
2502
- const trimmedEmail = String(email || '').trim();
2503
- if (!trimmedEmail) return '';
2504
- return /^mailto:/i.test(trimmedEmail) ? trimmedEmail : `mailto:${trimmedEmail}`;
2505
- }
2506
-
2507
696
  /**
2508
697
  * @typedef {Object} VapidConfig
2509
698
  * @property {string} [email] - VAPID contact email (e.g. mailto:…).
@@ -2615,4 +804,4 @@ async function createReiServer(config) {
2615
804
  };
2616
805
  }
2617
806
 
2618
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
807
+ export { createAdapter, createReiServer, createTenantToken, isValidUUIDv4, runScheduledTick, verifyTenantToken };