@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.
Files changed (49) hide show
  1. package/PROACTIVE_DELIVERY.en.md +18 -2
  2. package/PROACTIVE_DELIVERY.md +18 -2
  3. package/README.en.md +2 -0
  4. package/README.md +2 -0
  5. package/assets/logo-plugin-message-link-gradient.webp +0 -0
  6. package/lib/client.js +158 -62
  7. package/lib/index.js +322 -317
  8. package/locale/en.json +1 -1
  9. package/locale/zh.json +1 -1
  10. package/package.json +1 -1
  11. package/plugin-src/client/bot-general-settings.js +79 -42
  12. package/plugin-src/client/channels/feishu/api.js +3 -1
  13. package/plugin-src/client/delivery-settings.js +2 -2
  14. package/plugin-src/client/i18n.js +8 -7
  15. package/plugin-src/client/styles.js +19 -0
  16. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  17. package/plugin-src/host/channels/feishu/rpc.mjs +18 -0
  18. package/plugin-src/host/delivery-adapter.mjs +9 -1
  19. package/plugin-src/host/delivery-service.mjs +4 -3
  20. package/plugin-src/host/session-sync-coordinator.mjs +9 -5
  21. package/scripts/verify-package.mjs +11 -6
  22. package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
  23. package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
  24. package/src/channels/discord/discord-runtime.mjs +13 -2
  25. package/src/channels/feishu/bridge.mjs +30 -11
  26. package/src/channels/feishu/feishu-cards.mjs +8 -7
  27. package/src/channels/feishu/feishu-runtime.mjs +10 -1
  28. package/src/channels/feishu/multi-bot-controller.mjs +18 -0
  29. package/src/channels/feishu/plugin-config-store.mjs +2 -1
  30. package/src/channels/feishu/step-push-mode.mjs +21 -0
  31. package/src/channels/matrix/matrix-crypto.mjs +19 -5
  32. package/src/channels/matrix/matrix-runtime.mjs +16 -6
  33. package/src/channels/qq/markdown-reply.mjs +7 -340
  34. package/src/channels/qq/qq-runtime.mjs +11 -3
  35. package/src/channels/shared/harness-approval.mjs +102 -2
  36. package/src/channels/shared/harness-client.mjs +15 -3
  37. package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
  38. package/src/channels/shared/i18n-en/wecom.mjs +3 -0
  39. package/src/channels/shared/markdown-chunks.mjs +338 -0
  40. package/src/channels/shared/message-failure.mjs +1 -1
  41. package/src/channels/shared/text-harness-bridge.mjs +4 -1
  42. package/src/channels/slack/slack-api.mjs +2 -1
  43. package/src/channels/slack/slack-runtime.mjs +30 -0
  44. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  45. package/src/channels/wecom/wecom-bridge.mjs +45 -10
  46. package/src/channels/wecom/wecom-runtime.mjs +11 -5
  47. package/src/channels/weixin/weixin-runtime.mjs +17 -10
  48. package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
  49. 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(frame) {
158
- const body = bodyOf(frame);
159
- if (body.msgtype === 'image') return [body.image];
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(frame).map((image) => imageSource(client, image)).filter(Boolean),
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 imageCount = inboundMessage.images.length;
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 = inboundMessage;
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(inboundMessage, this.#signal,
1118
+ preparedMessage = prefetchInboundImages(combinedMessage, this.#signal,
1093
1119
  this.#harness.getImageInputLimits?.() ?? imageInputLimits());
1094
1120
  } else {
1095
- preparedMessage = imageQueueFullMessage(inboundMessage);
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
- await this.#client.sendMessage(chatId, {
258
- msgtype: 'markdown',
259
- markdown: { content: text },
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: 0, chunkCount: 1,
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?.throwIfAborted();
419
- await this.#sendTrackedText({
420
- toUserId,
421
- text,
422
- signal: signal ?? this.#abortController.signal,
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
  }