@xmanrui/dsh-im 4.38.0 → 4.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) 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/lib/client.js +49 -5
  6. package/lib/index.js +320 -315
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +1 -1
  10. package/plugin-src/client/connection-error.js +12 -2
  11. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  12. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  13. package/plugin-src/host/channels/matrix/production.mjs +1 -1
  14. package/plugin-src/host/channels/qq/production.mjs +1 -1
  15. package/plugin-src/host/channels/shared/production.mjs +1 -1
  16. package/plugin-src/host/channels/slack/production.mjs +1 -1
  17. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  18. package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
  19. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  20. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  21. package/plugin-src/host/delivery-adapter.mjs +9 -1
  22. package/plugin-src/host/delivery-service.mjs +4 -3
  23. package/plugin-src/host/modern-harness-api.mjs +2 -1
  24. package/plugin-src/host/session-sync-coordinator.mjs +9 -5
  25. package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
  26. package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
  27. package/src/channels/discord/discord-runtime.mjs +13 -2
  28. package/src/channels/feishu/bridge.mjs +95 -24
  29. package/src/channels/matrix/matrix-runtime.mjs +16 -6
  30. package/src/channels/qq/markdown-reply.mjs +7 -340
  31. package/src/channels/qq/qq-runtime.mjs +11 -3
  32. package/src/channels/shared/bot-workspace-store.mjs +3 -3
  33. package/src/channels/shared/connection-error.mjs +29 -4
  34. package/src/channels/shared/diagnostic-details.mjs +6 -2
  35. package/src/channels/shared/harness-client.mjs +138 -19
  36. package/src/channels/shared/harness-mux.mjs +6 -4
  37. package/src/channels/shared/i18n-en/diagnostics.mjs +24 -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 +31 -11
  41. package/src/channels/shared/text-harness-bridge.mjs +4 -1
  42. package/src/channels/shared/workspace-session.mjs +10 -1
  43. package/src/channels/slack/slack-api.mjs +2 -1
  44. package/src/channels/slack/slack-runtime.mjs +30 -0
  45. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  46. package/src/channels/wecom/wecom-bridge.mjs +45 -10
  47. package/src/channels/wecom/wecom-runtime.mjs +11 -5
  48. package/src/channels/weixin/weixin-runtime.mjs +17 -10
  49. package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
  50. package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
@@ -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
  }
@@ -0,0 +1,59 @@
1
+ // A conservative projection of common Markdown into WhatsApp's text dialect.
2
+ // Unsupported structures stay literal; code and link destinations are protected.
3
+ function inline(text) {
4
+ const saved = [];
5
+ let marker = '\u0000';
6
+ while (text.includes(marker)) marker += '\u0000';
7
+ const stash = value => `${marker}${saved.push(value) - 1}${marker}`;
8
+ let value = text.replace(/(`+)([^\n]*?)\1/g, (_match, ticks, code) => (
9
+ // WhatsApp cannot represent embedded backticks in inline code safely.
10
+ stash(code.includes('`') ? `${ticks}${code}${ticks}` : `\`${code}\``)
11
+ ));
12
+ value = value.replace(/\\([\\`*_{}[\]()#+.!~>-])/g, match => stash(match));
13
+ value = value.replace(/!?\[([^\]\n]+)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)(?:\s+"[^"\n]*")?\)/g,
14
+ (_match, label, url) => stash(`${label} (${url})`));
15
+ value = value.replace(/https?:\/\/[^\s<>]+/g, url => stash(url));
16
+ // Leave nested delimiters untouched instead of guessing their meaning.
17
+ value = value.replace(/(?<![*_])(\*\*|__)([^*_\n]+)\1(?![*_])/g,
18
+ (_match, _marker, body) => stash(`*${body}*`));
19
+ value = value.replace(/(?<![\w*_])\*([^*\n]+)\*(?![*_])/g, (_match, body) => `_${body}_`);
20
+ value = value.replace(/(?<!~)~~([^~\n]+)~~(?!~)/g, '~$1~');
21
+ const token = new RegExp(`${marker}(\\d+)${marker}`, 'g');
22
+ // A later protected URL can contain an earlier token; restore both passes.
23
+ for (let pass = 0; pass <= saved.length && value.includes(marker); pass++) {
24
+ value = value.replace(token, (match, index) => saved[Number(index)] ?? match);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ export function markdownToWhatsapp(text) {
30
+ const lines = text.replace(/\r\n?/g, '\n').split('\n');
31
+ const result = [];
32
+ let table = false;
33
+ for (let index = 0; index < lines.length; index++) {
34
+ const line = lines[index];
35
+ const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
36
+ if (open && !(open[1][0] === '`' && open[2].includes('`'))) {
37
+ let end = index + 1;
38
+ for (; end < lines.length; end++) {
39
+ const close = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(lines[end]);
40
+ if (close && close[1][0] === open[1][0] && close[1].length >= open[1].length) break;
41
+ }
42
+ const body = lines.slice(index + 1, end);
43
+ // Embedded triple backticks cannot be represented by WhatsApp's fence.
44
+ // Preserve the entire original block instead of changing code boundaries.
45
+ if (body.some(value => value.includes('```'))) result.push(...lines.slice(index, end + 1));
46
+ else result.push('```', ...body, '```');
47
+ index = end;
48
+ table = false;
49
+ continue;
50
+ }
51
+ if (line.includes('|') && /^\s*\|?\s*:?-+:?\s*\|[\s|:-]*$/.test(lines[index + 1] ?? '')) table = true;
52
+ if (table && line.includes('|')) { result.push(line); continue; }
53
+ table = false;
54
+ const heading = /^ {0,3}#{1,6}\s+(.+?)(?:\s+#+)?\s*$/.exec(line);
55
+ // A heading with its own inline delimiters stays literal rather than nested.
56
+ result.push(heading && !/[*_`]/.test(heading[1]) ? `*${heading[1]}*` : inline(line));
57
+ }
58
+ return result.join('\n');
59
+ }
@@ -14,6 +14,9 @@ import { t } from '../shared/i18n.mjs';
14
14
  import { ImagePromptError } from '../shared/image-prompt.mjs';
15
15
  import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
16
16
  import { createWhatsappBridgeStatus, WhatsappHarnessBridge } from './whatsapp-bridge.mjs';
17
+ import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
18
+ import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
19
+ import { markdownToWhatsapp } from './whatsapp-markdown.mjs';
17
20
  import {
18
21
  WHATSAPP_ACCESS_MODES,
19
22
  normalizeWhatsappAccountJid,
@@ -490,6 +493,25 @@ export class WhatsappBotClient {
490
493
  return { providerMessageIds };
491
494
  }
492
495
 
496
+ async sendDelivery(target, value) {
497
+ const block = createTextDeliveryBlock(value);
498
+ if (block.format === 'plain') return this.sendText(target, block.text);
499
+ let chunks;
500
+ try {
501
+ chunks = chunkMarkdownText(markdownToWhatsapp(block.text), WHATSAPP_TEXT_LIMIT);
502
+ } catch {
503
+ this.#logger.warn?.('[dsh-im:whatsapp] Markdown could not be prepared; sending plain text');
504
+ return this.sendText(target, block.text);
505
+ }
506
+ await this.#stopTyping(target.jid);
507
+ const providerMessageIds = [];
508
+ for (const [index, chunk] of chunks.entries()) {
509
+ const result = await this.#sendTextMessage(target, chunk, { quote: index === 0 });
510
+ providerMessageIds.push(result.key.id);
511
+ }
512
+ return { providerMessageIds };
513
+ }
514
+
493
515
  async #sendTextMessage(target, text, { edit, quote = true } = {}) {
494
516
  this.#signal?.throwIfAborted();
495
517
  const messageId = randomBytes(10).toString('hex').toUpperCase();
@@ -886,7 +908,7 @@ export class WhatsappRuntime {
886
908
  }, options);
887
909
  }
888
910
 
889
- async sendProactiveText(target, text, { signal } = {}) {
911
+ async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
890
912
  const jid = typeof target?.route?.jid === 'string' ? target.route.jid.trim() : '';
891
913
  const validUser = target?.kind === 'user'
892
914
  && /^[^@\s]+@(s\.whatsapp\.net|lid)$/.test(jid);
@@ -902,6 +924,9 @@ export class WhatsappRuntime {
902
924
  throw error;
903
925
  }
904
926
  signal?.throwIfAborted();
927
+ if (format === 'markdown') {
928
+ return this.#client.sendDelivery({ jid }, createTextDeliveryBlock(text, format));
929
+ }
905
930
  return this.#client.sendText({ jid }, text);
906
931
  }
907
932