@rei-standard/amsg-server 2.2.0 → 2.3.1

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/README.md CHANGED
@@ -76,9 +76,26 @@ AI 配置消息的提示词可以用两种形态之一,**互斥二选一**:
76
76
 
77
77
  可选 `temperature?: number` 透传给 LLM:`completePrompt` 路径未传时默认 0.8(保持旧行为);`messages` 路径未传时**不发**,让上游主路径自己决定。
78
78
 
79
+ ## 自定义分句正则 `splitPattern`(2.3.0+)
80
+
81
+ `processSingleMessage` 默认按 `/([。!?!?]+)/` 把 LLM 返回的整段文本切成多条推送(每条间隔 1.5s)。`splitPattern` 让调用方覆盖这个正则:
82
+
83
+ - `splitPattern: string` —— 单个正则 source(不带 flags)。例:`"([\\n]+)"` 按换行切。
84
+ - `splitPattern: string[]` —— **级联**应用:第一个正则切完,每段再用第二个切。例:`["(\\n\\n+)", "([。!?!?]+)"]` 先按段落、再按句号。
85
+ - 不传 / `null` / `[]` → 走默认正则,行为字节级与 2.2.x 一致;老库存任务(无此字段)零迁移。
86
+
87
+ **约定**:
88
+
89
+ - 传**正则 source**,不要带两边的 `/.../` 也不要带尾部 flag(`/foo/i` 会被当字面量斜杠 + 字面量 `i` 匹配)。需要大小写不敏感请用 `[Aa]` 这种字符类替代。
90
+ - 想让分隔符回贴到前一段(与默认行为一致),把分隔符包进 `(...)` 捕获组。库不会自动包——传 `"\\n+"` 而不是 `"(\\n+)"` 会得到首尾相连、分隔符丢失的奇怪结果。
91
+ - 数组语义是级联,不是"任一匹配就切"。后者请自己用 `|` 合一条正则。
92
+ - 限制:每项 ≤ 200 字符,数组 ≤ 10 项;非法或无法 `new RegExp(...)` 通过 → `400 INVALID_PARAMETERS`(schedule)/ `400 INVALID_UPDATE_DATA`(update)。
93
+ - `update-message` 显式传 `splitPattern: null` 可重置回默认;不传则保留原值。
94
+
79
95
  ## 导出(新增)
80
96
 
81
97
  - `validateLlmMessagesArray(messages)` — 同步预校验 messages 数组,返回 `string | null`(错误信息 / 通过)。和 `@rei-standard/amsg-instant` 的校验规则字节级一致。
98
+ - `validateSplitPattern(value)` — 同步预校验 splitPattern(string / string[] / null),返回 `string | null`。
82
99
 
83
100
  ## 一体化初始化流程
84
101
 
package/dist/index.cjs CHANGED
@@ -48,6 +48,48 @@ function isValidUUIDv4(uuid) {
48
48
  return uuidV4Regex.test(uuid);
49
49
  }
50
50
  var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
51
+ var AVATAR_URL_MAX_LENGTH = 2048;
52
+ function validateAvatarUrl(value) {
53
+ if (value === void 0 || value === null) return null;
54
+ if (typeof value !== "string") {
55
+ return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
56
+ }
57
+ if (/^data:/i.test(value)) {
58
+ return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
59
+ }
60
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
61
+ return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
62
+ }
63
+ if (!isValidUrl(value)) {
64
+ return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
65
+ }
66
+ return null;
67
+ }
68
+ var SPLIT_PATTERN_MAX_LENGTH = 200;
69
+ var SPLIT_PATTERN_MAX_ITEMS = 10;
70
+ function validateSplitPattern(value) {
71
+ if (value === void 0 || value === null) return null;
72
+ const isArray = Array.isArray(value);
73
+ const items = isArray ? value : [value];
74
+ if (isArray && items.length === 0) return null;
75
+ if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
76
+ return `splitPattern \u6570\u7EC4\u6700\u591A ${SPLIT_PATTERN_MAX_ITEMS} \u9879`;
77
+ }
78
+ for (let i = 0; i < items.length; i++) {
79
+ const s = items[i];
80
+ const label = isArray ? `splitPattern[${i}]` : "splitPattern";
81
+ if (typeof s !== "string") return `${label} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`;
82
+ if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
83
+ return `${label} \u4E0D\u80FD\u8D85\u8FC7 ${SPLIT_PATTERN_MAX_LENGTH} \u5B57\u7B26`;
84
+ }
85
+ try {
86
+ new RegExp(s);
87
+ } catch (_) {
88
+ return `${label} \u4E0D\u662F\u6709\u6548\u6B63\u5219\u8868\u8FBE\u5F0F`;
89
+ }
90
+ }
91
+ return null;
92
+ }
51
93
  function validateLlmMessagesArray(messages) {
52
94
  if (!Array.isArray(messages) || messages.length === 0) {
53
95
  return "messages must be a non-empty array";
@@ -146,8 +188,9 @@ function validateScheduleMessagePayload(payload) {
146
188
  if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
147
189
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
148
190
  }
149
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
150
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["avatarUrl (invalid URL format)"] } };
191
+ const avatarErr = validateAvatarUrl(payload.avatarUrl);
192
+ if (avatarErr) {
193
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
151
194
  }
152
195
  if (payload.uuid && !isValidUUID(payload.uuid)) {
153
196
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
@@ -155,6 +198,10 @@ function validateScheduleMessagePayload(payload) {
155
198
  if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
156
199
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
157
200
  }
201
+ const splitErr = validateSplitPattern(payload.splitPattern);
202
+ if (splitErr) {
203
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
204
+ }
158
205
  return { valid: true };
159
206
  }
160
207
 
@@ -435,6 +482,26 @@ function isUniqueViolation(error) {
435
482
 
436
483
  // src/server/lib/message-processor.js
437
484
 
485
+ var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
+ function splitOnceByRegex(chunk, regex) {
487
+ const out = chunk.split(regex).reduce((acc, part, i, arr) => {
488
+ if (i % 2 === 0 && part.trim()) {
489
+ const punctuation = arr[i + 1] || "";
490
+ acc.push(part.trim() + punctuation);
491
+ }
492
+ return acc;
493
+ }, []).filter((s) => s.length > 0);
494
+ return out.length > 0 ? out : [chunk];
495
+ }
496
+ function splitMessageIntoSentences(messageContent, splitPattern = null) {
497
+ const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : [splitPattern];
498
+ const regexes = sources && sources.length > 0 ? sources.map((s) => new RegExp(s)) : [DEFAULT_SPLIT_REGEX];
499
+ let chunks = [messageContent];
500
+ for (const regex of regexes) {
501
+ chunks = chunks.flatMap((c) => splitOnceByRegex(c, regex));
502
+ }
503
+ return chunks.length > 0 ? chunks : [messageContent];
504
+ }
438
505
  async function processSingleMessage(task, ctx, providedMasterKey) {
439
506
  try {
440
507
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -460,14 +527,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
460
527
  } else {
461
528
  throw new Error("Invalid message configuration: no content source available");
462
529
  }
463
- const sentences = messageContent.split(/([。!?!?]+)/).reduce((acc, part, i, arr) => {
464
- if (i % 2 === 0 && part.trim()) {
465
- const punctuation = arr[i + 1] || "";
466
- acc.push(part.trim() + punctuation);
467
- }
468
- return acc;
469
- }, []).filter((s) => s.length > 0);
470
- const messages = sentences.length > 0 ? sentences : [messageContent];
530
+ const messages = splitMessageIntoSentences(messageContent, _nullishCoalesce(decryptedPayload.splitPattern, () => ( null)));
471
531
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
472
532
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
473
533
  }
@@ -583,7 +643,7 @@ async function _callAI(payload) {
583
643
  );
584
644
  }
585
645
  const aiData = await aiResponse.json();
586
- const content = _optionalChain([aiData, 'optionalAccess', _ => _.choices, 'optionalAccess', _2 => _2[0], 'optionalAccess', _3 => _3.message, 'optionalAccess', _4 => _4.content]);
646
+ const content = _optionalChain([aiData, 'optionalAccess', _2 => _2.choices, 'optionalAccess', _3 => _3[0], 'optionalAccess', _4 => _4.message, 'optionalAccess', _5 => _5.content]);
587
647
  if (typeof content !== "string" || !content.trim()) {
588
648
  throw new Error("AI API error: response missing choices[0].message.content");
589
649
  }
@@ -706,6 +766,10 @@ function createScheduleMessageHandler(ctx) {
706
766
  messages: Array.isArray(payload.messages) ? payload.messages : null,
707
767
  maxTokens: _nullishCoalesce(payload.maxTokens, () => ( null)),
708
768
  temperature: _nullishCoalesce(payload.temperature, () => ( null)),
769
+ // 0.6.0+: optional caller-provided regex (string or string[]) used by
770
+ // the message processor to chunk LLM output into individual pushes.
771
+ // null → processor falls back to the default /([。!?!?]+)/ regex.
772
+ splitPattern: _nullishCoalesce(payload.splitPattern, () => ( null)),
709
773
  pushSubscription: payload.pushSubscription,
710
774
  metadata: payload.metadata || {}
711
775
  };
@@ -1029,6 +1093,18 @@ function createUpdateMessageHandler(ctx) {
1029
1093
  if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
1030
1094
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
1031
1095
  }
1096
+ if (Object.prototype.hasOwnProperty.call(updates, "splitPattern")) {
1097
+ const splitErr = validateSplitPattern(updates.splitPattern);
1098
+ if (splitErr) {
1099
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: splitErr, details: { invalidFields: ["splitPattern"] } } } };
1100
+ }
1101
+ }
1102
+ if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
1103
+ const avatarErr = validateAvatarUrl(updates.avatarUrl);
1104
+ if (avatarErr) {
1105
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
1106
+ }
1107
+ }
1032
1108
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
1033
1109
  if (!existingTask) {
1034
1110
  const taskStatus = await db.getTaskStatus(taskUuid, userId);
@@ -1054,7 +1130,11 @@ function createUpdateMessageHandler(ctx) {
1054
1130
  ...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
1055
1131
  ...updates.metadata && { metadata: updates.metadata },
1056
1132
  ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: _nullishCoalesce(updates.maxTokens, () => ( null)) },
1057
- ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: _nullishCoalesce(updates.temperature, () => ( null)) }
1133
+ ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: _nullishCoalesce(updates.temperature, () => ( null)) },
1134
+ // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1135
+ // default) doesn't get swallowed by truthy-spread the way the optional
1136
+ // string fields above are.
1137
+ ...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: _nullishCoalesce(updates.splitPattern, () => ( null)) }
1058
1138
  };
1059
1139
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
1060
1140
  const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
@@ -1246,7 +1326,7 @@ async function resolveBlobStore(namespace) {
1246
1326
  }
1247
1327
  try {
1248
1328
  const blobModule = await Promise.resolve().then(() => _interopRequireWildcard(require("@netlify/blobs")));
1249
- const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _5 => _5.default, 'optionalAccess', _6 => _6.getStore]);
1329
+ const getStore = blobModule.getStore || _optionalChain([blobModule, 'access', _6 => _6.default, 'optionalAccess', _7 => _7.getStore]);
1250
1330
  if (typeof getStore === "function") {
1251
1331
  return getStore({ name: namespace });
1252
1332
  }
@@ -1578,4 +1658,6 @@ async function createReiServer(config) {
1578
1658
 
1579
1659
 
1580
1660
 
1581
- exports.createAdapter = createAdapter; exports.createReiServer = createReiServer; exports.createTenantToken = createTenantToken; exports.decryptFromStorage = decryptFromStorage; exports.decryptPayload = decryptPayload; exports.deriveUserEncryptionKey = deriveUserEncryptionKey; exports.encryptForStorage = encryptForStorage; exports.isValidISO8601 = isValidISO8601; exports.isValidUUID = isValidUUID; exports.isValidUUIDv4 = isValidUUIDv4; exports.isValidUrl = isValidUrl; exports.validateLlmMessagesArray = validateLlmMessagesArray; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.verifyTenantToken = verifyTenantToken;
1661
+
1662
+
1663
+ exports.createAdapter = createAdapter; exports.createReiServer = createReiServer; exports.createTenantToken = createTenantToken; exports.decryptFromStorage = decryptFromStorage; exports.decryptPayload = decryptPayload; exports.deriveUserEncryptionKey = deriveUserEncryptionKey; exports.encryptForStorage = encryptForStorage; exports.isValidISO8601 = isValidISO8601; exports.isValidUUID = isValidUUID; exports.isValidUUIDv4 = isValidUUIDv4; exports.isValidUrl = isValidUrl; exports.validateAvatarUrl = validateAvatarUrl; exports.validateLlmMessagesArray = validateLlmMessagesArray; exports.validateScheduleMessagePayload = validateScheduleMessagePayload; exports.validateSplitPattern = validateSplitPattern; exports.verifyTenantToken = verifyTenantToken;
package/dist/index.d.cts CHANGED
@@ -103,6 +103,78 @@ function isValidUUIDv4(uuid) {
103
103
 
104
104
  const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
105
105
 
106
+ const AVATAR_URL_MAX_LENGTH = 2048;
107
+
108
+ /**
109
+ * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
110
+ * base64-encoded inline images) and anything longer than 2048 chars, both
111
+ * of which are the dominant trigger for downstream 413 / Web Push 4 KB
112
+ * payload errors. Returns an error message string, or null when valid.
113
+ *
114
+ * Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
115
+ * on purpose — both packages forward `avatarUrl` to the same SW push payload).
116
+ *
117
+ * @param {unknown} value
118
+ * @returns {string | null}
119
+ */
120
+ function validateAvatarUrl(value) {
121
+ if (value === undefined || value === null) return null;
122
+ if (typeof value !== 'string') {
123
+ return 'avatarUrl 必须是字符串';
124
+ }
125
+ if (/^data:/i.test(value)) {
126
+ return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
127
+ }
128
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
129
+ return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
130
+ }
131
+ if (!isValidUrl(value)) {
132
+ return 'avatarUrl 不是合法 URL';
133
+ }
134
+ return null;
135
+ }
136
+
137
+ const SPLIT_PATTERN_MAX_LENGTH = 200;
138
+ const SPLIT_PATTERN_MAX_ITEMS = 10;
139
+
140
+ /**
141
+ * Validate the optional `splitPattern` field. Mirrors
142
+ * @rei-standard/amsg-instant's `validateSplitPattern` (kept in lockstep).
143
+ * Accepts `string`, `string[]`, or absent/null. Returns an error message
144
+ * string, or null when valid.
145
+ *
146
+ * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
147
+ * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
148
+ * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
149
+ * backtracking. The real backstop is Worker / runtime CPU limits + the
150
+ * fact that splitPattern is stored under the user's own encrypted task
151
+ * and matched against output from the user's own LLM API key, so the
152
+ * blast radius is self-inflicted only (no cross-tenant attack surface).
153
+ *
154
+ * @param {unknown} value
155
+ * @returns {string | null}
156
+ */
157
+ function validateSplitPattern(value) {
158
+ if (value === undefined || value === null) return null;
159
+ const isArray = Array.isArray(value);
160
+ const items = isArray ? value : [value];
161
+ if (isArray && items.length === 0) return null; // empty array = use default
162
+ if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
163
+ return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
164
+ }
165
+ for (let i = 0; i < items.length; i++) {
166
+ const s = items[i];
167
+ const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
168
+ if (typeof s !== 'string') return `${label} 必须是字符串`;
169
+ if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
170
+ return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
171
+ }
172
+ try { new RegExp(s); }
173
+ catch (_) { return `${label} 不是有效正则表达式`; }
174
+ }
175
+ return null;
176
+ }
177
+
106
178
  /**
107
179
  * Validate an OpenAI-style messages array. Same shape contract as
108
180
  * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
@@ -241,8 +313,9 @@ function validateScheduleMessagePayload(payload) {
241
313
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
242
314
  }
243
315
 
244
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
245
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
316
+ const avatarErr = validateAvatarUrl(payload.avatarUrl);
317
+ if (avatarErr) {
318
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
246
319
  }
247
320
  if (payload.uuid && !isValidUUID(payload.uuid)) {
248
321
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
@@ -258,6 +331,11 @@ function validateScheduleMessagePayload(payload) {
258
331
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
259
332
  }
260
333
 
334
+ const splitErr = validateSplitPattern(payload.splitPattern);
335
+ if (splitErr) {
336
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
337
+ }
338
+
261
339
  return { valid: true };
262
340
  }
263
341
 
@@ -723,6 +801,55 @@ function isUniqueViolation(error) {
723
801
  */
724
802
 
725
803
 
804
+ const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
805
+
806
+ /**
807
+ * Split a single chunk by one regex; on no-match return [chunk] so a later
808
+ * regex in a cascade can still take a swing at it.
809
+ */
810
+ function splitOnceByRegex(chunk, regex) {
811
+ const out = chunk
812
+ .split(regex)
813
+ .reduce((acc, part, i, arr) => {
814
+ if (i % 2 === 0 && part.trim()) {
815
+ const punctuation = arr[i + 1] || '';
816
+ acc.push(part.trim() + punctuation);
817
+ }
818
+ return acc;
819
+ }, [])
820
+ .filter(s => s.length > 0);
821
+ return out.length > 0 ? out : [chunk];
822
+ }
823
+
824
+ /**
825
+ * Sentence splitter — kept byte-for-byte equivalent to
826
+ * `@rei-standard/amsg-instant`'s `splitMessageIntoSentences`. Server carries
827
+ * its own copy to avoid an architectural dependency on the instant package.
828
+ * Name matches the instant export on purpose so cross-package grep finds
829
+ * both copies; if you fix a bug here, fix it in the instant copy too.
830
+ *
831
+ * @param {string} messageContent
832
+ * @param {string | string[] | null} [splitPattern=null]
833
+ * @returns {string[]}
834
+ */
835
+ function splitMessageIntoSentences(messageContent, splitPattern = null) {
836
+ const sources =
837
+ splitPattern == null ? null :
838
+ Array.isArray(splitPattern) ? splitPattern :
839
+ [splitPattern];
840
+
841
+ const regexes = (sources && sources.length > 0)
842
+ ? sources.map(s => new RegExp(s))
843
+ : [DEFAULT_SPLIT_REGEX];
844
+
845
+ let chunks = [messageContent];
846
+ for (const regex of regexes) {
847
+ chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
848
+ }
849
+
850
+ return chunks.length > 0 ? chunks : [messageContent];
851
+ }
852
+
726
853
  /**
727
854
  * @typedef {Object} ProcessorContext
728
855
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -769,19 +896,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
769
896
  throw new Error('Invalid message configuration: no content source available');
770
897
  }
771
898
 
772
- // Sentence splitting
773
- const sentences = messageContent
774
- .split(/([。!?!?]+)/)
775
- .reduce((acc, part, i, arr) => {
776
- if (i % 2 === 0 && part.trim()) {
777
- const punctuation = arr[i + 1] || '';
778
- acc.push(part.trim() + punctuation);
779
- }
780
- return acc;
781
- }, [])
782
- .filter(s => s.length > 0);
783
-
784
- const messages = sentences.length > 0 ? sentences : [messageContent];
899
+ // Sentence splitting (mirrors @rei-standard/amsg-instant
900
+ // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
901
+ // override the default regex via decryptedPayload.splitPattern (string
902
+ // for a single regex, string[] for a cascade). Validation already enforces
903
+ // length cap + RegExp compilability upstream.
904
+ const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
785
905
 
786
906
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
787
907
  throw new Error('VAPID configuration missing - push notifications cannot be sent');
@@ -1150,6 +1270,10 @@ function createScheduleMessageHandler(ctx) {
1150
1270
  messages: Array.isArray(payload.messages) ? payload.messages : null,
1151
1271
  maxTokens: payload.maxTokens ?? null,
1152
1272
  temperature: payload.temperature ?? null,
1273
+ // 0.6.0+: optional caller-provided regex (string or string[]) used by
1274
+ // the message processor to chunk LLM output into individual pushes.
1275
+ // null → processor falls back to the default /([。!?!?]+)/ regex.
1276
+ splitPattern: payload.splitPattern ?? null,
1153
1277
  pushSubscription: payload.pushSubscription,
1154
1278
  metadata: payload.metadata || {}
1155
1279
  };
@@ -1569,6 +1693,18 @@ function createUpdateMessageHandler(ctx) {
1569
1693
  ) {
1570
1694
  return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1571
1695
  }
1696
+ if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
1697
+ const splitErr = validateSplitPattern(updates.splitPattern);
1698
+ if (splitErr) {
1699
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
1700
+ }
1701
+ }
1702
+ if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1703
+ const avatarErr = validateAvatarUrl(updates.avatarUrl);
1704
+ if (avatarErr) {
1705
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } };
1706
+ }
1707
+ }
1572
1708
 
1573
1709
  // Fetch existing task
1574
1710
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
@@ -1603,7 +1739,11 @@ function createUpdateMessageHandler(ctx) {
1603
1739
  ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1604
1740
  ...(updates.metadata && { metadata: updates.metadata }),
1605
1741
  ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1606
- ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null })
1742
+ ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
1743
+ // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1744
+ // default) doesn't get swallowed by truthy-spread the way the optional
1745
+ // string fields above are.
1746
+ ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
1607
1747
  };
1608
1748
 
1609
1749
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
@@ -2337,4 +2477,4 @@ async function createReiServer(config) {
2337
2477
  };
2338
2478
  }
2339
2479
 
2340
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
2480
+ export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
package/dist/index.d.ts CHANGED
@@ -103,6 +103,78 @@ function isValidUUIDv4(uuid) {
103
103
 
104
104
  const VALID_LLM_MESSAGE_ROLES = new Set(['system', 'user', 'assistant', 'tool']);
105
105
 
106
+ const AVATAR_URL_MAX_LENGTH = 2048;
107
+
108
+ /**
109
+ * Validate the optional `avatarUrl` field. Rejects `data:` URIs (typically
110
+ * base64-encoded inline images) and anything longer than 2048 chars, both
111
+ * of which are the dominant trigger for downstream 413 / Web Push 4 KB
112
+ * payload errors. Returns an error message string, or null when valid.
113
+ *
114
+ * Mirrors @rei-standard/amsg-instant's `validateAvatarUrl` (kept in lockstep
115
+ * on purpose — both packages forward `avatarUrl` to the same SW push payload).
116
+ *
117
+ * @param {unknown} value
118
+ * @returns {string | null}
119
+ */
120
+ function validateAvatarUrl(value) {
121
+ if (value === undefined || value === null) return null;
122
+ if (typeof value !== 'string') {
123
+ return 'avatarUrl 必须是字符串';
124
+ }
125
+ if (/^data:/i.test(value)) {
126
+ return '头像不支持传入 data: URI,请改为公网可访问的 https:// 图片 URL';
127
+ }
128
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
129
+ return `头像 URL 长度 ${value.length} 字符超过 ${AVATAR_URL_MAX_LENGTH} 上限,请改为更短的图片 URL`;
130
+ }
131
+ if (!isValidUrl(value)) {
132
+ return 'avatarUrl 不是合法 URL';
133
+ }
134
+ return null;
135
+ }
136
+
137
+ const SPLIT_PATTERN_MAX_LENGTH = 200;
138
+ const SPLIT_PATTERN_MAX_ITEMS = 10;
139
+
140
+ /**
141
+ * Validate the optional `splitPattern` field. Mirrors
142
+ * @rei-standard/amsg-instant's `validateSplitPattern` (kept in lockstep).
143
+ * Accepts `string`, `string[]`, or absent/null. Returns an error message
144
+ * string, or null when valid.
145
+ *
146
+ * Limits (per-item length ≤ 200, array ≤ 10 items, must compile via
147
+ * `new RegExp(item)`) are an **input-size guard**, NOT a ReDoS defense —
148
+ * a 6-character pattern like `(a+)+$` is enough to trigger catastrophic
149
+ * backtracking. The real backstop is Worker / runtime CPU limits + the
150
+ * fact that splitPattern is stored under the user's own encrypted task
151
+ * and matched against output from the user's own LLM API key, so the
152
+ * blast radius is self-inflicted only (no cross-tenant attack surface).
153
+ *
154
+ * @param {unknown} value
155
+ * @returns {string | null}
156
+ */
157
+ function validateSplitPattern(value) {
158
+ if (value === undefined || value === null) return null;
159
+ const isArray = Array.isArray(value);
160
+ const items = isArray ? value : [value];
161
+ if (isArray && items.length === 0) return null; // empty array = use default
162
+ if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
163
+ return `splitPattern 数组最多 ${SPLIT_PATTERN_MAX_ITEMS} 项`;
164
+ }
165
+ for (let i = 0; i < items.length; i++) {
166
+ const s = items[i];
167
+ const label = isArray ? `splitPattern[${i}]` : 'splitPattern';
168
+ if (typeof s !== 'string') return `${label} 必须是字符串`;
169
+ if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
170
+ return `${label} 不能超过 ${SPLIT_PATTERN_MAX_LENGTH} 字符`;
171
+ }
172
+ try { new RegExp(s); }
173
+ catch (_) { return `${label} 不是有效正则表达式`; }
174
+ }
175
+ return null;
176
+ }
177
+
106
178
  /**
107
179
  * Validate an OpenAI-style messages array. Same shape contract as
108
180
  * `@rei-standard/amsg-instant` (kept in lockstep on purpose — both packages
@@ -241,8 +313,9 @@ function validateScheduleMessagePayload(payload) {
241
313
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['temperature (must be a finite number)'] } };
242
314
  }
243
315
 
244
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
245
- return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['avatarUrl (invalid URL format)'] } };
316
+ const avatarErr = validateAvatarUrl(payload.avatarUrl);
317
+ if (avatarErr) {
318
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: avatarErr, details: { invalidFields: ['avatarUrl'] } };
246
319
  }
247
320
  if (payload.uuid && !isValidUUID(payload.uuid)) {
248
321
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['uuid (invalid UUID format)'] } };
@@ -258,6 +331,11 @@ function validateScheduleMessagePayload(payload) {
258
331
  return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: '缺少必需参数或参数格式错误', details: { invalidFields: ['messageSubtype'] } };
259
332
  }
260
333
 
334
+ const splitErr = validateSplitPattern(payload.splitPattern);
335
+ if (splitErr) {
336
+ return { valid: false, errorCode: 'INVALID_PARAMETERS', errorMessage: splitErr, details: { invalidFields: ['splitPattern'] } };
337
+ }
338
+
261
339
  return { valid: true };
262
340
  }
263
341
 
@@ -723,6 +801,55 @@ function isUniqueViolation(error) {
723
801
  */
724
802
 
725
803
 
804
+ const DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
805
+
806
+ /**
807
+ * Split a single chunk by one regex; on no-match return [chunk] so a later
808
+ * regex in a cascade can still take a swing at it.
809
+ */
810
+ function splitOnceByRegex(chunk, regex) {
811
+ const out = chunk
812
+ .split(regex)
813
+ .reduce((acc, part, i, arr) => {
814
+ if (i % 2 === 0 && part.trim()) {
815
+ const punctuation = arr[i + 1] || '';
816
+ acc.push(part.trim() + punctuation);
817
+ }
818
+ return acc;
819
+ }, [])
820
+ .filter(s => s.length > 0);
821
+ return out.length > 0 ? out : [chunk];
822
+ }
823
+
824
+ /**
825
+ * Sentence splitter — kept byte-for-byte equivalent to
826
+ * `@rei-standard/amsg-instant`'s `splitMessageIntoSentences`. Server carries
827
+ * its own copy to avoid an architectural dependency on the instant package.
828
+ * Name matches the instant export on purpose so cross-package grep finds
829
+ * both copies; if you fix a bug here, fix it in the instant copy too.
830
+ *
831
+ * @param {string} messageContent
832
+ * @param {string | string[] | null} [splitPattern=null]
833
+ * @returns {string[]}
834
+ */
835
+ function splitMessageIntoSentences(messageContent, splitPattern = null) {
836
+ const sources =
837
+ splitPattern == null ? null :
838
+ Array.isArray(splitPattern) ? splitPattern :
839
+ [splitPattern];
840
+
841
+ const regexes = (sources && sources.length > 0)
842
+ ? sources.map(s => new RegExp(s))
843
+ : [DEFAULT_SPLIT_REGEX];
844
+
845
+ let chunks = [messageContent];
846
+ for (const regex of regexes) {
847
+ chunks = chunks.flatMap(c => splitOnceByRegex(c, regex));
848
+ }
849
+
850
+ return chunks.length > 0 ? chunks : [messageContent];
851
+ }
852
+
726
853
  /**
727
854
  * @typedef {Object} ProcessorContext
728
855
  * @property {Object} webpush - The web-push module instance (already VAPID-configured).
@@ -769,19 +896,12 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
769
896
  throw new Error('Invalid message configuration: no content source available');
770
897
  }
771
898
 
772
- // Sentence splitting
773
- const sentences = messageContent
774
- .split(/([。!?!?]+)/)
775
- .reduce((acc, part, i, arr) => {
776
- if (i % 2 === 0 && part.trim()) {
777
- const punctuation = arr[i + 1] || '';
778
- acc.push(part.trim() + punctuation);
779
- }
780
- return acc;
781
- }, [])
782
- .filter(s => s.length > 0);
783
-
784
- const messages = sentences.length > 0 ? sentences : [messageContent];
899
+ // Sentence splitting (mirrors @rei-standard/amsg-instant
900
+ // splitMessageIntoSentences — keep in lockstep; do not drift). Caller may
901
+ // override the default regex via decryptedPayload.splitPattern (string
902
+ // for a single regex, string[] for a cascade). Validation already enforces
903
+ // length cap + RegExp compilability upstream.
904
+ const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
785
905
 
786
906
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
787
907
  throw new Error('VAPID configuration missing - push notifications cannot be sent');
@@ -1150,6 +1270,10 @@ function createScheduleMessageHandler(ctx) {
1150
1270
  messages: Array.isArray(payload.messages) ? payload.messages : null,
1151
1271
  maxTokens: payload.maxTokens ?? null,
1152
1272
  temperature: payload.temperature ?? null,
1273
+ // 0.6.0+: optional caller-provided regex (string or string[]) used by
1274
+ // the message processor to chunk LLM output into individual pushes.
1275
+ // null → processor falls back to the default /([。!?!?]+)/ regex.
1276
+ splitPattern: payload.splitPattern ?? null,
1153
1277
  pushSubscription: payload.pushSubscription,
1154
1278
  metadata: payload.metadata || {}
1155
1279
  };
@@ -1569,6 +1693,18 @@ function createUpdateMessageHandler(ctx) {
1569
1693
  ) {
1570
1694
  return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: '更新数据格式错误', details: { invalidFields: ['temperature'] } } } };
1571
1695
  }
1696
+ if (Object.prototype.hasOwnProperty.call(updates, 'splitPattern')) {
1697
+ const splitErr = validateSplitPattern(updates.splitPattern);
1698
+ if (splitErr) {
1699
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: splitErr, details: { invalidFields: ['splitPattern'] } } } };
1700
+ }
1701
+ }
1702
+ if (Object.prototype.hasOwnProperty.call(updates, 'avatarUrl')) {
1703
+ const avatarErr = validateAvatarUrl(updates.avatarUrl);
1704
+ if (avatarErr) {
1705
+ return { status: 400, body: { success: false, error: { code: 'INVALID_UPDATE_DATA', message: avatarErr, details: { invalidFields: ['avatarUrl'] } } } };
1706
+ }
1707
+ }
1572
1708
 
1573
1709
  // Fetch existing task
1574
1710
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
@@ -1603,7 +1739,11 @@ function createUpdateMessageHandler(ctx) {
1603
1739
  ...(updates.avatarUrl && { avatarUrl: updates.avatarUrl }),
1604
1740
  ...(updates.metadata && { metadata: updates.metadata }),
1605
1741
  ...(Object.prototype.hasOwnProperty.call(updates, 'maxTokens') && { maxTokens: updates.maxTokens ?? null }),
1606
- ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null })
1742
+ ...(Object.prototype.hasOwnProperty.call(updates, 'temperature') && { temperature: updates.temperature ?? null }),
1743
+ // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1744
+ // default) doesn't get swallowed by truthy-spread the way the optional
1745
+ // string fields above are.
1746
+ ...(Object.prototype.hasOwnProperty.call(updates, 'splitPattern') && { splitPattern: updates.splitPattern ?? null })
1607
1747
  };
1608
1748
 
1609
1749
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
@@ -2337,4 +2477,4 @@ async function createReiServer(config) {
2337
2477
  };
2338
2478
  }
2339
2479
 
2340
- export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateLlmMessagesArray, validateScheduleMessagePayload, verifyTenantToken };
2480
+ export { createAdapter, createReiServer, createTenantToken, decryptFromStorage, decryptPayload, deriveUserEncryptionKey, encryptForStorage, isValidISO8601, isValidUUID, isValidUUIDv4, isValidUrl, validateAvatarUrl, validateLlmMessagesArray, validateScheduleMessagePayload, validateSplitPattern, verifyTenantToken };
package/dist/index.mjs CHANGED
@@ -48,6 +48,48 @@ function isValidUUIDv4(uuid) {
48
48
  return uuidV4Regex.test(uuid);
49
49
  }
50
50
  var VALID_LLM_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
51
+ var AVATAR_URL_MAX_LENGTH = 2048;
52
+ function validateAvatarUrl(value) {
53
+ if (value === void 0 || value === null) return null;
54
+ if (typeof value !== "string") {
55
+ return "avatarUrl \u5FC5\u987B\u662F\u5B57\u7B26\u4E32";
56
+ }
57
+ if (/^data:/i.test(value)) {
58
+ return "\u5934\u50CF\u4E0D\u652F\u6301\u4F20\u5165 data: URI\uFF0C\u8BF7\u6539\u4E3A\u516C\u7F51\u53EF\u8BBF\u95EE\u7684 https:// \u56FE\u7247 URL";
59
+ }
60
+ if (value.length > AVATAR_URL_MAX_LENGTH) {
61
+ return `\u5934\u50CF URL \u957F\u5EA6 ${value.length} \u5B57\u7B26\u8D85\u8FC7 ${AVATAR_URL_MAX_LENGTH} \u4E0A\u9650\uFF0C\u8BF7\u6539\u4E3A\u66F4\u77ED\u7684\u56FE\u7247 URL`;
62
+ }
63
+ if (!isValidUrl(value)) {
64
+ return "avatarUrl \u4E0D\u662F\u5408\u6CD5 URL";
65
+ }
66
+ return null;
67
+ }
68
+ var SPLIT_PATTERN_MAX_LENGTH = 200;
69
+ var SPLIT_PATTERN_MAX_ITEMS = 10;
70
+ function validateSplitPattern(value) {
71
+ if (value === void 0 || value === null) return null;
72
+ const isArray = Array.isArray(value);
73
+ const items = isArray ? value : [value];
74
+ if (isArray && items.length === 0) return null;
75
+ if (items.length > SPLIT_PATTERN_MAX_ITEMS) {
76
+ return `splitPattern \u6570\u7EC4\u6700\u591A ${SPLIT_PATTERN_MAX_ITEMS} \u9879`;
77
+ }
78
+ for (let i = 0; i < items.length; i++) {
79
+ const s = items[i];
80
+ const label = isArray ? `splitPattern[${i}]` : "splitPattern";
81
+ if (typeof s !== "string") return `${label} \u5FC5\u987B\u662F\u5B57\u7B26\u4E32`;
82
+ if (s.length > SPLIT_PATTERN_MAX_LENGTH) {
83
+ return `${label} \u4E0D\u80FD\u8D85\u8FC7 ${SPLIT_PATTERN_MAX_LENGTH} \u5B57\u7B26`;
84
+ }
85
+ try {
86
+ new RegExp(s);
87
+ } catch (_) {
88
+ return `${label} \u4E0D\u662F\u6709\u6548\u6B63\u5219\u8868\u8FBE\u5F0F`;
89
+ }
90
+ }
91
+ return null;
92
+ }
51
93
  function validateLlmMessagesArray(messages) {
52
94
  if (!Array.isArray(messages) || messages.length === 0) {
53
95
  return "messages must be a non-empty array";
@@ -146,8 +188,9 @@ function validateScheduleMessagePayload(payload) {
146
188
  if (payload.temperature !== void 0 && payload.temperature !== null && (typeof payload.temperature !== "number" || !Number.isFinite(payload.temperature))) {
147
189
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature (must be a finite number)"] } };
148
190
  }
149
- if (payload.avatarUrl && !isValidUrl(payload.avatarUrl)) {
150
- return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["avatarUrl (invalid URL format)"] } };
191
+ const avatarErr = validateAvatarUrl(payload.avatarUrl);
192
+ if (avatarErr) {
193
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: avatarErr, details: { invalidFields: ["avatarUrl"] } };
151
194
  }
152
195
  if (payload.uuid && !isValidUUID(payload.uuid)) {
153
196
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["uuid (invalid UUID format)"] } };
@@ -155,6 +198,10 @@ function validateScheduleMessagePayload(payload) {
155
198
  if (payload.messageSubtype !== void 0 && payload.messageSubtype !== null && typeof payload.messageSubtype !== "string") {
156
199
  return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: "\u7F3A\u5C11\u5FC5\u9700\u53C2\u6570\u6216\u53C2\u6570\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["messageSubtype"] } };
157
200
  }
201
+ const splitErr = validateSplitPattern(payload.splitPattern);
202
+ if (splitErr) {
203
+ return { valid: false, errorCode: "INVALID_PARAMETERS", errorMessage: splitErr, details: { invalidFields: ["splitPattern"] } };
204
+ }
158
205
  return { valid: true };
159
206
  }
160
207
 
@@ -435,6 +482,26 @@ function isUniqueViolation(error) {
435
482
 
436
483
  // src/server/lib/message-processor.js
437
484
  import { randomUUID } from "crypto";
485
+ var DEFAULT_SPLIT_REGEX = /([。!?!?]+)/;
486
+ function splitOnceByRegex(chunk, regex) {
487
+ const out = chunk.split(regex).reduce((acc, part, i, arr) => {
488
+ if (i % 2 === 0 && part.trim()) {
489
+ const punctuation = arr[i + 1] || "";
490
+ acc.push(part.trim() + punctuation);
491
+ }
492
+ return acc;
493
+ }, []).filter((s) => s.length > 0);
494
+ return out.length > 0 ? out : [chunk];
495
+ }
496
+ function splitMessageIntoSentences(messageContent, splitPattern = null) {
497
+ const sources = splitPattern == null ? null : Array.isArray(splitPattern) ? splitPattern : [splitPattern];
498
+ const regexes = sources && sources.length > 0 ? sources.map((s) => new RegExp(s)) : [DEFAULT_SPLIT_REGEX];
499
+ let chunks = [messageContent];
500
+ for (const regex of regexes) {
501
+ chunks = chunks.flatMap((c) => splitOnceByRegex(c, regex));
502
+ }
503
+ return chunks.length > 0 ? chunks : [messageContent];
504
+ }
438
505
  async function processSingleMessage(task, ctx, providedMasterKey) {
439
506
  try {
440
507
  const masterKey = providedMasterKey || ctx.masterKey;
@@ -460,14 +527,7 @@ async function processSingleMessage(task, ctx, providedMasterKey) {
460
527
  } else {
461
528
  throw new Error("Invalid message configuration: no content source available");
462
529
  }
463
- const sentences = messageContent.split(/([。!?!?]+)/).reduce((acc, part, i, arr) => {
464
- if (i % 2 === 0 && part.trim()) {
465
- const punctuation = arr[i + 1] || "";
466
- acc.push(part.trim() + punctuation);
467
- }
468
- return acc;
469
- }, []).filter((s) => s.length > 0);
470
- const messages = sentences.length > 0 ? sentences : [messageContent];
530
+ const messages = splitMessageIntoSentences(messageContent, decryptedPayload.splitPattern ?? null);
471
531
  if (!ctx.vapid.email || !ctx.vapid.publicKey || !ctx.vapid.privateKey) {
472
532
  throw new Error("VAPID configuration missing - push notifications cannot be sent");
473
533
  }
@@ -706,6 +766,10 @@ function createScheduleMessageHandler(ctx) {
706
766
  messages: Array.isArray(payload.messages) ? payload.messages : null,
707
767
  maxTokens: payload.maxTokens ?? null,
708
768
  temperature: payload.temperature ?? null,
769
+ // 0.6.0+: optional caller-provided regex (string or string[]) used by
770
+ // the message processor to chunk LLM output into individual pushes.
771
+ // null → processor falls back to the default /([。!?!?]+)/ regex.
772
+ splitPattern: payload.splitPattern ?? null,
709
773
  pushSubscription: payload.pushSubscription,
710
774
  metadata: payload.metadata || {}
711
775
  };
@@ -1029,6 +1093,18 @@ function createUpdateMessageHandler(ctx) {
1029
1093
  if (Object.prototype.hasOwnProperty.call(updates, "temperature") && updates.temperature !== null && (typeof updates.temperature !== "number" || !Number.isFinite(updates.temperature))) {
1030
1094
  return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: "\u66F4\u65B0\u6570\u636E\u683C\u5F0F\u9519\u8BEF", details: { invalidFields: ["temperature"] } } } };
1031
1095
  }
1096
+ if (Object.prototype.hasOwnProperty.call(updates, "splitPattern")) {
1097
+ const splitErr = validateSplitPattern(updates.splitPattern);
1098
+ if (splitErr) {
1099
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: splitErr, details: { invalidFields: ["splitPattern"] } } } };
1100
+ }
1101
+ }
1102
+ if (Object.prototype.hasOwnProperty.call(updates, "avatarUrl")) {
1103
+ const avatarErr = validateAvatarUrl(updates.avatarUrl);
1104
+ if (avatarErr) {
1105
+ return { status: 400, body: { success: false, error: { code: "INVALID_UPDATE_DATA", message: avatarErr, details: { invalidFields: ["avatarUrl"] } } } };
1106
+ }
1107
+ }
1032
1108
  const existingTask = await db.getTaskByUuid(taskUuid, userId);
1033
1109
  if (!existingTask) {
1034
1110
  const taskStatus = await db.getTaskStatus(taskUuid, userId);
@@ -1054,7 +1130,11 @@ function createUpdateMessageHandler(ctx) {
1054
1130
  ...updates.avatarUrl && { avatarUrl: updates.avatarUrl },
1055
1131
  ...updates.metadata && { metadata: updates.metadata },
1056
1132
  ...Object.prototype.hasOwnProperty.call(updates, "maxTokens") && { maxTokens: updates.maxTokens ?? null },
1057
- ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null }
1133
+ ...Object.prototype.hasOwnProperty.call(updates, "temperature") && { temperature: updates.temperature ?? null },
1134
+ // splitPattern: hasOwnProperty so that explicit `null` (= revert to
1135
+ // default) doesn't get swallowed by truthy-spread the way the optional
1136
+ // string fields above are.
1137
+ ...Object.prototype.hasOwnProperty.call(updates, "splitPattern") && { splitPattern: updates.splitPattern ?? null }
1058
1138
  };
1059
1139
  const encryptedPayload = encryptForStorage(JSON.stringify(updatedData), userKey);
1060
1140
  const extraFields = updates.nextSendAt ? { next_send_at: updates.nextSendAt } : void 0;
@@ -1575,7 +1655,9 @@ export {
1575
1655
  isValidUUID,
1576
1656
  isValidUUIDv4,
1577
1657
  isValidUrl,
1658
+ validateAvatarUrl,
1578
1659
  validateLlmMessagesArray,
1579
1660
  validateScheduleMessagePayload,
1661
+ validateSplitPattern,
1580
1662
  verifyTenantToken
1581
1663
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rei-standard/amsg-server",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "ReiStandard Active Messaging server SDK with pluggable database adapters",
5
5
  "repository": {
6
6
  "type": "git",