@xmanrui/dsh-im 4.37.0 → 4.38.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/PROACTIVE_DELIVERY.en.md +18 -2
- package/PROACTIVE_DELIVERY.md +18 -2
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/assets/logo-plugin-message-link-gradient.webp +0 -0
- package/lib/client.js +158 -62
- package/lib/index.js +322 -317
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/plugin-src/client/bot-general-settings.js +79 -42
- package/plugin-src/client/channels/feishu/api.js +3 -1
- package/plugin-src/client/delivery-settings.js +2 -2
- package/plugin-src/client/i18n.js +8 -7
- package/plugin-src/client/styles.js +19 -0
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +18 -0
- package/plugin-src/host/delivery-adapter.mjs +9 -1
- package/plugin-src/host/delivery-service.mjs +4 -3
- package/plugin-src/host/session-sync-coordinator.mjs +9 -5
- package/scripts/verify-package.mjs +11 -6
- package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
- package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
- package/src/channels/discord/discord-runtime.mjs +13 -2
- package/src/channels/feishu/bridge.mjs +30 -11
- package/src/channels/feishu/feishu-cards.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +10 -1
- package/src/channels/feishu/multi-bot-controller.mjs +18 -0
- package/src/channels/feishu/plugin-config-store.mjs +2 -1
- package/src/channels/feishu/step-push-mode.mjs +21 -0
- package/src/channels/matrix/matrix-crypto.mjs +19 -5
- package/src/channels/matrix/matrix-runtime.mjs +16 -6
- package/src/channels/qq/markdown-reply.mjs +7 -340
- package/src/channels/qq/qq-runtime.mjs +11 -3
- package/src/channels/shared/harness-approval.mjs +102 -2
- package/src/channels/shared/harness-client.mjs +15 -3
- package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
- package/src/channels/shared/i18n-en/wecom.mjs +3 -0
- package/src/channels/shared/markdown-chunks.mjs +338 -0
- package/src/channels/shared/message-failure.mjs +1 -1
- package/src/channels/shared/text-harness-bridge.mjs +4 -1
- package/src/channels/slack/slack-api.mjs +2 -1
- package/src/channels/slack/slack-runtime.mjs +30 -0
- package/src/channels/telegram/telegram-runtime.mjs +1 -0
- package/src/channels/wecom/wecom-bridge.mjs +45 -10
- package/src/channels/wecom/wecom-runtime.mjs +11 -5
- package/src/channels/weixin/weixin-runtime.mjs +17 -10
- package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
|
@@ -4,347 +4,13 @@
|
|
|
4
4
|
import { t } from '../shared/i18n.mjs';
|
|
5
5
|
import { ApiError } from '@tencent-connect/qqbot-nodejs';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
|
|
8
|
+
export { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
|
|
9
|
+
|
|
9
10
|
const MARKDOWN_REJECTION_CODES = new Set([40_034_090]);
|
|
10
11
|
const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
|
|
11
12
|
const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
|
|
12
13
|
|
|
13
|
-
function safeSliceIndex(value, limit) {
|
|
14
|
-
let index = Math.min(limit, value.length);
|
|
15
|
-
const before = value.charCodeAt(index - 1);
|
|
16
|
-
const after = value.charCodeAt(index);
|
|
17
|
-
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
18
|
-
index -= 1;
|
|
19
|
-
}
|
|
20
|
-
return Math.max(1, index);
|
|
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
|
-
|
|
238
|
-
/**
|
|
239
|
-
* 按换行边界切分 Markdown 文本:
|
|
240
|
-
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
|
241
|
-
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
|
242
|
-
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
243
|
-
*/
|
|
244
|
-
function chunkMarkdownParts(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
245
|
-
const value = typeof text === 'string' ? text : '';
|
|
246
|
-
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
|
247
|
-
if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
|
|
248
|
-
|
|
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');
|
|
252
|
-
const chunks = [];
|
|
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
|
-
};
|
|
260
|
-
|
|
261
|
-
const appendBlock = (block) => {
|
|
262
|
-
if (block.length <= bound) {
|
|
263
|
-
if (current === null) {
|
|
264
|
-
current = block;
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
const candidate = `${current}\n${block}`;
|
|
268
|
-
if (candidate.length > bound) {
|
|
269
|
-
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
270
|
-
current = block;
|
|
271
|
-
} else {
|
|
272
|
-
current = candidate;
|
|
273
|
-
}
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
|
277
|
-
flushCurrent();
|
|
278
|
-
let remaining = block;
|
|
279
|
-
while (remaining.length > bound) {
|
|
280
|
-
const index = safeSliceIndex(remaining, bound);
|
|
281
|
-
const part = remaining.slice(0, index);
|
|
282
|
-
chunks.push({ markdown: part, plain: part });
|
|
283
|
-
remaining = remaining.slice(index);
|
|
284
|
-
}
|
|
285
|
-
current = remaining;
|
|
286
|
-
};
|
|
287
|
-
|
|
288
|
-
const appendLine = (line) => {
|
|
289
|
-
let remaining = line;
|
|
290
|
-
// 超长行先硬切,保证每块不超过 bound。
|
|
291
|
-
while (remaining.length > bound) {
|
|
292
|
-
flushCurrent();
|
|
293
|
-
const index = safeSliceIndex(remaining, bound);
|
|
294
|
-
const part = remaining.slice(0, index);
|
|
295
|
-
chunks.push({ markdown: part, plain: part });
|
|
296
|
-
remaining = remaining.slice(index);
|
|
297
|
-
}
|
|
298
|
-
appendBlock(remaining);
|
|
299
|
-
};
|
|
300
|
-
|
|
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));
|
|
316
|
-
}
|
|
317
|
-
index = hasClosingFence ? end + 1 : lines.length;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
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;
|
|
333
|
-
continue;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
appendLine(lines[index]);
|
|
337
|
-
index += 1;
|
|
338
|
-
}
|
|
339
|
-
|
|
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);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
14
|
function nextMsgSeq() {
|
|
349
15
|
// 与 SDK getNextMsgSeq 相同的 16-bit 随机 seed;同批分片在 seed 上递增,
|
|
350
16
|
// 保证同一个被动回复 msg_id 内不发生随机碰撞。
|
|
@@ -376,7 +42,7 @@ function sendPlainText(bot, target, content, msgSeq) {
|
|
|
376
42
|
* 以 markdown(msg_type=2)发送回复;单条被平台拒绝时回退纯文本(msg_type=0)。
|
|
377
43
|
* 返回每条消息的平台响应,供调用方提取 provider message ids。
|
|
378
44
|
*/
|
|
379
|
-
export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
45
|
+
export async function sendMarkdownReply(bot, target, text, { logger, signal, requireComplete = false } = {}) {
|
|
380
46
|
const chunks = chunkMarkdownParts(text);
|
|
381
47
|
const results = [];
|
|
382
48
|
const firstMsgSeq = nextMsgSeq();
|
|
@@ -404,6 +70,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
404
70
|
};
|
|
405
71
|
|
|
406
72
|
for (const [index, chunk] of chunks.entries()) {
|
|
73
|
+
signal?.throwIfAborted();
|
|
407
74
|
const msgSeq = (firstMsgSeq + index) & 0xFFFF;
|
|
408
75
|
const deliveryTarget = overflow && index >= passiveContentCount
|
|
409
76
|
? proactiveTarget
|
|
@@ -423,7 +90,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
423
90
|
'[dsh-im:qq] markdown delivery outcome is uncertain; refusing a duplicate-prone retry:',
|
|
424
91
|
error,
|
|
425
92
|
);
|
|
426
|
-
if (results.length === 0) throw error;
|
|
93
|
+
if (requireComplete || results.length === 0) throw error;
|
|
427
94
|
await sendPartialNotice();
|
|
428
95
|
break;
|
|
429
96
|
}
|
|
@@ -437,7 +104,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
|
|
|
437
104
|
try {
|
|
438
105
|
results.push(await sendPlainText(bot, deliveryTarget, chunk.plain, msgSeq));
|
|
439
106
|
} catch (error) {
|
|
440
|
-
if (results.length === 0) throw error;
|
|
107
|
+
if (requireComplete || results.length === 0) throw error;
|
|
441
108
|
await sendPartialNotice();
|
|
442
109
|
break;
|
|
443
110
|
}
|
|
@@ -9,6 +9,7 @@ import { t } from '../shared/i18n.mjs';
|
|
|
9
9
|
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
|
|
10
10
|
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
|
|
11
11
|
import { isQqMessageAddressed, normalizeQqMentions } from './qq-mention.mjs';
|
|
12
|
+
import { sendMarkdownReply } from './markdown-reply.mjs';
|
|
12
13
|
|
|
13
14
|
function timeoutError() {
|
|
14
15
|
const error = new Error('QQ WebSocket did not become ready in time');
|
|
@@ -114,7 +115,7 @@ export class QqRuntime {
|
|
|
114
115
|
}, options);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
async sendProactiveText(target, text, { signal } = {}) {
|
|
118
|
+
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
118
119
|
const nativeId = target?.kind === 'user'
|
|
119
120
|
? (typeof target?.route?.userOpenId === 'string' ? target.route.userOpenId.trim() : '')
|
|
120
121
|
: target?.kind === 'group'
|
|
@@ -131,10 +132,17 @@ export class QqRuntime {
|
|
|
131
132
|
throw error;
|
|
132
133
|
}
|
|
133
134
|
signal?.throwIfAborted();
|
|
134
|
-
|
|
135
|
+
const replyTarget = {
|
|
135
136
|
scope: target.kind === 'user' ? 'c2c' : 'group',
|
|
136
137
|
targetId: nativeId,
|
|
137
|
-
}
|
|
138
|
+
};
|
|
139
|
+
if (format === 'markdown') {
|
|
140
|
+
await sendMarkdownReply(this.#bot, replyTarget, text, {
|
|
141
|
+
logger: this.#logger, signal, requireComplete: true,
|
|
142
|
+
});
|
|
143
|
+
return { sent: true };
|
|
144
|
+
}
|
|
145
|
+
return this.#bot.sendText(replyTarget, text);
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
async start() {
|
|
@@ -19,6 +19,11 @@ const APPROVAL_UNAVAILABLE_TEXT = '机器人端无法处理这次审批,请到
|
|
|
19
19
|
const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
|
|
20
20
|
const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
|
|
21
21
|
const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
|
|
22
|
+
const APPROVAL_TIMEOUT_TEXT = '审批已超时,已自动拒绝此次操作。';
|
|
23
|
+
const APPROVAL_TIMEOUT_UNCONFIRMED_TEXT = '审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。';
|
|
24
|
+
const DEFAULT_APPROVAL_TIMEOUT_MS = 60 * 60_000;
|
|
25
|
+
const EXPIRY_RETRY_BASE_MS = 30_000;
|
|
26
|
+
const EXPIRY_RETRY_MAX_MS = 5 * 60_000;
|
|
22
27
|
const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
|
|
23
28
|
const MAX_RESOLVED_ROUTES = 2_048;
|
|
24
29
|
|
|
@@ -102,6 +107,7 @@ function approvalResult(pending, outcome) {
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
function approvalOutcomeText(outcome) {
|
|
110
|
+
if (outcome === 'expired') return t(APPROVAL_TIMEOUT_TEXT);
|
|
105
111
|
if (outcome === 'unavailable') return t(APPROVAL_UNAVAILABLE_TEXT);
|
|
106
112
|
if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
|
|
107
113
|
if (outcome === 'rejected') return t('已拒绝此次操作。');
|
|
@@ -114,10 +120,14 @@ export class HarnessApprovalQueue {
|
|
|
114
120
|
#byId = new Map();
|
|
115
121
|
#routes = new Map();
|
|
116
122
|
#resolvedRoutes = new Map();
|
|
123
|
+
#approvalTimeoutMs;
|
|
117
124
|
|
|
118
|
-
constructor({ label = 'IM', logger = console } = {}) {
|
|
125
|
+
constructor({ label = 'IM', logger = console, approvalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS } = {}) {
|
|
119
126
|
this.#label = label;
|
|
120
127
|
this.#logger = logger;
|
|
128
|
+
this.#approvalTimeoutMs = Number.isFinite(approvalTimeoutMs) && approvalTimeoutMs > 0
|
|
129
|
+
? approvalTimeoutMs
|
|
130
|
+
: DEFAULT_APPROVAL_TIMEOUT_MS;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
hasPending(key) {
|
|
@@ -184,6 +194,10 @@ export class HarnessApprovalQueue {
|
|
|
184
194
|
}
|
|
185
195
|
await pending.activationTask?.catch(() => undefined);
|
|
186
196
|
await pending.presentationTask?.catch(() => undefined);
|
|
197
|
+
if (!pending.inactive && pending.expiring) {
|
|
198
|
+
await send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
187
201
|
if (pending.inactive || pending.resolving) {
|
|
188
202
|
await send(t(APPROVAL_RESOLVED_TEXT));
|
|
189
203
|
return;
|
|
@@ -338,11 +352,22 @@ export class HarnessApprovalQueue {
|
|
|
338
352
|
closedOutcome: null,
|
|
339
353
|
resolutionNotified: false,
|
|
340
354
|
activationTask: null,
|
|
355
|
+
timeoutTimer: null,
|
|
356
|
+
expired: false,
|
|
357
|
+
expiring: false,
|
|
358
|
+
expiryAttempts: 0,
|
|
359
|
+
expiryNoticeSent: false,
|
|
341
360
|
};
|
|
342
361
|
this.#byId.set(approvalId, pending);
|
|
343
362
|
const route = this.#routes.get(key) ?? { items: [] };
|
|
344
363
|
route.items.push(pending);
|
|
345
364
|
this.#routes.set(key, route);
|
|
365
|
+
pending.timeoutTimer = setTimeout(() => {
|
|
366
|
+
void this.#expire(pending).catch((error) => {
|
|
367
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
|
|
368
|
+
});
|
|
369
|
+
}, this.#approvalTimeoutMs);
|
|
370
|
+
pending.timeoutTimer.unref?.();
|
|
346
371
|
if (route.items[0] === pending) await this.#present(pending);
|
|
347
372
|
return true;
|
|
348
373
|
}
|
|
@@ -377,13 +402,15 @@ export class HarnessApprovalQueue {
|
|
|
377
402
|
const shouldNotify = pending.presented || presentationTask;
|
|
378
403
|
const send = pending.send;
|
|
379
404
|
const next = this.#remove(pending);
|
|
405
|
+
// The host reports our own timeout rejection as a plain rejection.
|
|
406
|
+
const outcome = pending.expiring && resolution.outcome === 'rejected' ? 'expired' : resolution.outcome;
|
|
380
407
|
await this.#transition(next, async () => {
|
|
381
408
|
let delivered = pending.presented;
|
|
382
409
|
if (presentationTask) {
|
|
383
410
|
delivered = await presentationTask.then(() => true, () => false);
|
|
384
411
|
}
|
|
385
412
|
if (shouldNotify && delivered) {
|
|
386
|
-
await this.#notifyResolved(pending,
|
|
413
|
+
await this.#notifyResolved(pending, outcome, send);
|
|
387
414
|
}
|
|
388
415
|
});
|
|
389
416
|
return true;
|
|
@@ -453,6 +480,70 @@ export class HarnessApprovalQueue {
|
|
|
453
480
|
}
|
|
454
481
|
}
|
|
455
482
|
|
|
483
|
+
async #expire(pending) {
|
|
484
|
+
pending.timeoutTimer = null;
|
|
485
|
+
pending.expired = true;
|
|
486
|
+
// An in-flight decision wins; #submit expires the approval if it fails.
|
|
487
|
+
if (pending.inactive || pending.resolving || pending.submitting) return;
|
|
488
|
+
pending.resolving = true;
|
|
489
|
+
pending.expiring = true;
|
|
490
|
+
// Keep the route barrier: an earlier confirmation must finish before a
|
|
491
|
+
// later approval can be presented.
|
|
492
|
+
await pending.activationTask?.catch(() => undefined);
|
|
493
|
+
if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return;
|
|
494
|
+
await this.#releaseExpired(pending);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async #releaseExpired(pending) {
|
|
498
|
+
pending.timeoutTimer = null;
|
|
499
|
+
if (pending.inactive) return;
|
|
500
|
+
let outcome = 'expired';
|
|
501
|
+
try {
|
|
502
|
+
// Reject rather than withdraw: a withdrawal only retires the IM side and
|
|
503
|
+
// would leave a shared Web approval waiting without a bound.
|
|
504
|
+
await pending.interaction.respond(
|
|
505
|
+
approvalResult(pending, 'rejected'),
|
|
506
|
+
{ signal: AbortSignal.timeout(5_000) },
|
|
507
|
+
);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (error?.code !== 'interaction-not-pending') {
|
|
510
|
+
if (pending.inactive) return;
|
|
511
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to release an expired approval:`, error);
|
|
512
|
+
this.#retryExpiry(pending);
|
|
513
|
+
if (!pending.expiryNoticeSent && pending.presented) {
|
|
514
|
+
pending.expiryNoticeSent = true;
|
|
515
|
+
await pending.send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT)).catch(() => undefined);
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
outcome = 'resolved';
|
|
520
|
+
}
|
|
521
|
+
if (pending.inactive) return;
|
|
522
|
+
const presentationTask = pending.presentationTask;
|
|
523
|
+
const shouldNotify = pending.presented || presentationTask;
|
|
524
|
+
const send = pending.send;
|
|
525
|
+
const next = this.#remove(pending);
|
|
526
|
+
pending.closedOutcome = outcome;
|
|
527
|
+
await this.#transition(next, async () => {
|
|
528
|
+
let delivered = pending.presented;
|
|
529
|
+
if (presentationTask) {
|
|
530
|
+
delivered = await presentationTask.then(() => true, () => false);
|
|
531
|
+
}
|
|
532
|
+
if (shouldNotify && delivered) await this.#notifyResolved(pending, outcome, send);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#retryExpiry(pending) {
|
|
537
|
+
const delay = Math.min(EXPIRY_RETRY_BASE_MS * 2 ** pending.expiryAttempts, EXPIRY_RETRY_MAX_MS);
|
|
538
|
+
pending.expiryAttempts += 1;
|
|
539
|
+
pending.timeoutTimer = setTimeout(() => {
|
|
540
|
+
void this.#releaseExpired(pending).catch((error) => {
|
|
541
|
+
this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
|
|
542
|
+
});
|
|
543
|
+
}, delay);
|
|
544
|
+
pending.timeoutTimer.unref?.();
|
|
545
|
+
}
|
|
546
|
+
|
|
456
547
|
async #submit(pending, outcome) {
|
|
457
548
|
pending.submitting = true;
|
|
458
549
|
try {
|
|
@@ -478,6 +569,11 @@ export class HarnessApprovalQueue {
|
|
|
478
569
|
if (pending.inactive) return;
|
|
479
570
|
pending.submitting = false;
|
|
480
571
|
this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
|
|
572
|
+
// The deadline passed while this decision was in flight.
|
|
573
|
+
if (pending.expired) {
|
|
574
|
+
await this.#expire(pending);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
481
577
|
await pending.send(t('审批提交失败,请重新回复「批准」或「拒绝」。')).catch(() => undefined);
|
|
482
578
|
return;
|
|
483
579
|
}
|
|
@@ -536,6 +632,10 @@ export class HarnessApprovalQueue {
|
|
|
536
632
|
#remove(pending) {
|
|
537
633
|
if (pending.inactive) return null;
|
|
538
634
|
pending.inactive = true;
|
|
635
|
+
if (pending.timeoutTimer) {
|
|
636
|
+
clearTimeout(pending.timeoutTimer);
|
|
637
|
+
pending.timeoutTimer = null;
|
|
638
|
+
}
|
|
539
639
|
this.#rememberResolvedRoute(pending.key);
|
|
540
640
|
this.#byId.delete(pending.approvalId);
|
|
541
641
|
const route = this.#routes.get(pending.key);
|
|
@@ -33,6 +33,18 @@ const MAX_ERROR_CLASSIFICATION_BYTES = 64;
|
|
|
33
33
|
const IM_INPUT_ORIGIN_TTL_MS = 30 * 60 * 1000;
|
|
34
34
|
const MAX_IM_INPUT_ORIGINS = 4096;
|
|
35
35
|
|
|
36
|
+
function clientTimeZoneFields() {
|
|
37
|
+
try {
|
|
38
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
39
|
+
return typeof timeZone === 'string' && timeZone.trim()
|
|
40
|
+
? { clientTimeZone: timeZone }
|
|
41
|
+
: {};
|
|
42
|
+
} catch {
|
|
43
|
+
// Time-zone metadata is optional; unresolved values are not JSON-safe.
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
async function smallResponseText(response) {
|
|
37
49
|
const stream = response?.body;
|
|
38
50
|
if (!stream || typeof stream.getReader !== 'function') return null;
|
|
@@ -1621,7 +1633,7 @@ export class HarnessClient {
|
|
|
1621
1633
|
sessionId,
|
|
1622
1634
|
mode: 'steer',
|
|
1623
1635
|
content: [{ type: 'text', text }],
|
|
1624
|
-
|
|
1636
|
+
...clientTimeZoneFields(),
|
|
1625
1637
|
}, 30_000, { ...options, rpcId: inputRpcId });
|
|
1626
1638
|
return true;
|
|
1627
1639
|
} catch (error) {
|
|
@@ -1890,12 +1902,12 @@ export class HarnessClient {
|
|
|
1890
1902
|
// `content`: the prompt also carries whatever the user typed, and a
|
|
1891
1903
|
// message that merely looks like a guidance block is not configuration.
|
|
1892
1904
|
imSourceGuidance.publish(sessionId, options.sourceGuidance);
|
|
1893
|
-
const
|
|
1905
|
+
const timeZoneFields = clientTimeZoneFields();
|
|
1894
1906
|
const sendPrompt = (promptContent) => this.#sessionRpc('session.prompt', {
|
|
1895
1907
|
sessionId,
|
|
1896
1908
|
mode: 'queue',
|
|
1897
1909
|
content: promptContent,
|
|
1898
|
-
|
|
1910
|
+
...timeZoneFields,
|
|
1899
1911
|
}, 30_000, { rpcId: promptRpcId, signal });
|
|
1900
1912
|
try {
|
|
1901
1913
|
await sendPrompt(content);
|
|
@@ -56,6 +56,10 @@ export default {
|
|
|
56
56
|
'Please finish the current question first, then reply exactly with 「批准」 (approve) or 「拒绝」 (reject).',
|
|
57
57
|
'该审批已处理,无需再次回复。':
|
|
58
58
|
'This approval has already been handled; no need to reply again.',
|
|
59
|
+
'审批已超时,已自动拒绝此次操作。':
|
|
60
|
+
'This approval timed out and the operation was rejected automatically.',
|
|
61
|
+
'审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。':
|
|
62
|
+
'This approval timed out. Automatic rejection has not been confirmed yet and will be retried shortly.',
|
|
59
63
|
'DeepSeek Harness 需要你的审批:':
|
|
60
64
|
'DeepSeek Harness needs your approval:',
|
|
61
65
|
'工具:{tool}': 'Tool: {tool}',
|
|
@@ -30,6 +30,9 @@ export default {
|
|
|
30
30
|
// Bot replies and status text (wecom-bridge.mjs)
|
|
31
31
|
|
|
32
32
|
'当前待处理图片较多,请稍后重新发送。': 'Too many images are pending. Please send them again later.',
|
|
33
|
+
'图片 {indexes} 来自当前消息。': 'Images {indexes} are from the current message.',
|
|
34
|
+
'图片 {indexes} 来自被引用消息。图片序号与本轮附件清单一致。': 'Images {indexes} are from the quoted message. Image numbers match the attachment list for this turn.',
|
|
35
|
+
'有 {count} 张引用图片未提供可下载资源,未附上这些图片;需要看图时请用户重新发送。': '{count} quoted images have no downloadable resource and are not attached. Ask the user to send them again if needed.',
|
|
33
36
|
|
|
34
37
|
'结果文件「{name}」已生成,但企业微信智能机器人缺少素材上传或文件消息能力,请检查机器人权限。': 'The result file "{name}" was generated, but the Enterprise WeChat bot lacks media upload or file message capability. Please check the bot permissions.',
|
|
35
38
|
'结果文件「{name}」超过当前企业微信机器人可发送的文件大小,未发送。': 'The result file "{name}" exceeds the file size this Enterprise WeChat bot can send and was not sent.',
|