@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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Structure-aware Markdown chunks reused by outbound channel renderers.
|
|
2
|
+
const DEFAULT_CHUNK_LIMIT = 4_500;
|
|
3
|
+
const CODE_FENCE_OPEN = /^( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
4
|
+
|
|
5
|
+
function safeSliceIndex(value, limit) {
|
|
6
|
+
let index = Math.min(limit, value.length);
|
|
7
|
+
const before = value.charCodeAt(index - 1);
|
|
8
|
+
const after = value.charCodeAt(index);
|
|
9
|
+
if (before >= 0xD800 && before <= 0xDBFF && after >= 0xDC00 && after <= 0xDFFF) {
|
|
10
|
+
index -= 1;
|
|
11
|
+
}
|
|
12
|
+
return Math.max(1, index);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openingFence(line) {
|
|
16
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
17
|
+
const match = CODE_FENCE_OPEN.exec(normalized);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
// CommonMark forbids backticks in the info string of a backtick fence.
|
|
20
|
+
if (match[2][0] === '`' && match[3].includes('`')) return null;
|
|
21
|
+
return { delimiter: match[2], info: match[3], indent: match[1].length };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function closesFence(line, opening) {
|
|
25
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line);
|
|
26
|
+
return Boolean(match
|
|
27
|
+
&& match[1][0] === opening.delimiter[0]
|
|
28
|
+
&& match[1].length >= opening.delimiter.length);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function longestLeadingRun(text, character) {
|
|
32
|
+
let longest = 0;
|
|
33
|
+
for (const line of text.split('\n')) {
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (offset < 3 && line[offset] === ' ') offset += 1;
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (line[offset + length] === character) length += 1;
|
|
38
|
+
longest = Math.max(longest, length);
|
|
39
|
+
}
|
|
40
|
+
return longest;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeCodeFence(text, info) {
|
|
44
|
+
const backticks = '`'.repeat(Math.max(3, longestLeadingRun(text, '`') + 1));
|
|
45
|
+
const tildes = '~'.repeat(Math.max(3, longestLeadingRun(text, '~') + 1));
|
|
46
|
+
if (info.includes('`')) return tildes;
|
|
47
|
+
return backticks.length <= tildes.length ? backticks : tildes;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function removeFenceIndent(line, indent) {
|
|
51
|
+
let index = 0;
|
|
52
|
+
let column = 0;
|
|
53
|
+
let remaining = indent;
|
|
54
|
+
while (remaining > 0 && index < line.length) {
|
|
55
|
+
if (line[index] === ' ') {
|
|
56
|
+
index += 1;
|
|
57
|
+
column += 1;
|
|
58
|
+
remaining -= 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (line[index] === '\t') {
|
|
62
|
+
const width = 4 - (column % 4);
|
|
63
|
+
if (width <= remaining) {
|
|
64
|
+
index += 1;
|
|
65
|
+
column += width;
|
|
66
|
+
remaining -= width;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return `${' '.repeat(width - remaining)}${line.slice(index + 1)}`;
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return line.slice(index);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeFencedContent(lines, indent) {
|
|
77
|
+
return indent === 0 ? lines : lines.map((line) => removeFenceIndent(line, indent));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitRawText(text, limit) {
|
|
81
|
+
if (text.length <= limit) return [text];
|
|
82
|
+
const parts = [];
|
|
83
|
+
let remaining = text;
|
|
84
|
+
while (remaining.length > limit) {
|
|
85
|
+
let index = safeSliceIndex(remaining, limit);
|
|
86
|
+
const newline = remaining.lastIndexOf('\n', index - 1);
|
|
87
|
+
if (newline >= 0) index = newline + 1;
|
|
88
|
+
parts.push(remaining.slice(0, index));
|
|
89
|
+
remaining = remaining.slice(index);
|
|
90
|
+
}
|
|
91
|
+
if (remaining || parts.length === 0) parts.push(remaining);
|
|
92
|
+
return parts;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function splitAsFencedCode(text, info, limit) {
|
|
96
|
+
if (limit < 8) return splitRawText(text, limit).map((part) => ({
|
|
97
|
+
markdown: part,
|
|
98
|
+
plain: part,
|
|
99
|
+
}));
|
|
100
|
+
const delimiter = safeCodeFence(text, info);
|
|
101
|
+
let opening = `${delimiter}${info}`;
|
|
102
|
+
if (opening.length + delimiter.length + 2 >= limit) opening = delimiter;
|
|
103
|
+
const payloadLimit = limit - opening.length - delimiter.length - 2;
|
|
104
|
+
if (payloadLimit < 1) return splitRawText(text, limit).map((part) => ({
|
|
105
|
+
markdown: part,
|
|
106
|
+
plain: part,
|
|
107
|
+
}));
|
|
108
|
+
return splitRawText(text, payloadLimit).map((part) => (
|
|
109
|
+
{
|
|
110
|
+
markdown: `${opening}\n${part}${part.endsWith('\n') ? '' : '\n'}${delimiter}`,
|
|
111
|
+
plain: part,
|
|
112
|
+
}
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function hasClosingBacktickRun(text, start, length) {
|
|
117
|
+
for (let index = start; index < text.length;) {
|
|
118
|
+
if (text[index] !== '`') {
|
|
119
|
+
index += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
let end = index + 1;
|
|
123
|
+
while (text[end] === '`') end += 1;
|
|
124
|
+
if (end - index === length) return true;
|
|
125
|
+
index = end;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function gfmTableCells(line) {
|
|
131
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
132
|
+
if (normalized.startsWith(' ') || normalized.startsWith('\t')) return null;
|
|
133
|
+
const text = normalized.trim();
|
|
134
|
+
if (!text) return null;
|
|
135
|
+
|
|
136
|
+
const cells = [];
|
|
137
|
+
let cell = '';
|
|
138
|
+
let separators = 0;
|
|
139
|
+
let codeRun = 0;
|
|
140
|
+
for (let index = 0; index < text.length;) {
|
|
141
|
+
const character = text[index];
|
|
142
|
+
if (character === '\\' && index + 1 < text.length) {
|
|
143
|
+
cell += text.slice(index, index + 2);
|
|
144
|
+
index += 2;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (character === '`') {
|
|
148
|
+
let end = index + 1;
|
|
149
|
+
while (text[end] === '`') end += 1;
|
|
150
|
+
const length = end - index;
|
|
151
|
+
if (codeRun === length) {
|
|
152
|
+
codeRun = 0;
|
|
153
|
+
} else if (codeRun === 0 && hasClosingBacktickRun(text, end, length)) {
|
|
154
|
+
codeRun = length;
|
|
155
|
+
}
|
|
156
|
+
cell += text.slice(index, end);
|
|
157
|
+
index = end;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (character === '|' && codeRun === 0) {
|
|
161
|
+
cells.push(cell);
|
|
162
|
+
cell = '';
|
|
163
|
+
separators += 1;
|
|
164
|
+
index += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
cell += character;
|
|
168
|
+
index += 1;
|
|
169
|
+
}
|
|
170
|
+
if (separators === 0) return null;
|
|
171
|
+
cells.push(cell);
|
|
172
|
+
if (cells[0].trim() === '') cells.shift();
|
|
173
|
+
if (cells.at(-1)?.trim() === '') cells.pop();
|
|
174
|
+
return cells.length > 0 ? cells : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function gfmTableStart(lines, index) {
|
|
178
|
+
const headerCells = gfmTableCells(lines[index]);
|
|
179
|
+
const separatorCells = gfmTableCells(lines[index + 1] ?? '');
|
|
180
|
+
if (!headerCells || !separatorCells || headerCells.length !== separatorCells.length) return null;
|
|
181
|
+
if (!separatorCells.every((cell) => /^\s*:?-+:?\s*$/.test(cell))) return null;
|
|
182
|
+
return { headerCells };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function supportedTableBodyCells(line) {
|
|
186
|
+
const normalized = line.endsWith('\r') ? line.slice(0, -1) : line;
|
|
187
|
+
const content = normalized.replace(/^ {0,3}/, '');
|
|
188
|
+
if (openingFence(normalized)
|
|
189
|
+
|| /^(?:#{1,6}(?:[ \t]+|$)|>|(?:[-+*]|\d{1,9}[.)])[ \t]+)/.test(content)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return gfmTableCells(normalized);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function splitOversizedTable(lines, limit) {
|
|
196
|
+
const source = lines.join('\n');
|
|
197
|
+
if (lines.length < 2 || !gfmTableStart(lines, 0)) {
|
|
198
|
+
return splitAsFencedCode(source, 'text', limit);
|
|
199
|
+
}
|
|
200
|
+
const prefix = `${lines[0]}\n${lines[1]}`;
|
|
201
|
+
const rows = lines.slice(2);
|
|
202
|
+
if (prefix.length > limit
|
|
203
|
+
|| rows.some((row) => `${prefix}\n${row}`.length > limit)) {
|
|
204
|
+
return splitAsFencedCode(source, 'text', limit);
|
|
205
|
+
}
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let current = prefix;
|
|
208
|
+
let currentRows = [];
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const candidate = `${current}\n${row}`;
|
|
211
|
+
if (candidate.length > limit) {
|
|
212
|
+
chunks.push({
|
|
213
|
+
markdown: current,
|
|
214
|
+
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
215
|
+
});
|
|
216
|
+
current = `${prefix}\n${row}`;
|
|
217
|
+
currentRows = [row];
|
|
218
|
+
} else {
|
|
219
|
+
current = candidate;
|
|
220
|
+
currentRows.push(row);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
chunks.push({
|
|
224
|
+
markdown: current,
|
|
225
|
+
plain: chunks.length === 0 ? current : currentRows.join('\n'),
|
|
226
|
+
});
|
|
227
|
+
return chunks;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 按换行边界切分 Markdown 文本:
|
|
232
|
+
* - 可容纳的代码块和可识别的 GFM pipe table 保持完整;
|
|
233
|
+
* - 超长代码块会补齐围栏,超长表格会为每段重复表头;
|
|
234
|
+
* - 超长行在 limit 处硬切,避免单行超限无法投递。
|
|
235
|
+
*/
|
|
236
|
+
export function chunkMarkdownParts(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
237
|
+
const value = typeof text === 'string' ? text : '';
|
|
238
|
+
const bound = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_CHUNK_LIMIT;
|
|
239
|
+
if (value.length <= bound) return value ? [{ markdown: value, plain: value }] : [];
|
|
240
|
+
|
|
241
|
+
// Once splitting is required, normalize line endings so a chunk never ends
|
|
242
|
+
// with a dangling CR whose paired LF moved to the next QQ message.
|
|
243
|
+
const lines = value.replace(/\r\n?/g, '\n').split('\n');
|
|
244
|
+
const chunks = [];
|
|
245
|
+
let current = null;
|
|
246
|
+
|
|
247
|
+
const flushCurrent = () => {
|
|
248
|
+
if (current === null) return;
|
|
249
|
+
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
250
|
+
current = null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const appendBlock = (block) => {
|
|
254
|
+
if (block.length <= bound) {
|
|
255
|
+
if (current === null) {
|
|
256
|
+
current = block;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const candidate = `${current}\n${block}`;
|
|
260
|
+
if (candidate.length > bound) {
|
|
261
|
+
if (current.length > 0) chunks.push({ markdown: current, plain: current });
|
|
262
|
+
current = block;
|
|
263
|
+
} else {
|
|
264
|
+
current = candidate;
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// 超大块:收束当前块后按 bound 硬切,保证每块可投递。
|
|
269
|
+
flushCurrent();
|
|
270
|
+
let remaining = block;
|
|
271
|
+
while (remaining.length > bound) {
|
|
272
|
+
const index = safeSliceIndex(remaining, bound);
|
|
273
|
+
const part = remaining.slice(0, index);
|
|
274
|
+
chunks.push({ markdown: part, plain: part });
|
|
275
|
+
remaining = remaining.slice(index);
|
|
276
|
+
}
|
|
277
|
+
current = remaining;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const appendLine = (line) => {
|
|
281
|
+
let remaining = line;
|
|
282
|
+
// 超长行先硬切,保证每块不超过 bound。
|
|
283
|
+
while (remaining.length > bound) {
|
|
284
|
+
flushCurrent();
|
|
285
|
+
const index = safeSliceIndex(remaining, bound);
|
|
286
|
+
const part = remaining.slice(0, index);
|
|
287
|
+
chunks.push({ markdown: part, plain: part });
|
|
288
|
+
remaining = remaining.slice(index);
|
|
289
|
+
}
|
|
290
|
+
appendBlock(remaining);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
for (let index = 0; index < lines.length;) {
|
|
294
|
+
const fence = openingFence(lines[index]);
|
|
295
|
+
if (fence) {
|
|
296
|
+
flushCurrent();
|
|
297
|
+
let end = index + 1;
|
|
298
|
+
while (end < lines.length && !closesFence(lines[end], fence)) end += 1;
|
|
299
|
+
const hasClosingFence = end < lines.length;
|
|
300
|
+
const blockLines = lines.slice(index, hasClosingFence ? end + 1 : lines.length);
|
|
301
|
+
const block = blockLines.join('\n');
|
|
302
|
+
if (block.length <= bound) {
|
|
303
|
+
appendBlock(block);
|
|
304
|
+
} else {
|
|
305
|
+
const contentLines = blockLines.slice(1, hasClosingFence ? -1 : undefined);
|
|
306
|
+
const content = normalizeFencedContent(contentLines, fence.indent).join('\n');
|
|
307
|
+
chunks.push(...splitAsFencedCode(content, fence.info, bound));
|
|
308
|
+
}
|
|
309
|
+
index = hasClosingFence ? end + 1 : lines.length;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (gfmTableStart(lines, index)) {
|
|
314
|
+
let end = index + 2;
|
|
315
|
+
while (end < lines.length && supportedTableBodyCells(lines[end])) end += 1;
|
|
316
|
+
const tableLines = lines.slice(index, end);
|
|
317
|
+
const table = tableLines.join('\n');
|
|
318
|
+
if (table.length <= bound) {
|
|
319
|
+
appendBlock(table);
|
|
320
|
+
} else {
|
|
321
|
+
flushCurrent();
|
|
322
|
+
chunks.push(...splitOversizedTable(tableLines, bound));
|
|
323
|
+
}
|
|
324
|
+
index = end;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
appendLine(lines[index]);
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
flushCurrent();
|
|
333
|
+
return chunks.filter(({ markdown, plain }) => markdown.length > 0 || plain.length > 0);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function chunkMarkdownText(text, limit = DEFAULT_CHUNK_LIMIT) {
|
|
337
|
+
return chunkMarkdownParts(text, limit).map(({ markdown }) => markdown);
|
|
338
|
+
}
|
|
@@ -128,7 +128,7 @@ function failureCode(error) {
|
|
|
128
128
|
'harness-loopback-forbidden', 'harness-host-untrusted',
|
|
129
129
|
'harness-request-forbidden',
|
|
130
130
|
].includes(code)) return 'HARNESS_ACCESS';
|
|
131
|
-
if (['harness-api-not-found', 'harness-response-invalid'].includes(code)) {
|
|
131
|
+
if (['harness-api-not-found', 'harness-response-invalid', 'gateway/input-invalid'].includes(code)) {
|
|
132
132
|
return 'HARNESS_PROTOCOL';
|
|
133
133
|
}
|
|
134
134
|
if (code === 'harness-turn-failed') return 'INTERNAL_UNKNOWN';
|
|
@@ -649,8 +649,11 @@ export class TextHarnessBridge {
|
|
|
649
649
|
});
|
|
650
650
|
}
|
|
651
651
|
|
|
652
|
-
sendProactiveText(target, text, { signal } = {}) {
|
|
652
|
+
sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
653
653
|
signal?.throwIfAborted();
|
|
654
|
+
if (format === 'markdown' && typeof this.#bot.sendDelivery === 'function') {
|
|
655
|
+
return this.#bot.sendDelivery(target, createTextDeliveryBlock(text, format));
|
|
656
|
+
}
|
|
654
657
|
return this.#bot.sendText(target, text);
|
|
655
658
|
}
|
|
656
659
|
|
|
@@ -271,13 +271,14 @@ export class SlackApi {
|
|
|
271
271
|
: null;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
postMessage({ channelId, text, threadTs, signal }) {
|
|
274
|
+
postMessage({ channelId, text, threadTs, signal, format = 'plain' }) {
|
|
275
275
|
return this.#request('chat.postMessage', {
|
|
276
276
|
tokenKind: 'bot',
|
|
277
277
|
signal,
|
|
278
278
|
body: {
|
|
279
279
|
channel: slackId(channelId, 'channel id'),
|
|
280
280
|
text: safeOutgoingText(text),
|
|
281
|
+
...(format === 'markdown' ? { blocks: [{ type: 'markdown', text: safeOutgoingText(text) }] } : {}),
|
|
281
282
|
...(threadTs ? { thread_ts: cleanString(threadTs) } : {}),
|
|
282
283
|
mrkdwn: true,
|
|
283
284
|
link_names: false,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
|
|
2
2
|
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
3
|
+
import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
|
|
4
|
+
import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
|
3
5
|
import { t } from '../shared/i18n.mjs';
|
|
4
6
|
import { SlackApi } from './slack-api.mjs';
|
|
5
7
|
import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs';
|
|
@@ -350,6 +352,34 @@ export class SlackBotClient {
|
|
|
350
352
|
return { providerMessageIds };
|
|
351
353
|
}
|
|
352
354
|
|
|
355
|
+
async sendDelivery(target, value) {
|
|
356
|
+
const block = createTextDeliveryBlock(value);
|
|
357
|
+
if (block.format === 'plain') return this.sendText(target, block.text);
|
|
358
|
+
const providerMessageIds = [];
|
|
359
|
+
// Standard Markdown blocks have a 12,000-character payload limit.
|
|
360
|
+
for (const chunk of chunkMarkdownParts(block.text, SLACK_STREAM_CHUNK_LIMIT)) {
|
|
361
|
+
this.#signal?.throwIfAborted();
|
|
362
|
+
try {
|
|
363
|
+
const result = await this.#api.postMessage({
|
|
364
|
+
channelId: target.channelId, threadTs: target.threadTs,
|
|
365
|
+
text: chunk.markdown, format: 'markdown', signal: this.#signal,
|
|
366
|
+
});
|
|
367
|
+
if (typeof result?.ts === 'string' && result.ts) providerMessageIds.push(result.ts);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
// Retry only a definite formatting rejection, never an ambiguous send.
|
|
370
|
+
if (this.#signal?.aborted || Number(error?.status) >= 500
|
|
371
|
+
|| !['invalid_blocks', 'invalid_blocks_format', 'invalid_markdown']
|
|
372
|
+
.includes(error?.providerCode)) throw error;
|
|
373
|
+
this.#logger?.warn?.('[dsh-im:slack] Markdown rejected; sending this chunk as text');
|
|
374
|
+
if (chunk.plain) {
|
|
375
|
+
const result = await this.sendText(target, chunk.plain);
|
|
376
|
+
providerMessageIds.push(...result.providerMessageIds);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return { providerMessageIds };
|
|
381
|
+
}
|
|
382
|
+
|
|
353
383
|
async addReaction(target, emoji, { signal } = {}) {
|
|
354
384
|
const reactionKey = String(emoji ?? '').trim();
|
|
355
385
|
await this.#api.addReaction({
|
|
@@ -761,6 +761,7 @@ export class TelegramBotClient {
|
|
|
761
761
|
try {
|
|
762
762
|
chunks = splitTelegramRichMarkdown(block.text);
|
|
763
763
|
} catch {
|
|
764
|
+
this.#logger.warn?.('[dsh-im:telegram] Rich Markdown could not be prepared; sending plain text');
|
|
764
765
|
return this.#sendPlain(target, block.text);
|
|
765
766
|
}
|
|
766
767
|
const providerMessageIds = [];
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
isBatchInputCommand,
|
|
22
22
|
} from '../shared/batch-input.mjs';
|
|
23
23
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
24
|
+
import { isSharedLocalCommand } from '../shared/command-permission.mjs';
|
|
24
25
|
import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
|
|
25
26
|
import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
|
|
26
27
|
import {
|
|
@@ -154,10 +155,9 @@ function messageText(frame) {
|
|
|
154
155
|
: text;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
function imageContents(
|
|
158
|
-
|
|
159
|
-
if (body
|
|
160
|
-
if (body.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
|
|
158
|
+
function imageContents(body) {
|
|
159
|
+
if (body?.msgtype === 'image') return [body.image];
|
|
160
|
+
if (body?.msgtype !== 'mixed' || !Array.isArray(body.mixed?.msg_item)) return [];
|
|
161
161
|
return body.mixed.msg_item
|
|
162
162
|
.filter((item) => item?.msgtype === 'image')
|
|
163
163
|
.map((item) => item.image);
|
|
@@ -259,7 +259,7 @@ export function wecomInboundMessage(frame, client) {
|
|
|
259
259
|
const replyTo = replyReferenceForBody(body);
|
|
260
260
|
return {
|
|
261
261
|
content: messageText(frame),
|
|
262
|
-
images: imageContents(
|
|
262
|
+
images: imageContents(body).map((image) => imageSource(client, image)).filter(Boolean),
|
|
263
263
|
files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
|
|
264
264
|
...(replyTo ? { replyTo } : {}),
|
|
265
265
|
};
|
|
@@ -337,6 +337,25 @@ function imageQueueFullMessage(message) {
|
|
|
337
337
|
};
|
|
338
338
|
}
|
|
339
339
|
|
|
340
|
+
function quotedImageGuidance(message) {
|
|
341
|
+
const lines = [];
|
|
342
|
+
const directCount = message.images.length;
|
|
343
|
+
const quotedCount = message.quotedImages?.length ?? 0;
|
|
344
|
+
if (quotedCount > 0) {
|
|
345
|
+
if (directCount > 0) lines.push(t('图片 {indexes} 来自当前消息。', {
|
|
346
|
+
indexes: Array.from({ length: directCount }, (_, index) => index + 1).join(', '),
|
|
347
|
+
}));
|
|
348
|
+
lines.push(t('图片 {indexes} 来自被引用消息。图片序号与本轮附件清单一致。', {
|
|
349
|
+
indexes: Array.from({ length: quotedCount }, (_, index) => directCount + index + 1).join(', '),
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
if (message.missingQuotedImageCount > 0) lines.push(t(
|
|
353
|
+
'有 {count} 张引用图片未提供可下载资源,未附上这些图片;需要看图时请用户重新发送。',
|
|
354
|
+
{ count: message.missingQuotedImageCount },
|
|
355
|
+
));
|
|
356
|
+
return lines.join('\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
340
359
|
function interactionReplyText(frame) {
|
|
341
360
|
return bodyOf(frame).msgtype === 'text' ? messageText(frame) : '';
|
|
342
361
|
}
|
|
@@ -1082,19 +1101,33 @@ export class WecomHarnessBridge {
|
|
|
1082
1101
|
// may legally stay queued longer. Start the authenticated SDK download as
|
|
1083
1102
|
// soon as the validated callback is accepted, then consume it in order.
|
|
1084
1103
|
const inboundMessage = wecomInboundMessage(frame, this.#client);
|
|
1085
|
-
const
|
|
1104
|
+
const directCount = inboundMessage.images.length;
|
|
1105
|
+
const quoteContents = isSharedLocalCommand(inboundMessage.content, {
|
|
1106
|
+
hasImages: hasInboundImages(inboundMessage),
|
|
1107
|
+
hasFiles: hasInboundFiles(inboundMessage),
|
|
1108
|
+
}) ? [] : imageContents(bodyOf(frame).quote);
|
|
1109
|
+
const quotedImages = quoteContents.map((image) => imageSource(this.#client, image)).filter(Boolean);
|
|
1110
|
+
const combinedMessage = { ...inboundMessage, images: [...inboundMessage.images, ...quotedImages] };
|
|
1111
|
+
const imageCount = combinedMessage.images.length;
|
|
1086
1112
|
let reservedImages = 0;
|
|
1087
|
-
let preparedMessage =
|
|
1113
|
+
let preparedMessage = combinedMessage;
|
|
1088
1114
|
if (imageCount > 0) {
|
|
1089
1115
|
if (this.#prefetchedImageCount + imageCount <= MAX_PREFETCHED_IMAGES) {
|
|
1090
1116
|
reservedImages = imageCount;
|
|
1091
1117
|
this.#prefetchedImageCount += reservedImages;
|
|
1092
|
-
preparedMessage = prefetchInboundImages(
|
|
1118
|
+
preparedMessage = prefetchInboundImages(combinedMessage, this.#signal,
|
|
1093
1119
|
this.#harness.getImageInputLimits?.() ?? imageInputLimits());
|
|
1094
1120
|
} else {
|
|
1095
|
-
preparedMessage = imageQueueFullMessage(
|
|
1121
|
+
preparedMessage = imageQueueFullMessage(combinedMessage);
|
|
1096
1122
|
}
|
|
1097
1123
|
}
|
|
1124
|
+
// Keep quoted media separate until local command and interaction routing is done.
|
|
1125
|
+
preparedMessage = {
|
|
1126
|
+
...preparedMessage,
|
|
1127
|
+
images: preparedMessage.images.slice(0, directCount),
|
|
1128
|
+
quotedImages: preparedMessage.images.slice(directCount),
|
|
1129
|
+
missingQuotedImageCount: quoteContents.length - quotedImages.length,
|
|
1130
|
+
};
|
|
1098
1131
|
preparedMessage = prefetchInboundFiles(preparedMessage, this.#signal);
|
|
1099
1132
|
const previous = this.#queues.get(key) ?? Promise.resolve();
|
|
1100
1133
|
const current = previous
|
|
@@ -1390,6 +1423,8 @@ export class WecomHarnessBridge {
|
|
|
1390
1423
|
let content = hasImages || hasReply
|
|
1391
1424
|
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
1392
1425
|
: undefined;
|
|
1426
|
+
const imageGuidance = quotedImageGuidance(message);
|
|
1427
|
+
if (imageGuidance) content.push({ type: 'text', text: imageGuidance });
|
|
1393
1428
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
1394
1429
|
let contextEnhanced = false;
|
|
1395
1430
|
if (snapshot) {
|
|
@@ -1440,7 +1475,7 @@ export class WecomHarnessBridge {
|
|
|
1440
1475
|
}),
|
|
1441
1476
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
1442
1477
|
files: message.files,
|
|
1443
|
-
images: message.images,
|
|
1478
|
+
images: [...message.images, ...(message.quotedImages ?? [])],
|
|
1444
1479
|
},
|
|
1445
1480
|
});
|
|
1446
1481
|
if (batchSubmission) {
|
|
@@ -4,6 +4,7 @@ import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/
|
|
|
4
4
|
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
|
|
5
5
|
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
|
|
6
6
|
import { t } from '../shared/i18n.mjs';
|
|
7
|
+
import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
|
|
7
8
|
|
|
8
9
|
function timeoutError() {
|
|
9
10
|
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
|
|
@@ -240,7 +241,7 @@ export class WecomRuntime {
|
|
|
240
241
|
}, options);
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
async sendProactiveText(target, text, { signal } = {}) {
|
|
244
|
+
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
244
245
|
const chatId = typeof target?.route?.chatId === 'string'
|
|
245
246
|
? target.route.chatId.trim() : '';
|
|
246
247
|
if ((target?.kind !== 'user' && target?.kind !== 'group') || !chatId) {
|
|
@@ -254,10 +255,15 @@ export class WecomRuntime {
|
|
|
254
255
|
throw error;
|
|
255
256
|
}
|
|
256
257
|
signal?.throwIfAborted();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
// At most three UTF-8 bytes per UTF-16 unit: stay within the existing
|
|
259
|
+
// bridge's 18,000-byte budget while keeping Markdown structures intact.
|
|
260
|
+
const chunks = format === 'markdown' ? chunkMarkdownText(text, 6_000) : [text];
|
|
261
|
+
for (const content of chunks) {
|
|
262
|
+
signal?.throwIfAborted();
|
|
263
|
+
await this.#client.sendMessage(chatId, {
|
|
264
|
+
msgtype: 'markdown', markdown: { content },
|
|
265
|
+
});
|
|
266
|
+
}
|
|
261
267
|
return { sent: true };
|
|
262
268
|
}
|
|
263
269
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createWeixinDiagnostics } from './connection-error.mjs';
|
|
2
|
-
import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError, rejectedProviderResponse } from './weixin-api.mjs';
|
|
2
|
+
import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError, rejectedProviderResponse, splitWeixinText } from './weixin-api.mjs';
|
|
3
|
+
import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
|
|
3
4
|
import {
|
|
4
5
|
createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
|
|
5
6
|
} from './weixin-bridge.mjs';
|
|
@@ -350,7 +351,7 @@ export class WeixinRuntime {
|
|
|
350
351
|
return { sent: true };
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
async #sendTrackedText({ toUserId, text, signal }) {
|
|
354
|
+
async #sendTrackedText({ toUserId, text, signal, fullText = text, chunkIndex = 0, chunkCount = 1 }) {
|
|
354
355
|
const sentAt = Date.now();
|
|
355
356
|
const contextToken = this.#state.contextTokenFor?.(toUserId);
|
|
356
357
|
let result;
|
|
@@ -366,7 +367,7 @@ export class WeixinRuntime {
|
|
|
366
367
|
} catch (cause) {
|
|
367
368
|
if (signal?.aborted) throw cause;
|
|
368
369
|
const error = weixinSendError(cause, {
|
|
369
|
-
baseUrl: this.#config.baseUrl, text, chunk: text, chunkIndex
|
|
370
|
+
baseUrl: this.#config.baseUrl, text: fullText, chunk: text, chunkIndex, chunkCount,
|
|
370
371
|
maxMessageChars: this.#maxMessageChars, contextToken,
|
|
371
372
|
});
|
|
372
373
|
const failure = setLastMessageFailure(this.#status,
|
|
@@ -402,7 +403,7 @@ export class WeixinRuntime {
|
|
|
402
403
|
}, options);
|
|
403
404
|
}
|
|
404
405
|
|
|
405
|
-
async sendProactiveText(target, text, { signal } = {}) {
|
|
406
|
+
async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
|
|
406
407
|
const toUserId = typeof target?.route?.toUserId === 'string'
|
|
407
408
|
? target.route.toUserId.trim() : '';
|
|
408
409
|
if (target?.kind !== 'user' || !toUserId) {
|
|
@@ -415,12 +416,18 @@ export class WeixinRuntime {
|
|
|
415
416
|
error.code = 'bot-not-connected';
|
|
416
417
|
throw error;
|
|
417
418
|
}
|
|
418
|
-
signal
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
text,
|
|
422
|
-
|
|
423
|
-
|
|
419
|
+
const sendSignal = signal ?? this.#abortController.signal;
|
|
420
|
+
sendSignal.throwIfAborted();
|
|
421
|
+
const chunks = format === 'markdown'
|
|
422
|
+
? chunkMarkdownText(text, this.#maxMessageChars)
|
|
423
|
+
: splitWeixinText(text, this.#maxMessageChars);
|
|
424
|
+
for (const [chunkIndex, chunk] of chunks.entries()) {
|
|
425
|
+
sendSignal.throwIfAborted();
|
|
426
|
+
await this.#sendTrackedText({
|
|
427
|
+
toUserId, text: chunk, signal: sendSignal,
|
|
428
|
+
fullText: text, chunkIndex, chunkCount: chunks.length,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
424
431
|
return { sent: true };
|
|
425
432
|
}
|
|
426
433
|
}
|