@xmanrui/dsh-im 2.6.0 → 3.0.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.en.md +4 -4
- package/README.md +3 -3
- package/bin/dsh-im.mjs +1 -1
- package/lib/client.js +1440 -1432
- package/lib/index.js +187 -172
- package/package.json +2 -2
- package/plugin-src/client/channels/dingtalk/index.js +0 -17
- package/plugin-src/client/channels/dingtalk/styles.js +2 -2
- package/plugin-src/client/channels/feishu/index.js +0 -29
- package/plugin-src/client/channels/feishu/styles.js +2 -2
- package/plugin-src/client/channels/qq/index.js +0 -9
- package/plugin-src/client/channels/telegram/styles.js +12 -10
- package/plugin-src/client/channels/wecom/index.js +0 -9
- package/plugin-src/client/channels/weixin/index.js +0 -19
- package/plugin-src/client/channels/weixin/styles.js +2 -2
- package/plugin-src/client/i18n.js +4 -1
- package/plugin-src/client/index.js +4 -4
- package/plugin-src/client/styles.js +15 -12
- package/scripts/verify-package.mjs +48 -11
- package/src/channels/feishu/bridge.mjs +5 -2
- package/src/channels/feishu/message-utils.mjs +1 -1
- package/src/channels/qq/markdown-reply.mjs +324 -56
- package/src/channels/qq/qq-bridge.mjs +2 -10
- package/src/channels/qq/qq-runtime.mjs +3 -0
- package/src/channels/shared/connection-test.mjs +1 -1
- package/src/channels/shared/i18n-en/feishu.mjs +6 -6
- package/src/channels/shared/i18n-en/shared-c.mjs +2 -2
|
@@ -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
|
|
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
|
-
* -
|
|
240
|
+
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
|
241
|
+
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
|
26
242
|
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
27
243
|
*/
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
chunks.push(current);
|
|
81
|
-
current = '';
|
|
82
|
-
}
|
|
292
|
+
flushCurrent();
|
|
83
293
|
const index = safeSliceIndex(remaining, bound);
|
|
84
|
-
|
|
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 (
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
appendLine(
|
|
335
|
+
|
|
336
|
+
appendLine(lines[index]);
|
|
337
|
+
index += 1;
|
|
112
338
|
}
|
|
113
339
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
121
|
-
//
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
780
|
-
// some clients
|
|
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');
|
|
@@ -29,7 +29,7 @@ export function connectionTestTarget(state) {
|
|
|
29
29
|
|
|
30
30
|
export function connectionTestMessage(botName, channelLabel = t('机器人')) {
|
|
31
31
|
const name = cleanText(botName) ?? channelLabel;
|
|
32
|
-
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('
|
|
32
|
+
return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。', { name })}`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export function connectionTestTargetUnavailable(channelLabel = t('机器人')) {
|
|
@@ -18,8 +18,8 @@ export default {
|
|
|
18
18
|
'机器人正在移除或已重新接入,无法操作原会话的工作区。':
|
|
19
19
|
'The bot is being removed or has been reconnected; the original session’s workspace cannot be changed.',
|
|
20
20
|
'操作失败,请稍后重试。': 'The operation failed. Please try again later.',
|
|
21
|
-
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair
|
|
22
|
-
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the
|
|
21
|
+
'结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
|
|
22
|
+
'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
|
|
23
23
|
'结果文件「{name}」超过飞书 30 MB 上限,未发送。':
|
|
24
24
|
'The result file "{name}" exceeds the Feishu 30 MB limit and was not sent.',
|
|
25
25
|
'结果文件「{name}」为空,飞书不允许发送空文件。':
|
|
@@ -28,8 +28,8 @@ export default {
|
|
|
28
28
|
'The result file "{name}" was temporarily rate-limited by Feishu and could not be sent. Please try again later.',
|
|
29
29
|
'结果文件「{name}」已生成,但暂时未能发送,请稍后重试。':
|
|
30
30
|
'The result file "{name}" was generated but could not be sent right now. Please try again later.',
|
|
31
|
-
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness
|
|
32
|
-
'Message processing failed. Please try again later. If the problem persists, check the connection status on the
|
|
31
|
+
'处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的「IM机器人」设置页检查飞书连接状态。':
|
|
32
|
+
'Message processing failed. Please try again later. If the problem persists, check the Feishu connection status on the IM Bot settings page in DeepSeek Harness.',
|
|
33
33
|
'已开启全新 Harness 会话。': 'A brand-new Harness session has started.',
|
|
34
34
|
'飞书机器人与 DeepSeek Harness 连接正常。':
|
|
35
35
|
'The Feishu bot is connected to DeepSeek Harness and working normally.',
|
|
@@ -338,8 +338,8 @@ export default {
|
|
|
338
338
|
'飞书机器人': 'Feishu bot',
|
|
339
339
|
|
|
340
340
|
// feishu/message-utils.mjs
|
|
341
|
-
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair
|
|
342
|
-
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the
|
|
341
|
+
'飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
|
|
342
|
+
'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
|
|
343
343
|
|
|
344
344
|
// feishu/feishu-runtime.mjs — callback probe notices
|
|
345
345
|
'✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。':
|
|
@@ -73,8 +73,8 @@ export default {
|
|
|
73
73
|
// connection-test.mjs
|
|
74
74
|
'✅ DeepSeek Harness 连接测试成功':
|
|
75
75
|
'✅ DeepSeek Harness connection test succeeded',
|
|
76
|
-
'
|
|
77
|
-
'This message was sent from the "{name}" bot card on the
|
|
76
|
+
'这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。':
|
|
77
|
+
'This message was sent from the "{name}" bot card on the IM Bot settings page.',
|
|
78
78
|
'{channelLabel}尚未收到可用于测试的私聊消息。':
|
|
79
79
|
'The {channelLabel} has not received a direct message that can be used for testing yet.',
|
|
80
80
|
'机器人': 'bot',
|