@xmanrui/dsh-im 3.0.0 → 3.0.2

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.
@@ -2,10 +2,11 @@
2
2
  // 平台拒绝 markdown 时逐条回退纯文本。
3
3
 
4
4
  import { t } from '../shared/i18n.mjs';
5
+ import { ApiError } from '@tencent-connect/qqbot-nodejs';
5
6
 
6
7
  const DEFAULT_CHUNK_LIMIT = 4_500;
7
- const CODE_FENCE_OPEN = /^```/;
8
- const GFM_TABLE_LINE = /^\|.+\|$/;
8
+ const CODE_FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/;
9
+ const MARKDOWN_REJECTION_CODES = new Set([40_034_090]);
9
10
  const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
10
11
  const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
11
12
 
@@ -19,32 +20,253 @@ function safeSliceIndex(value, limit) {
19
20
  return Math.max(1, index);
20
21
  }
21
22
 
23
+ function openingFence(line) {
24
+ const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
25
+ const match = CODE_FENCE_OPEN.exec(normalized);
26
+ if (!match) return null;
27
+ // CommonMark forbids backticks in the info string of a backtick fence.
28
+ if (match[2][0] === '`' && match[3].includes('`')) return null;
29
+ return { delimiter: match[2], info: match[3], indent: match[1].length };
30
+ }
31
+
32
+ function closesFence(line, opening) {
33
+ const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
34
+ return Boolean(match
35
+ && match[1][0] === opening.delimiter[0]
36
+ && match[1].length >= opening.delimiter.length);
37
+ }
38
+
39
+ function longestLeadingRun(text, character) {
40
+ let longest = 0;
41
+ for (const line of text.split('\n')) {
42
+ let offset = 0;
43
+ while (offset < 3 && line[offset] === ' ') offset += 1;
44
+ let length = 0;
45
+ while (line[offset + length] === character) length += 1;
46
+ longest = Math.max(longest, length);
47
+ }
48
+ return longest;
49
+ }
50
+
51
+ function safeCodeFence(text, info) {
52
+ const backticks = '`'.repeat(Math.max(3, longestLeadingRun(text, '`') + 1));
53
+ const tildes = '~'.repeat(Math.max(3, longestLeadingRun(text, '~') + 1));
54
+ if (info.includes('`')) return tildes;
55
+ return backticks.length <= tildes.length ? backticks : tildes;
56
+ }
57
+
58
+ function removeFenceIndent(line, indent) {
59
+ let index = 0;
60
+ let column = 0;
61
+ let remaining = indent;
62
+ while (remaining > 0 && index < line.length) {
63
+ if (line[index] === ' ') {
64
+ index += 1;
65
+ column += 1;
66
+ remaining -= 1;
67
+ continue;
68
+ }
69
+ if (line[index] === '\t') {
70
+ const width = 4 - (column % 4);
71
+ if (width <= remaining) {
72
+ index += 1;
73
+ column += width;
74
+ remaining -= width;
75
+ continue;
76
+ }
77
+ return `${' '.repeat(width - remaining)}${line.slice(index + 1)}`;
78
+ }
79
+ break;
80
+ }
81
+ return line.slice(index);
82
+ }
83
+
84
+ function normalizeFencedContent(lines, indent) {
85
+ return indent === 0 ? lines : lines.map((line) => removeFenceIndent(line, indent));
86
+ }
87
+
88
+ function splitRawText(text, limit) {
89
+ if (text.length <= limit) return [text];
90
+ const parts = [];
91
+ let remaining = text;
92
+ while (remaining.length > limit) {
93
+ let index = safeSliceIndex(remaining, limit);
94
+ const newline = remaining.lastIndexOf('\n', index - 1);
95
+ if (newline >= 0) index = newline + 1;
96
+ parts.push(remaining.slice(0, index));
97
+ remaining = remaining.slice(index);
98
+ }
99
+ if (remaining || parts.length === 0) parts.push(remaining);
100
+ return parts;
101
+ }
102
+
103
+ function splitAsFencedCode(text, info, limit) {
104
+ if (limit < 8) return splitRawText(text, limit).map((part) => ({
105
+ markdown: part,
106
+ plain: part,
107
+ }));
108
+ const delimiter = safeCodeFence(text, info);
109
+ let opening = `${delimiter}${info}`;
110
+ if (opening.length + delimiter.length + 2 >= limit) opening = delimiter;
111
+ const payloadLimit = limit - opening.length - delimiter.length - 2;
112
+ if (payloadLimit < 1) return splitRawText(text, limit).map((part) => ({
113
+ markdown: part,
114
+ plain: part,
115
+ }));
116
+ return splitRawText(text, payloadLimit).map((part) => (
117
+ {
118
+ markdown: `${opening}\n${part}${part.endsWith('\n') ? '' : '\n'}${delimiter}`,
119
+ plain: part,
120
+ }
121
+ ));
122
+ }
123
+
124
+ function hasClosingBacktickRun(text, start, length) {
125
+ for (let index = start; index < text.length;) {
126
+ if (text[index] !== '`') {
127
+ index += 1;
128
+ continue;
129
+ }
130
+ let end = index + 1;
131
+ while (text[end] === '`') end += 1;
132
+ if (end - index === length) return true;
133
+ index = end;
134
+ }
135
+ return false;
136
+ }
137
+
138
+ function gfmTableCells(line) {
139
+ const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
140
+ if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
141
+ const text = normalized.trim();
142
+ if (!text) return null;
143
+
144
+ const cells = [];
145
+ let cell = '';
146
+ let separators = 0;
147
+ let codeRun = 0;
148
+ for (let index = 0; index < text.length;) {
149
+ const character = text[index];
150
+ if (character === '\\' && index + 1 < text.length) {
151
+ cell += text.slice(index, index + 2);
152
+ index += 2;
153
+ continue;
154
+ }
155
+ if (character === '`') {
156
+ let end = index + 1;
157
+ while (text[end] === '`') end += 1;
158
+ const length = end - index;
159
+ if (codeRun === length) {
160
+ codeRun = 0;
161
+ } else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
162
+ codeRun = length;
163
+ }
164
+ cell += text.slice(index, end);
165
+ index = end;
166
+ continue;
167
+ }
168
+ if (character === '|' && codeRun === 0) {
169
+ cells.push(cell);
170
+ cell = '';
171
+ separators += 1;
172
+ index += 1;
173
+ continue;
174
+ }
175
+ cell += character;
176
+ index += 1;
177
+ }
178
+ if (separators === 0) return null;
179
+ cells.push(cell);
180
+ if (cells[0].trim() === '') cells.shift();
181
+ if (cells.at(-1)?.trim() === '') cells.pop();
182
+ return cells.length > 0 ? cells : null;
183
+ }
184
+
185
+ function gfmTableStart(lines, index) {
186
+ const headerCells = gfmTableCells(lines[index]);
187
+ const separatorCells = gfmTableCells(lines[index + 1] ?? '');
188
+ if (!headerCells || !separatorCells || headerCells.length !== separatorCells.length) return null;
189
+ if (!separatorCells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return null;
190
+ return { headerCells };
191
+ }
192
+
193
+ function supportedTableBodyCells(line) {
194
+ const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
195
+ const content = normalized.replace(/^ {0,3}/, '');
196
+ if (openingFence(normalized)
197
+ || /^(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/.test(content)) {
198
+ return null;
199
+ }
200
+ return gfmTableCells(normalized);
201
+ }
202
+
203
+ function splitOversizedTable(lines, limit) {
204
+ const source = lines.join('\n');
205
+ if (lines.length < 2 || !gfmTableStart(lines, 0)) {
206
+ return splitAsFencedCode(source, 'text', limit);
207
+ }
208
+ const prefix = `${lines[0]}\n${lines[1]}`;
209
+ const rows = lines.slice(2);
210
+ if (prefix.length > limit
211
+ || rows.some((row) => `${prefix}\n${row}`.length > limit)) {
212
+ return splitAsFencedCode(source, 'text', limit);
213
+ }
214
+ const chunks = [];
215
+ let current = prefix;
216
+ let currentRows = [];
217
+ for (const row of rows) {
218
+ const candidate = `${current}\n${row}`;
219
+ if (candidate.length > limit) {
220
+ chunks.push({
221
+ markdown: current,
222
+ plain: chunks.length === 0 ? current : currentRows.join('\n'),
223
+ });
224
+ current = `${prefix}\n${row}`;
225
+ currentRows = [row];
226
+ } else {
227
+ current = candidate;
228
+ currentRows.push(row);
229
+ }
230
+ }
231
+ chunks.push({
232
+ markdown: current,
233
+ plain: chunks.length === 0 ? current : currentRows.join('\n'),
234
+ });
235
+ return chunks;
236
+ }
237
+
22
238
  /**
23
239
  * 按换行边界切分 Markdown 文本:
24
- * - 不在代码块中间断开;
25
- * - 不在 GFM 表格中间断开;
240
+ * - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
241
+ * - 超长代码块会补齐围栏,超长表格会为每段重复表头;
26
242
  * - 超长行在 limit 处硬切,避免单行超限无法投递。
27
243
  */
28
- export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
244
+ function chunkMarkdownParts(text, limit = DEFAULT_CHUNK_LIMIT) {
29
245
  const value = typeof text === 'string' ? text : '';
30
246
  const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
31
- if (value.length <= bound) return value ? [value] : [];
247
+ if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
32
248
 
33
- const lines = value.split('\n');
249
+ // Once splitting is required, normalize line endings so a chunk never ends
250
+ // with a dangling CR whose paired LF moved to the next QQ message.
251
+ const lines = value.replace(/\r\n?/g, '\n').split('\n');
34
252
  const chunks = [];
35
- let current = '';
36
- let inCodeBlock = false;
37
- let tableBuffer = [];
253
+ let current = null;
254
+
255
+ const flushCurrent = () => {
256
+ if (current === null) return;
257
+ if (current.length > 0) chunks.push({ markdown: current, plain: current });
258
+ current = null;
259
+ };
38
260
 
39
261
  const appendBlock = (block) => {
40
262
  if (block.length <= bound) {
41
- if (!current) {
263
+ if (current === null) {
42
264
  current = block;
43
265
  return;
44
266
  }
45
267
  const candidate = `${current}\n${block}`;
46
268
  if (candidate.length > bound) {
47
- chunks.push(current);
269
+ if (current.length > 0) chunks.push({ markdown: current, plain: current });
48
270
  current = block;
49
271
  } else {
50
272
  current = candidate;
@@ -52,85 +274,112 @@ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
52
274
  return;
53
275
  }
54
276
  // 超大块:收束当前块后按 bound 硬切,保证每块可投递。
55
- if (current) {
56
- chunks.push(current);
57
- current = '';
58
- }
277
+ flushCurrent();
59
278
  let remaining = block;
60
279
  while (remaining.length > bound) {
61
280
  const index = safeSliceIndex(remaining, bound);
62
- chunks.push(remaining.slice(0, index));
281
+ const part = remaining.slice(0, index);
282
+ chunks.push({ markdown: part, plain: part });
63
283
  remaining = remaining.slice(index);
64
284
  }
65
285
  current = remaining;
66
286
  };
67
287
 
68
- const flushTable = () => {
69
- if (tableBuffer.length === 0) return;
70
- const block = tableBuffer.join('\n');
71
- tableBuffer = [];
72
- appendBlock(block);
73
- };
74
-
75
288
  const appendLine = (line) => {
76
289
  let remaining = line;
77
290
  // 超长行先硬切,保证每块不超过 bound。
78
291
  while (remaining.length > bound) {
79
- if (current) {
80
- chunks.push(current);
81
- current = '';
82
- }
292
+ flushCurrent();
83
293
  const index = safeSliceIndex(remaining, bound);
84
- chunks.push(remaining.slice(0, index));
294
+ const part = remaining.slice(0, index);
295
+ chunks.push({ markdown: part, plain: part });
85
296
  remaining = remaining.slice(index);
86
297
  }
87
298
  appendBlock(remaining);
88
299
  };
89
300
 
90
- for (const line of lines) {
91
- if (CODE_FENCE_OPEN.test(line)) {
92
- flushTable();
93
- if (!inCodeBlock && current) {
94
- // 代码块开启:先收束当前块,让整个代码块从新块开始。
95
- chunks.push(current);
96
- current = '';
301
+ for (let index = 0; index < lines.length;) {
302
+ const fence = openingFence(lines[index]);
303
+ if (fence) {
304
+ flushCurrent();
305
+ let end = index + 1;
306
+ while (end < lines.length && !closesFence(lines[end], fence)) end += 1;
307
+ const hasClosingFence = end < lines.length;
308
+ const blockLines = lines.slice(index, hasClosingFence ? end + 1 : lines.length);
309
+ const block = blockLines.join('\n');
310
+ if (block.length <= bound) {
311
+ appendBlock(block);
312
+ } else {
313
+ const contentLines = blockLines.slice(1, hasClosingFence ? -1 : undefined);
314
+ const content = normalizeFencedContent(contentLines, fence.indent).join('\n');
315
+ chunks.push(...splitAsFencedCode(content, fence.info, bound));
97
316
  }
98
- inCodeBlock = !inCodeBlock;
99
- appendLine(line);
100
- continue;
101
- }
102
- if (inCodeBlock) {
103
- appendLine(line);
317
+ index = hasClosingFence ? end + 1 : lines.length;
104
318
  continue;
105
319
  }
106
- if (GFM_TABLE_LINE.test(line)) {
107
- tableBuffer.push(line);
320
+
321
+ if (gfmTableStart(lines, index)) {
322
+ let end = index + 2;
323
+ while (end < lines.length && supportedTableBodyCells(lines[end])) end += 1;
324
+ const tableLines = lines.slice(index, end);
325
+ const table = tableLines.join('\n');
326
+ if (table.length <= bound) {
327
+ appendBlock(table);
328
+ } else {
329
+ flushCurrent();
330
+ chunks.push(...splitOversizedTable(tableLines, bound));
331
+ }
332
+ index = end;
108
333
  continue;
109
334
  }
110
- flushTable();
111
- appendLine(line);
335
+
336
+ appendLine(lines[index]);
337
+ index += 1;
112
338
  }
113
339
 
114
- flushTable();
115
- if (current) chunks.push(current);
116
- return chunks;
340
+ flushCurrent();
341
+ return chunks.filter(({ markdown, plain }) => markdown.length > 0 || plain.length > 0);
342
+ }
343
+
344
+ export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
345
+ return chunkMarkdownParts(text, limit).map(({ markdown }) => markdown);
117
346
  }
118
347
 
119
348
  function nextMsgSeq() {
120
- // 与 SDK getNextMsgSeq 相同的随机策略:被动回复同 msg_id 的多条消息
121
- // 各自带不同 msg_seq,避免平台去重(错误码 40054005)。
349
+ // 与 SDK getNextMsgSeq 相同的 16-bit 随机 seed;同批分片在 seed 上递增,
350
+ // 保证同一个被动回复 msg_id 内不发生随机碰撞。
122
351
  const timePart = Date.now() % 100_000_000;
123
352
  const random = Math.floor(Math.random() * 65_536);
124
353
  return (timePart ^ random) % 65_536;
125
354
  }
126
355
 
356
+ function isMarkdownRejection(error) {
357
+ return error instanceof ApiError
358
+ && error.httpStatus >= 400
359
+ && error.httpStatus < 500
360
+ && MARKDOWN_REJECTION_CODES.has(Number(error.bizCode));
361
+ }
362
+
363
+ function sendPlainText(bot, target, content, msgSeq) {
364
+ if (typeof bot?.send === 'function') {
365
+ return bot.send({
366
+ target,
367
+ msgType: 0,
368
+ content,
369
+ extra: { msg_seq: msgSeq },
370
+ });
371
+ }
372
+ return bot.sendText(target, content);
373
+ }
374
+
127
375
  /**
128
376
  * 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
129
377
  * 返回每条消息的平台响应,供调用方提取 provider message ids。
130
378
  */
131
379
  export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
132
- const chunks = chunkMarkdownText(text);
380
+ const chunks = chunkMarkdownParts(text);
133
381
  const results = [];
382
+ const firstMsgSeq = nextMsgSeq();
134
383
  const passiveLimit = target?.msgId ? PASSIVE_REPLY_LIMIT[target.scope] : null;
135
384
  const overflow = passiveLimit !== null && chunks.length > passiveLimit;
136
385
  const passiveContentCount = overflow ? passiveLimit - 1 : chunks.length;
@@ -143,13 +392,19 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
143
392
  if (partialNoticeSent || !target?.msgId) return;
144
393
  partialNoticeSent = true;
145
394
  try {
146
- results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE()));
395
+ results.push(await sendPlainText(
396
+ bot,
397
+ target,
398
+ PARTIAL_REPLY_NOTICE(),
399
+ (firstMsgSeq + chunks.length) & 0xFFFF,
400
+ ));
147
401
  } catch (error) {
148
402
  logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
149
403
  }
150
404
  };
151
405
 
152
406
  for (const [index, chunk] of chunks.entries()) {
407
+ const msgSeq = (firstMsgSeq + index) & 0xFFFF;
153
408
  const deliveryTarget = overflow && index >= passiveContentCount
154
409
  ? proactiveTarget
155
410
  : target;
@@ -158,16 +413,29 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
158
413
  results.push(await bot.send({
159
414
  target: deliveryTarget,
160
415
  msgType: 2,
161
- markdown: { content: chunk },
162
- extra: { msg_seq: nextMsgSeq() },
416
+ markdown: { content: chunk.markdown },
417
+ extra: { msg_seq: msgSeq },
163
418
  }));
164
419
  continue;
165
420
  } catch (error) {
421
+ if (!isMarkdownRejection(error)) {
422
+ logger?.warn?.(
423
+ '[dsh-im:qq] markdown delivery outcome is uncertain; refusing a duplicate-prone retry:',
424
+ error,
425
+ );
426
+ if (results.length === 0) throw error;
427
+ await sendPartialNotice();
428
+ break;
429
+ }
166
430
  logger?.warn?.('[dsh-im:qq] markdown delivery failed; retrying as plain text:', error);
167
431
  }
168
432
  }
433
+ // Synthetic Markdown can represent an empty fenced-code body even though
434
+ // its plain equivalent is empty. Never turn that into an invalid QQ text
435
+ // message after a definite Markdown rejection (or on legacy text clients).
436
+ if (chunk.plain.length === 0) continue;
169
437
  try {
170
- results.push(await bot.sendText(deliveryTarget, chunk));
438
+ results.push(await sendPlainText(bot, deliveryTarget, chunk.plain, msgSeq));
171
439
  } catch (error) {
172
440
  if (results.length === 0) throw error;
173
441
  await sendPartialNotice();
@@ -776,16 +776,8 @@ export class QqHarnessBridge {
776
776
  const content = hasImages
777
777
  ? await promptContentForMessage(promptMessage, { signal: this.#signal })
778
778
  : undefined;
779
- // QQ C2C keeps one stream bubble. Progress is collected but never submitted:
780
- // some clients reject replacing an already visible stream frame, which would
781
- // otherwise leave a stale progress bubble plus a separate fallback answer.
782
- if (message.kind === 'c2c' && target?.msgId && typeof this.#bot.openStream === 'function') {
783
- try {
784
- stream = this.#bot.openStream({ target });
785
- } catch (error) {
786
- this.#logger.warn?.('[dsh-im:qq] unable to start a QQ stream; using markdown fallback:', error);
787
- }
788
- }
779
+ // QQ stream_messages can acknowledge a final frame without rendering it in
780
+ // some C2C clients. Standard Markdown delivery is the reliable reply path.
789
781
  const toolErrors = [];
790
782
  let answer;
791
783
  let artifacts = [];
@@ -124,6 +124,9 @@ export class QqRuntime {
124
124
  logger: sdkLogger,
125
125
  transport: 'websocket',
126
126
  tokenPrefetch: 'sync',
127
+ // sendText is reserved for literal notices/connection tests. Markdown
128
+ // replies use the explicit msg_type=2 path in sendMarkdownReply().
129
+ markdownSupport: false,
127
130
  });
128
131
  if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
129
132
  throw new TypeError('QQ bot factory returned an invalid client');