@xmanrui/dsh-im 4.38.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 (32) 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 +1 -1
  6. package/lib/index.js +321 -316
  7. package/locale/en.json +1 -1
  8. package/locale/zh.json +1 -1
  9. package/package.json +1 -1
  10. package/plugin-src/host/delivery-adapter.mjs +9 -1
  11. package/plugin-src/host/delivery-service.mjs +4 -3
  12. package/plugin-src/host/session-sync-coordinator.mjs +9 -5
  13. package/src/channels/dingtalk/dingtalk-api.mjs +41 -24
  14. package/src/channels/dingtalk/dingtalk-runtime.mjs +3 -2
  15. package/src/channels/discord/discord-runtime.mjs +13 -2
  16. package/src/channels/feishu/bridge.mjs +8 -1
  17. package/src/channels/matrix/matrix-runtime.mjs +16 -6
  18. package/src/channels/qq/markdown-reply.mjs +7 -340
  19. package/src/channels/qq/qq-runtime.mjs +11 -3
  20. package/src/channels/shared/harness-client.mjs +15 -3
  21. package/src/channels/shared/i18n-en/wecom.mjs +3 -0
  22. package/src/channels/shared/markdown-chunks.mjs +338 -0
  23. package/src/channels/shared/message-failure.mjs +1 -1
  24. package/src/channels/shared/text-harness-bridge.mjs +4 -1
  25. package/src/channels/slack/slack-api.mjs +2 -1
  26. package/src/channels/slack/slack-runtime.mjs +30 -0
  27. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  28. package/src/channels/wecom/wecom-bridge.mjs +45 -10
  29. package/src/channels/wecom/wecom-runtime.mjs +11 -5
  30. package/src/channels/weixin/weixin-runtime.mjs +17 -10
  31. package/src/channels/whatsapp/whatsapp-markdown.mjs +59 -0
  32. package/src/channels/whatsapp/whatsapp-runtime.mjs +26 -1
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "IM bots",
3
+ "title": "IM bots (dsh-im)",
4
4
  "description": "Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
- "title": "IM 机器人",
3
+ "title": "IM 机器人 (dsh-im)",
4
4
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.38.0",
3
+ "version": "4.38.1",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -404,7 +404,15 @@ export function createDeliveryAdapter({ channel, workspaces, coreController, sta
404
404
  if (typeof coreController.sendProactiveText !== 'function') {
405
405
  throw new TypeError('delivery controller cannot send proactive text');
406
406
  }
407
- await coreController.sendProactiveText(botId, target, text, options);
407
+ const result = await coreController.sendProactiveText(botId, target, text, options);
408
+ // Rich senders may report failure without throwing. Never report these
409
+ // as success, or retry an uncertain send that may already be visible.
410
+ if (result === false || result?.sent === false
411
+ || result?.deliveryOutcome === 'failed' || result?.deliveryOutcome === 'unknown') {
412
+ const error = new Error('Session sync delivery was not confirmed');
413
+ error.code = result?.deliveryOutcome === 'unknown' ? 'send-result-unknown' : 'delivery-failed';
414
+ throw error;
415
+ }
408
416
  return { sent: true };
409
417
  },
410
418
  });
@@ -341,11 +341,12 @@ export class DeliveryService {
341
341
  return false;
342
342
  }
343
343
 
344
- async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
344
+ async sendSessionSyncText(botId, targetId, sessionId, text, { signal, format = 'plain' } = {}) {
345
345
  const id = botIdOf(botId);
346
346
  const targetKey = targetIdOf(targetId);
347
347
  if (typeof sessionId !== 'string' || !sessionId
348
- || typeof text !== 'string' || !text.trim()) {
348
+ || typeof text !== 'string' || !text.trim()
349
+ || (format !== 'plain' && format !== 'markdown')) {
349
350
  throw deliveryError('bad-request', 'Invalid session sync delivery');
350
351
  }
351
352
  cancellation(signal);
@@ -354,7 +355,7 @@ export class DeliveryService {
354
355
  throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
355
356
  }
356
357
  try {
357
- await adapter.sendSessionSyncText(id, targetKey, sessionId, text, { signal });
358
+ await adapter.sendSessionSyncText(id, targetKey, sessionId, text, { signal, format });
358
359
  return { sent: true };
359
360
  } catch (error) {
360
361
  if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
@@ -6,7 +6,7 @@ import {
6
6
  import { deliverSessionSyncMirror } from '../../src/channels/shared/session-sync-registry.mjs';
7
7
 
8
8
  const DSH_USER_PREFIX = '[来自 DSH]\n';
9
- const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n';
9
+ const DSH_ASSISTANT_PREFIX = '[DSH 助手]\n\n';
10
10
 
11
11
  function completedTurn(reason) {
12
12
  return (typeof reason === 'string' ? reason : reason?.kind) === 'completed';
@@ -55,7 +55,7 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
55
55
  );
56
56
  };
57
57
 
58
- const deliver = async (sessionId, targets, text, phase) => {
58
+ const deliver = async (sessionId, targets, text, phase, format = 'plain') => {
59
59
  const unique = new Map();
60
60
  for (const target of targets) {
61
61
  if (validRecipient(target)) unique.set(recipientKey(target), target);
@@ -67,6 +67,9 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
67
67
  target.targetId,
68
68
  sessionId,
69
69
  text,
70
+ // Feishu has already tried its dedicated mirror card. Preserve its
71
+ // existing plain fallback instead of attempting another rich card.
72
+ { format: target.channel === 'feishu' ? 'plain' : format },
70
73
  )
71
74
  )));
72
75
  const successful = new Map();
@@ -165,13 +168,14 @@ export function createSessionSyncCoordinator({ deliveryService, logger = console
165
168
  return false;
166
169
  }
167
170
  }));
168
- const plain = recipients.filter((_target, index) => !rendered[index]);
169
- if (plain.length === 0) return;
171
+ const fallback = recipients.filter((_target, index) => !rendered[index]);
172
+ if (fallback.length === 0) return;
170
173
  await deliver(
171
174
  sessionId,
172
- plain,
175
+ fallback,
173
176
  `${DSH_ASSISTANT_PREFIX}${state.assistant.text}`,
174
177
  'assistant delivery',
178
+ 'markdown',
175
179
  );
176
180
  };
177
181
 
@@ -3,6 +3,7 @@ import { extname } from 'node:path';
3
3
 
4
4
  import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
5
5
  import { t } from '../shared/i18n.mjs';
6
+ import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
6
7
  import { DINGTALK_MENU_TEMPLATE_ID, dingtalkMenuCardData } from './dingtalk-menu.mjs';
7
8
 
8
9
  export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
@@ -402,6 +403,7 @@ function positiveNumber(value, fallback) {
402
403
  }
403
404
 
404
405
  export function createDingtalkApi({
406
+ logger = console,
405
407
  fetchImpl = fetch,
406
408
  registrationBaseUrl = process.env.DINGTALK_REGISTRATION_BASE_URL
407
409
  || DINGTALK_REGISTRATION_BASE_URL,
@@ -1075,37 +1077,52 @@ export function createDingtalkApi({
1075
1077
  return true;
1076
1078
  },
1077
1079
 
1078
- async sendRobotText({ clientId, clientSecret, target, text, signal }) {
1080
+ async sendRobotText({ clientId, clientSecret, target, text, signal, format = 'plain' }) {
1079
1081
  if (typeof text !== 'string' || !text.trim()) throw new TypeError('text is required');
1080
- const content = text;
1081
1082
  const normalizedTarget = normalizeFileTarget(target);
1082
1083
  const token = await accessToken({ clientId, clientSecret, signal });
1083
- const body = {
1084
- robotCode: normalizedTarget.robotCode,
1085
- msgKey: 'sampleText',
1086
- msgParam: JSON.stringify({ content }),
1087
- ...(normalizedTarget.type === 'group'
1088
- ? { openConversationId: normalizedTarget.openConversationId }
1089
- : { userIds: [normalizedTarget.userId] }),
1090
- };
1091
1084
  const pathname = normalizedTarget.type === 'group'
1092
1085
  ? 'v1.0/robot/groupMessages/send'
1093
1086
  : 'v1.0/robot/oToMessages/batchSend';
1094
- const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
1095
- body,
1096
- headers: { 'x-acs-dingtalk-access-token': token },
1097
- signal,
1098
- action: '主动文字消息发送',
1099
- });
1100
- const rejection = rejectedProviderResponse(response);
1101
- if (rejection) {
1102
- throw new DingtalkApiError(
1103
- 'send-rejected',
1104
- '钉钉服务拒绝了主动文字消息。',
1105
- { providerCode: rejection },
1106
- );
1087
+ const send = async (content, markdown) => {
1088
+ const response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
1089
+ body: {
1090
+ robotCode: normalizedTarget.robotCode,
1091
+ msgKey: markdown ? 'sampleMarkdown' : 'sampleText',
1092
+ msgParam: JSON.stringify(markdown ? { title: 'DSH', text: content } : { content }),
1093
+ ...(normalizedTarget.type === 'group'
1094
+ ? { openConversationId: normalizedTarget.openConversationId }
1095
+ : { userIds: [normalizedTarget.userId] }),
1096
+ },
1097
+ headers: { 'x-acs-dingtalk-access-token': token },
1098
+ signal,
1099
+ action: '主动文字消息发送',
1100
+ });
1101
+ const rejection = rejectedProviderResponse(response);
1102
+ if (rejection) {
1103
+ throw new DingtalkApiError('send-rejected', '钉钉服务拒绝了主动文字消息。', {
1104
+ providerCode: rejection,
1105
+ });
1106
+ }
1107
+ return response;
1108
+ };
1109
+ if (format !== 'markdown') return send(text, false);
1110
+ // Conservative Unicode payload size; reuse QQ's fence/table-aware split.
1111
+ const chunks = chunkMarkdownParts(text, 1_000);
1112
+ let result;
1113
+ for (const chunk of chunks) {
1114
+ try {
1115
+ result = await send(chunk.markdown, true);
1116
+ } catch (error) {
1117
+ // A missing message model is a definite format rejection. Do not
1118
+ // retry auth/target failures, rate limits or ambiguous transport errors.
1119
+ if (signal?.aborted || error?.providerCode !== 'sendMessage.model.notMatch'
1120
+ || !(error.code === 'send-rejected' || error.status === 400)) throw error;
1121
+ logger.warn?.('[dsh-im:dingtalk] Markdown rejected; sending this chunk as text');
1122
+ if (chunk.plain) result = await send(chunk.plain, false);
1123
+ }
1107
1124
  }
1108
- return response;
1125
+ return result;
1109
1126
  },
1110
1127
 
1111
1128
  async sendFile(request) {
@@ -163,7 +163,7 @@ export class DingtalkRuntime {
163
163
  maxMessageChars = 4_000,
164
164
  connectTimeoutMs = 15_000,
165
165
  connectPollIntervalMs = 25,
166
- api = createDingtalkApi(),
166
+ api = createDingtalkApi({ logger }),
167
167
  streamFactory = defaultStreamFactory,
168
168
  }) {
169
169
  if (!config || !nonEmptyString(config.clientId) || !nonEmptyString(clientSecret)) {
@@ -443,7 +443,7 @@ export class DingtalkRuntime {
443
443
  }, options);
444
444
  }
445
445
 
446
- async sendProactiveText(target, text, { signal } = {}) {
446
+ async sendProactiveText(target, text, { signal, format = 'plain' } = {}) {
447
447
  const userId = typeof target?.route?.userId === 'string'
448
448
  ? target.route.userId.trim() : '';
449
449
  const openConversationId = typeof target?.route?.openConversationId === 'string'
@@ -471,6 +471,7 @@ export class DingtalkRuntime {
471
471
  ...(target.kind === 'user' ? { userId } : { openConversationId }),
472
472
  },
473
473
  text,
474
+ format,
474
475
  signal: signal ?? this.#abortController.signal,
475
476
  });
476
477
  } catch (cause) {
@@ -1,5 +1,7 @@
1
1
  import { extractConnectionEvidence, createConnectionDiagnostics, atConnectionStage } from '../shared/connection-error.mjs';
2
2
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
3
+ import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
4
+ import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
3
5
  import { fetchFileStream } from '../shared/file-download.mjs';
4
6
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
5
7
  import { t } from '../shared/i18n.mjs';
@@ -428,10 +430,19 @@ export class DiscordBotClient {
428
430
  this.#signal = signal;
429
431
  }
430
432
 
431
- async sendText(target, text) {
433
+ sendText(target, text) {
434
+ return this.#sendText(target, text, false);
435
+ }
436
+
437
+ sendDelivery(target, value) {
438
+ const block = createTextDeliveryBlock(value);
439
+ return this.#sendText(target, block.text, block.format === 'markdown');
440
+ }
441
+
442
+ async #sendText(target, text, markdown) {
432
443
  const notice = !this.#deliveredNotices.has(target) && target?.notice
433
444
  ? String(target.notice) : null;
434
- const chunks = splitMessageText(notice ? `${notice}\n\n${text}` : text, 1_900);
445
+ const chunks = (markdown ? chunkMarkdownText : splitMessageText)(notice ? `${notice}\n\n${text}` : text, 1_900);
435
446
  const providerMessageIds = [];
436
447
  for (const [index, chunk] of chunks.entries()) {
437
448
  const result = await this.#api.createMessage({
@@ -1,6 +1,7 @@
1
1
  import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
2
2
  import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
+ import { chunkMarkdownText } from '../shared/markdown-chunks.mjs';
4
5
  import QRCode from 'qrcode';
5
6
  import { waitForFeishuOperation } from './feishu-channel.mjs';
6
7
  import {
@@ -4790,7 +4791,13 @@ export class FeishuHarnessBridge {
4790
4791
 
4791
4792
  /** Replace the draft interval with `text` chunked into splitter-safe blocks. */
4792
4793
  #writeStepCardAnswer(card, text) {
4793
- const blocks = splitStepPostMarkdown(text, STEP_STREAM_ANSWER_CHUNK_MAX_BYTES)
4794
+ // JSON can expand one UTF-16 unit to six bytes (for example a control
4795
+ // character). Leave room for the element wrapper, and let the existing
4796
+ // Markdown splitter reopen fences in each independently rendered block.
4797
+ const chunks = Buffer.byteLength(JSON.stringify(text), 'utf8') + 128 <= STEP_STREAM_ANSWER_CHUNK_MAX_BYTES
4798
+ ? [text]
4799
+ : chunkMarkdownText(text, Math.floor((STEP_STREAM_ANSWER_CHUNK_MAX_BYTES - 128) / 6));
4800
+ const blocks = chunks
4794
4801
  .map((chunk) => ({ kind: 'message', text: chunk }));
4795
4802
  if (card.answerStart !== null) {
4796
4803
  card.blocks.splice(card.answerStart, card.answerEnd - card.answerStart + 1, ...blocks);
@@ -36,6 +36,7 @@ import {
36
36
  extractOutboundMentions,
37
37
  hasRoomMention,
38
38
  } from './matrix-rich-text.mjs';
39
+ import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
39
40
 
40
41
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
41
42
  const SYNC_LONG_POLL_MS = 30_000;
@@ -917,14 +918,23 @@ export class MatrixRuntime {
917
918
  ? target.threadId : null;
918
919
  const replyToEventId = typeof target?.replyToEventId === 'string' && isMatrixEventId(target.replyToEventId)
919
920
  ? target.replyToEventId : null;
920
- const chunks = splitMessageText(text, this.#config.maxMessageLength);
921
+ const chunks = options.format === 'markdown'
922
+ ? chunkMarkdownParts(text, this.#config.maxMessageLength)
923
+ : splitMessageText(text, this.#config.maxMessageLength).map(plain => ({ markdown: plain, plain }));
921
924
  const providerMessageIds = [];
922
925
  for (const chunk of chunks) {
923
- const content = buildMatrixTextContent({
924
- text: chunk,
925
- mentionUserIds: extractOutboundMentions(chunk),
926
- roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk),
927
- });
926
+ let content;
927
+ try {
928
+ content = buildMatrixTextContent({
929
+ text: chunk.markdown,
930
+ mentionUserIds: extractOutboundMentions(chunk.markdown),
931
+ roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk.markdown),
932
+ });
933
+ } catch {
934
+ this.#logger.warn?.('[dsh-im:matrix] Rich text could not be prepared; sending plain text');
935
+ if (!chunk.plain) continue;
936
+ content = { msgtype: 'm.text', body: chunk.plain };
937
+ }
928
938
  applyMatrixRelations(content, { threadId, replyToEventId });
929
939
  const result = await this.#sendRoomEvent(roomId, 'm.room.message', content, {
930
940
  ...(options.signal ? { signal: options.signal } : {}),