@xmanrui/dsh-im 1.4.0 → 2.0.0

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 (82) hide show
  1. package/README.en.md +18 -6
  2. package/README.md +18 -6
  3. package/lib/index.js +206 -176
  4. package/package.json +1 -1
  5. package/plugin-src/host/index.mjs +2 -0
  6. package/src/channels/dingtalk/config-store.mjs +5 -3
  7. package/src/channels/dingtalk/dingtalk-api.mjs +105 -82
  8. package/src/channels/dingtalk/dingtalk-bridge.mjs +79 -110
  9. package/src/channels/dingtalk/dingtalk-card-stream.mjs +3 -1
  10. package/src/channels/dingtalk/dingtalk-controller.mjs +24 -23
  11. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -3
  12. package/src/channels/dingtalk/state-store.mjs +3 -1
  13. package/src/channels/discord/config-store.mjs +2 -1
  14. package/src/channels/discord/discord-api.mjs +25 -1
  15. package/src/channels/discord/discord-runtime.mjs +237 -11
  16. package/src/channels/feishu/bridge.mjs +169 -180
  17. package/src/channels/feishu/feishu-cards.mjs +62 -54
  18. package/src/channels/feishu/feishu-channel.mjs +57 -21
  19. package/src/channels/feishu/feishu-runtime.mjs +10 -9
  20. package/src/channels/feishu/message-utils.mjs +4 -3
  21. package/src/channels/office/office-job-executor.mjs +15 -14
  22. package/src/channels/office/office-runtime.mjs +6 -5
  23. package/src/channels/qq/config-store.mjs +3 -1
  24. package/src/channels/qq/markdown-reply.mjs +4 -2
  25. package/src/channels/qq/qq-bridge.mjs +149 -130
  26. package/src/channels/qq/qq-controller.mjs +17 -16
  27. package/src/channels/qq/qq-runtime.mjs +3 -2
  28. package/src/channels/shared/agent-preset.mjs +3 -1
  29. package/src/channels/shared/compact-command.mjs +15 -14
  30. package/src/channels/shared/connection-test.mjs +6 -4
  31. package/src/channels/shared/control-command.mjs +13 -11
  32. package/src/channels/shared/editable-message-stream.mjs +4 -2
  33. package/src/channels/shared/harness-approval.mjs +23 -21
  34. package/src/channels/shared/harness-client.mjs +3 -2
  35. package/src/channels/shared/harness-question.mjs +8 -6
  36. package/src/channels/shared/i18n-en/dingtalk.mjs +52 -0
  37. package/src/channels/shared/i18n-en/discord.mjs +8 -0
  38. package/src/channels/shared/i18n-en/feishu.mjs +217 -0
  39. package/src/channels/shared/i18n-en/office.mjs +23 -0
  40. package/src/channels/shared/i18n-en/qq.mjs +54 -0
  41. package/src/channels/shared/i18n-en/shared-a.mjs +91 -0
  42. package/src/channels/shared/i18n-en/shared-b.mjs +215 -0
  43. package/src/channels/shared/i18n-en/shared-c.mjs +99 -0
  44. package/src/channels/shared/i18n-en/slack.mjs +27 -0
  45. package/src/channels/shared/i18n-en/telegram.mjs +21 -0
  46. package/src/channels/shared/i18n-en/wecom.mjs +38 -0
  47. package/src/channels/shared/i18n-en/weixin.mjs +80 -0
  48. package/src/channels/shared/i18n-en/whatsapp.mjs +17 -0
  49. package/src/channels/shared/i18n-en.mjs +35 -0
  50. package/src/channels/shared/i18n.mjs +35 -0
  51. package/src/channels/shared/image-prompt.mjs +17 -15
  52. package/src/channels/shared/inbound-file.mjs +5 -3
  53. package/src/channels/shared/model-command.mjs +35 -31
  54. package/src/channels/shared/preset-command.mjs +41 -38
  55. package/src/channels/shared/semantic/artifact-delivery.mjs +170 -0
  56. package/src/channels/shared/semantic/artifact.mjs +2 -2
  57. package/src/channels/shared/semantic/delivery.mjs +40 -0
  58. package/src/channels/shared/text-harness-bridge.mjs +154 -136
  59. package/src/channels/shared/token-bot-controller.mjs +32 -11
  60. package/src/channels/shared/workspace-command.mjs +69 -58
  61. package/src/channels/slack/config-store.mjs +3 -1
  62. package/src/channels/slack/slack-api.mjs +6 -5
  63. package/src/channels/slack/slack-controller.mjs +10 -9
  64. package/src/channels/slack/slack-runtime.mjs +16 -4
  65. package/src/channels/telegram/config-store.mjs +2 -1
  66. package/src/channels/telegram/telegram-api.mjs +111 -19
  67. package/src/channels/telegram/telegram-rich-message.mjs +147 -0
  68. package/src/channels/telegram/telegram-runtime.mjs +383 -5
  69. package/src/channels/wecom/config-store.mjs +3 -1
  70. package/src/channels/wecom/wecom-bridge.mjs +154 -153
  71. package/src/channels/wecom/wecom-controller.mjs +20 -19
  72. package/src/channels/wecom/wecom-runtime.mjs +2 -1
  73. package/src/channels/weixin/config-store.mjs +2 -1
  74. package/src/channels/weixin/weixin-api.mjs +140 -107
  75. package/src/channels/weixin/weixin-bridge.mjs +86 -123
  76. package/src/channels/weixin/weixin-controller.mjs +22 -21
  77. package/src/channels/weixin/weixin-runtime.mjs +3 -2
  78. package/src/channels/whatsapp/config-store.mjs +3 -1
  79. package/src/channels/whatsapp/whatsapp-bridge.mjs +3 -1
  80. package/src/channels/whatsapp/whatsapp-controller.mjs +15 -14
  81. package/src/channels/whatsapp/whatsapp-runtime.mjs +47 -13
  82. package/src/channels/whatsapp/whatsapp-web-session.mjs +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "1.4.0",
3
+ "version": "2.0.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -9,6 +9,7 @@ import { apply as applyWecom } from './channels/wecom/index.mjs';
9
9
  import { apply as applyWeixin } from './channels/weixin/index.mjs';
10
10
  import { apply as applyWhatsapp } from './channels/whatsapp/index.mjs';
11
11
  import { installOutboundArtifactTool } from '../../src/channels/shared/semantic/artifact.mjs';
12
+ import { setImHostLanguage } from '../../src/channels/shared/i18n.mjs';
12
13
 
13
14
  export const name = 'dsh-im-host';
14
15
  export const inject = [
@@ -40,6 +41,7 @@ export function createImHostPlugin(internals = {}) {
40
41
  name,
41
42
  inject,
42
43
  async apply(ctx, config = {}) {
44
+ setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
43
45
  if (typeof ctx?.inject === 'function') {
44
46
  ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
45
47
  installOutboundArtifactTool(artifactCtx);
@@ -2,6 +2,8 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
 
5
+ import { t } from '../shared/i18n.mjs';
6
+
5
7
  const EMPTY_DOCUMENT = Object.freeze({ version: 1, bots: Object.freeze([]) });
6
8
  const STORED_BOT_KEYS = new Set(['clientId', 'secretRef', 'approvedSenders']);
7
9
 
@@ -86,8 +88,8 @@ export function deriveDingtalkSenderKey() {
86
88
  */
87
89
  export function maskDingtalkSenderId(staffId) {
88
90
  const value = cleanString(staffId);
89
- if (!value) return '钉钉用户';
90
- return '身份已隐藏';
91
+ if (!value) return t('钉钉用户');
92
+ return t('身份已隐藏');
91
93
  }
92
94
 
93
95
  /**
@@ -97,7 +99,7 @@ export function maskDingtalkSenderId(staffId) {
97
99
  */
98
100
  export function maskDingtalkClientId(clientId) {
99
101
  const value = cleanString(clientId);
100
- if (!value) return '钉钉机器人';
102
+ if (!value) return t('钉钉机器人');
101
103
  if (value.length <= 8) return `${value.slice(0, 2)}••••`;
102
104
  return `${value.slice(0, 4)}••••${value.slice(-4)}`;
103
105
  }
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
  import { extname } from 'node:path';
3
3
 
4
4
  import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
5
+ import { t } from '../shared/i18n.mjs';
5
6
 
6
7
  export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
7
8
  export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
@@ -540,6 +541,93 @@ export function createDingtalkApi({
540
541
  return true;
541
542
  }
542
543
 
544
+ async function sendArtifact({
545
+ clientId,
546
+ clientSecret,
547
+ target,
548
+ file,
549
+ signal,
550
+ }, { uploadType, messageKey, createMessageParams }) {
551
+ if (!file || typeof file !== 'object'
552
+ || typeof file.fileName !== 'string' || !file.fileName
553
+ || !Buffer.isBuffer(file.bytes)) {
554
+ throw new TypeError('A DingTalk file is required');
555
+ }
556
+ const normalizedTarget = normalizeFileTarget(target);
557
+ let token;
558
+ try {
559
+ token = await accessToken({ clientId, clientSecret, signal });
560
+ } catch (error) {
561
+ if (signal?.aborted) throw abortError(signal);
562
+ const status = Number(error?.status);
563
+ const fallback = error?.code === 'http-error' && status >= 400 && status < 500
564
+ ? 'artifact-provider-rejected'
565
+ : 'artifact-provider-failed';
566
+ throw dingtalkArtifactError(error, { fallback });
567
+ }
568
+ const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
569
+ uploadUrl.searchParams.set('access_token', token);
570
+ uploadUrl.searchParams.set('type', uploadType);
571
+ const form = new FormData();
572
+ form.append(
573
+ 'media',
574
+ new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
575
+ file.fileName,
576
+ );
577
+ let uploaded;
578
+ try {
579
+ uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
580
+ } catch (error) {
581
+ if (signal?.aborted) throw abortError(signal);
582
+ const status = Number(error?.status);
583
+ const fallback = error?.code === 'http-error' && status >= 400 && status < 500
584
+ ? 'artifact-provider-rejected'
585
+ : 'artifact-provider-failed';
586
+ throw dingtalkArtifactError(error, { fallback });
587
+ }
588
+ const uploadRejection = rejectedProviderResponse(uploaded);
589
+ const mediaId = nonEmptyString(uploaded?.media_id);
590
+ if (uploadRejection || !mediaId) {
591
+ throw dingtalkArtifactError(new DingtalkApiError(
592
+ 'upload-rejected',
593
+ '钉钉服务拒绝了文件上传。',
594
+ { providerCode: uploadRejection ?? 'missing-media-id' },
595
+ ));
596
+ }
597
+ signal?.throwIfAborted();
598
+ const messageBody = {
599
+ robotCode: normalizedTarget.robotCode,
600
+ msgKey: messageKey,
601
+ msgParam: JSON.stringify(createMessageParams(mediaId, file)),
602
+ ...(normalizedTarget.type === 'group'
603
+ ? { openConversationId: normalizedTarget.openConversationId }
604
+ : { userIds: [normalizedTarget.userId] }),
605
+ };
606
+ const pathname = normalizedTarget.type === 'group'
607
+ ? 'v1.0/robot/groupMessages/send'
608
+ : 'v1.0/robot/oToMessages/batchSend';
609
+ let response;
610
+ try {
611
+ response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
612
+ body: messageBody,
613
+ headers: { 'x-acs-dingtalk-access-token': token },
614
+ signal,
615
+ action: '文件消息发送',
616
+ });
617
+ } catch (error) {
618
+ throw classifyDingtalkFinalDeliveryError(error, signal);
619
+ }
620
+ const sendRejection = rejectedProviderResponse(response);
621
+ if (sendRejection) {
622
+ throw dingtalkArtifactError(new DingtalkApiError(
623
+ 'send-rejected',
624
+ '钉钉服务拒绝了文件消息。',
625
+ { providerCode: sendRejection },
626
+ ));
627
+ }
628
+ return response;
629
+ }
630
+
543
631
  return Object.freeze({
544
632
  async beginRegistration({ signal } = {}) {
545
633
  const initialized = assertRegistrationOk(await requestJson(
@@ -742,7 +830,7 @@ export function createDingtalkApi({
742
830
  clientId: appKey,
743
831
  clientSecret: appSecret,
744
832
  cardInstanceId,
745
- text: '消息处理失败,请稍后重试。',
833
+ text: t('消息处理失败,请稍后重试。'),
746
834
  signal: cleanupSignal,
747
835
  }).catch(() => undefined);
748
836
  }
@@ -842,89 +930,24 @@ export function createDingtalkApi({
842
930
  return true;
843
931
  },
844
932
 
845
- async sendFile({ clientId, clientSecret, target, file, signal }) {
846
- if (!file || typeof file !== 'object'
847
- || typeof file.fileName !== 'string' || !file.fileName
848
- || !Buffer.isBuffer(file.bytes)) {
849
- throw new TypeError('A DingTalk file is required');
850
- }
851
- const normalizedTarget = normalizeFileTarget(target);
852
- const fileType = dingtalkFileType(file.fileName);
853
- let token;
854
- try {
855
- token = await accessToken({ clientId, clientSecret, signal });
856
- } catch (error) {
857
- if (signal?.aborted) throw abortError(signal);
858
- const status = Number(error?.status);
859
- const fallback = error?.code === 'http-error' && status >= 400 && status < 500
860
- ? 'artifact-provider-rejected'
861
- : 'artifact-provider-failed';
862
- throw dingtalkArtifactError(error, { fallback });
863
- }
864
- const uploadUrl = new URL('media/upload', DINGTALK_REGISTRATION_BASE_URL);
865
- uploadUrl.searchParams.set('access_token', token);
866
- uploadUrl.searchParams.set('type', 'file');
867
- const form = new FormData();
868
- form.append(
869
- 'media',
870
- new Blob([file.bytes], { type: file.mediaType ?? 'application/octet-stream' }),
871
- file.fileName,
872
- );
873
- let uploaded;
874
- try {
875
- uploaded = await requestMultipart(fetchImpl, uploadUrl, { body: form, signal });
876
- } catch (error) {
877
- if (signal?.aborted) throw abortError(signal);
878
- const status = Number(error?.status);
879
- const fallback = error?.code === 'http-error' && status >= 400 && status < 500
880
- ? 'artifact-provider-rejected'
881
- : 'artifact-provider-failed';
882
- throw dingtalkArtifactError(error, { fallback });
883
- }
884
- const uploadRejection = rejectedProviderResponse(uploaded);
885
- if (uploadRejection || !nonEmptyString(uploaded?.media_id)) {
886
- throw dingtalkArtifactError(new DingtalkApiError(
887
- 'upload-rejected',
888
- '钉钉服务拒绝了文件上传。',
889
- { providerCode: uploadRejection ?? 'missing-media-id' },
890
- ));
891
- }
892
- signal?.throwIfAborted();
893
- const messageBody = {
894
- robotCode: normalizedTarget.robotCode,
895
- msgKey: 'sampleFile',
896
- msgParam: JSON.stringify({
897
- mediaId: uploaded.media_id,
933
+ async sendFile(request) {
934
+ return sendArtifact(request, {
935
+ uploadType: 'file',
936
+ messageKey: 'sampleFile',
937
+ createMessageParams: (mediaId, file) => ({
938
+ mediaId,
898
939
  fileName: file.fileName,
899
- fileType,
940
+ fileType: dingtalkFileType(file.fileName),
900
941
  }),
901
- ...(normalizedTarget.type === 'group'
902
- ? { openConversationId: normalizedTarget.openConversationId }
903
- : { userIds: [normalizedTarget.userId] }),
904
- };
905
- const pathname = normalizedTarget.type === 'group'
906
- ? 'v1.0/robot/groupMessages/send'
907
- : 'v1.0/robot/oToMessages/batchSend';
908
- let response;
909
- try {
910
- response = await requestJson(fetchImpl, endpoint(apiBase, pathname), {
911
- body: messageBody,
912
- headers: { 'x-acs-dingtalk-access-token': token },
913
- signal,
914
- action: '文件消息发送',
915
- });
916
- } catch (error) {
917
- throw classifyDingtalkFinalDeliveryError(error, signal);
918
- }
919
- const sendRejection = rejectedProviderResponse(response);
920
- if (sendRejection) {
921
- throw dingtalkArtifactError(new DingtalkApiError(
922
- 'send-rejected',
923
- '钉钉服务拒绝了文件消息。',
924
- { providerCode: sendRejection },
925
- ));
926
- }
927
- return response;
942
+ });
943
+ },
944
+
945
+ async sendImage(request) {
946
+ return sendArtifact(request, {
947
+ uploadType: 'image',
948
+ messageKey: 'sampleImageMsg',
949
+ createMessageParams: (mediaId) => ({ photoURL: mediaId }),
950
+ });
928
951
  },
929
952
 
930
953
  clearAccessToken(clientId) {
@@ -35,22 +35,18 @@ import {
35
35
  prefetchInboundFiles,
36
36
  } from '../shared/inbound-file.mjs';
37
37
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
38
+ import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
38
39
  import {
39
- materializeOutboundArtifact,
40
- releaseOutboundArtifact,
41
- } from '../shared/semantic/artifact.mjs';
42
- import {
43
- createArtifactFailureReceipt,
44
40
  createDeliveryReceipt,
45
- mergeDeliveryReceipts,
46
41
  providerMessageIdsFor,
47
42
  } from '../shared/semantic/delivery.mjs';
43
+ import { t } from '../shared/i18n.mjs';
48
44
 
49
45
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
50
46
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
51
47
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
52
48
 
53
- const HELP_TEXT = [
49
+ const HELP_TEXT_LINES = [
54
50
  '钉钉机器人已连接 DeepSeek Harness。',
55
51
  '',
56
52
  '直接发送文字、图片或文件即可继续当前会话。',
@@ -71,7 +67,11 @@ const HELP_TEXT = [
71
67
  '/steer 补充指令 纠偏当前任务',
72
68
  '/status 检查连接状态',
73
69
  '/help 显示本帮助',
74
- ].join('\n');
70
+ ];
71
+
72
+ function helpText() {
73
+ return HELP_TEXT_LINES.map((line) => t(line)).join('\n');
74
+ }
75
75
 
76
76
  function nonEmptyString(value) {
77
77
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -111,13 +111,13 @@ function dingtalkImageErrorUserMessage(error) {
111
111
  while (current && typeof current === 'object' && !seen.has(current)) {
112
112
  seen.add(current);
113
113
  if (current.code === 'image-download-address-failed') {
114
- return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
114
+ return t('钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。');
115
115
  }
116
116
  if (current.code === 'invalid-image-download') {
117
- return '钉钉没有返回图片下载地址,请重新发送。';
117
+ return t('钉钉没有返回图片下载地址,请重新发送。');
118
118
  }
119
119
  if (current.code === 'image-content-download-failed') {
120
- return '钉钉返回的图片临时地址无法读取,请重新发送。';
120
+ return t('钉钉返回的图片临时地址无法读取,请重新发送。');
121
121
  }
122
122
  current = current.cause;
123
123
  }
@@ -246,34 +246,34 @@ function fileTarget(message, sender, clientId) {
246
246
  }
247
247
 
248
248
  function artifactFailureText(fileName, error) {
249
- const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
249
+ const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
250
250
  switch (error?.code) {
251
251
  case 'artifact-delivery-uncertain':
252
- return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
252
+ return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
253
253
  case 'artifact-permission-required':
254
- return `结果文件「${name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
254
+ return t('结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。', { name });
255
255
  case 'artifact-too-large':
256
- return `结果文件「${name}」超过当前钉钉机器人可发送的文件大小,未发送。`;
256
+ return t('结果文件「{name}」超过当前钉钉机器人可发送的文件大小,未发送。', { name });
257
257
  case 'artifact-rate-limited':
258
- return `结果文件「${name}」暂时被钉钉限流,未能发送,请稍后重试。`;
258
+ return t('结果文件「{name}」暂时被钉钉限流,未能发送,请稍后重试。', { name });
259
259
  case 'artifact-provider-rejected':
260
- return `结果文件「${name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。`;
260
+ return t('结果文件「{name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。', { name });
261
261
  case 'artifact-invalid':
262
262
  case 'artifact-changed':
263
263
  case 'artifact-unavailable':
264
- return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
264
+ return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
265
265
  default:
266
- return `结果文件「${name}」已生成,但暂时未能通过钉钉发送,请稍后重试。`;
266
+ return t('结果文件「{name}」已生成,但暂时未能通过钉钉发送,请稍后重试。', { name });
267
267
  }
268
268
  }
269
269
 
270
270
  function progressText(update) {
271
271
  if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
272
272
  if (update?.type === 'tool') {
273
- if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
274
- return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`;
273
+ if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
274
+ return t('_正在使用 {name}…_', { name: nonEmptyString(update.name) ?? t('工具') });
275
275
  }
276
- return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`;
276
+ return t('_{text}_', { text: nonEmptyString(update?.text) ?? t('正在处理…') });
277
277
  }
278
278
 
279
279
  function canClaimInteractionReply(message, pending, sender) {
@@ -431,9 +431,9 @@ export class DingtalkHarnessBridge {
431
431
  commandRunner,
432
432
  ).catch((error) => {
433
433
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
434
- this.#status.lastError = '钉钉命令处理失败。';
434
+ this.#status.lastError = t('钉钉命令处理失败。');
435
435
  this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
436
- return this.#send(sessionWebhook, CARD_ERROR_TEXT).catch(() => undefined);
436
+ return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
437
437
  }).finally(() => {
438
438
  this.#acceptedMessageIds.delete(messageId);
439
439
  this.#commandTasks.delete(task);
@@ -468,13 +468,13 @@ export class DingtalkHarnessBridge {
468
468
  if (!sessionWebhook) {
469
469
  increment(this.#status, 'messagesRejected');
470
470
  this.#status.lastRejectedAt = new Date().toISOString();
471
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
471
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
472
472
  }
473
473
  return true;
474
474
  })
475
475
  .catch((error) => {
476
476
  if (this.#signal?.aborted) return;
477
- this.#status.lastError = '钉钉审批处理失败。';
477
+ this.#status.lastError = t('钉钉审批处理失败。');
478
478
  this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
479
479
  })
480
480
  .finally(() => {
@@ -616,7 +616,7 @@ export class DingtalkHarnessBridge {
616
616
  } catch {
617
617
  increment(this.#status, 'messagesRejected');
618
618
  this.#status.lastRejectedAt = new Date().toISOString();
619
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
619
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
620
620
  return;
621
621
  }
622
622
 
@@ -633,23 +633,23 @@ export class DingtalkHarnessBridge {
633
633
  let cardStarted = false;
634
634
  try {
635
635
  if (!text && !hasImages && !hasFiles) {
636
- await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
636
+ await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
637
637
  return;
638
638
  }
639
639
 
640
640
  const command = text.toLowerCase();
641
641
  if (isPlainText && !hasImages && !hasFiles && command === '/help') {
642
- await this.#send(sessionWebhook, HELP_TEXT);
642
+ await this.#send(sessionWebhook, helpText());
643
643
  return;
644
644
  }
645
645
  if (isPlainText && !hasImages && !hasFiles && command === '/status') {
646
646
  await this.#harness.ensureRunning({ signal: this.#signal });
647
- await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
647
+ await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'));
648
648
  return;
649
649
  }
650
650
  if (isPlainText && !hasImages && !hasFiles && command === '/new') {
651
651
  await this.#state.clearSession(key);
652
- await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
652
+ await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'));
653
653
  return;
654
654
  }
655
655
  const workspaceCommand = isPlainText && !hasImages && !hasFiles
@@ -689,7 +689,7 @@ export class DingtalkHarnessBridge {
689
689
  signal: this.#signal,
690
690
  logger: this.#logger,
691
691
  });
692
- cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
692
+ cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
693
693
  }
694
694
  const { answer, artifacts = [] } = await askInWorkspaceSession({
695
695
  harness: this.#harness,
@@ -717,7 +717,7 @@ export class DingtalkHarnessBridge {
717
717
  });
718
718
  const answerText = typeof answer === 'string' && answer.trim()
719
719
  ? answer
720
- : artifacts.length > 0 ? '结果文件已生成。' : answer;
720
+ : artifacts.length > 0 ? t('结果文件已生成。') : answer;
721
721
  let textDeliveryError = null;
722
722
  let textReceipt = null;
723
723
  let streamed = false;
@@ -752,11 +752,11 @@ export class DingtalkHarnessBridge {
752
752
  return delivery.receipt;
753
753
  } catch (error) {
754
754
  if (error?.code === 'turn-stopped') {
755
- if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
755
+ if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
756
756
  return;
757
757
  }
758
758
  if (this.#signal?.aborted) return;
759
- this.#status.lastError = '钉钉消息处理失败。';
759
+ this.#status.lastError = t('钉钉消息处理失败。');
760
760
  this.#logger.error?.(
761
761
  '[dsh-dingtalk] failed to process an inbound message',
762
762
  safeErrorDiagnostic(error),
@@ -764,7 +764,7 @@ export class DingtalkHarnessBridge {
764
764
  try {
765
765
  const errorText = inboundFileUserMessage(error)
766
766
  ?? dingtalkImageErrorUserMessage(error)
767
- ?? CARD_ERROR_TEXT;
767
+ ?? t(CARD_ERROR_TEXT);
768
768
  const streamed = cardStarted && await cardStream.finish(errorText);
769
769
  if (!streamed) await this.#send(sessionWebhook, errorText);
770
770
  } catch {
@@ -802,14 +802,14 @@ export class DingtalkHarnessBridge {
802
802
  } catch {
803
803
  increment(this.#status, 'messagesRejected');
804
804
  this.#status.lastRejectedAt = new Date().toISOString();
805
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
805
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
806
806
  return;
807
807
  }
808
808
 
809
809
  const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
810
810
  if (!text) {
811
811
  try {
812
- await this.#send(sessionWebhook, '请用文字回答当前问题。');
812
+ await this.#send(sessionWebhook, t('请用文字回答当前问题。'));
813
813
  } catch {
814
814
  this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
815
815
  }
@@ -820,7 +820,7 @@ export class DingtalkHarnessBridge {
820
820
  if (!pending || pending !== expected || pending.submitting) {
821
821
  if (claimed && (!pending || pending !== expected)) {
822
822
  try {
823
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
823
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
824
824
  } catch {
825
825
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
826
826
  }
@@ -836,7 +836,7 @@ export class DingtalkHarnessBridge {
836
836
  try {
837
837
  await this.#presentInteraction(pending);
838
838
  } catch {
839
- this.#status.lastError = '钉钉交互问题发送失败。';
839
+ this.#status.lastError = t('钉钉交互问题发送失败。');
840
840
  this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
841
841
  pending.interaction.reconnect?.();
842
842
  }
@@ -855,7 +855,7 @@ export class DingtalkHarnessBridge {
855
855
  try {
856
856
  await this.#presentInteraction(pending);
857
857
  } catch {
858
- this.#status.lastError = '钉钉交互问题发送失败。';
858
+ this.#status.lastError = t('钉钉交互问题发送失败。');
859
859
  this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
860
860
  pending.interaction.reconnect?.();
861
861
  }
@@ -879,7 +879,7 @@ export class DingtalkHarnessBridge {
879
879
  if (error?.code === 'interaction-not-pending') {
880
880
  this.#clearPendingInteraction(key, pending.interactionId);
881
881
  try {
882
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
882
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
883
883
  } catch {
884
884
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
885
885
  }
@@ -888,10 +888,10 @@ export class DingtalkHarnessBridge {
888
888
  pending.submitting = false;
889
889
  pending.answers.pop();
890
890
  pending.index -= 1;
891
- this.#status.lastError = '回答提交失败。';
891
+ this.#status.lastError = t('回答提交失败。');
892
892
  this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
893
893
  try {
894
- await this.#send(sessionWebhook, '回答提交失败,请重新发送当前问题的答案。');
894
+ await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'));
895
895
  } catch {
896
896
  this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
897
897
  }
@@ -937,7 +937,7 @@ export class DingtalkHarnessBridge {
937
937
  },
938
938
  });
939
939
  try {
940
- await this.#send(sessionWebhook, '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。');
940
+ await this.#send(sessionWebhook, t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'));
941
941
  } catch {
942
942
  this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice');
943
943
  }
@@ -1023,7 +1023,7 @@ export class DingtalkHarnessBridge {
1023
1023
  return;
1024
1024
  }
1025
1025
  try {
1026
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
1026
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
1027
1027
  } catch {
1028
1028
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
1029
1029
  }
@@ -1084,70 +1084,39 @@ export class DingtalkHarnessBridge {
1084
1084
  }
1085
1085
 
1086
1086
  async #deliverArtifacts(target, sessionWebhook, replyTo, artifacts, baseReceipt) {
1087
- const receipts = baseReceipt ? [baseReceipt] : [];
1088
- let userVisible = Boolean(baseReceipt);
1089
- for (const artifact of artifacts) {
1090
- this.#signal?.throwIfAborted();
1091
- try {
1092
- if (typeof this.#api.sendFile !== 'function') {
1093
- const unavailable = new Error('DingTalk file delivery is unavailable');
1094
- unavailable.code = 'artifact-provider-unavailable';
1095
- throw unavailable;
1096
- }
1097
- const file = await materializeOutboundArtifact(artifact, {
1098
- signal: this.#signal,
1099
- });
1100
- const result = await this.#api.sendFile({
1101
- clientId: this.#clientId,
1102
- clientSecret: this.#clientSecret,
1103
- target,
1104
- file,
1105
- signal: this.#signal,
1106
- });
1107
- receipts.push(createDeliveryReceipt({
1108
- deliveryId: file.deliveryKey,
1109
- presentation: 'dingtalk-file',
1110
- providerMessageIds: dingtalkFileProviderIds(result),
1111
- artifacts: [{ artifactId: file.artifactId, outcome: 'sent' }],
1112
- }));
1113
- userVisible = true;
1114
- this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
1115
- } catch (error) {
1116
- if (this.#signal?.aborted) throw error;
1117
- this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
1118
- this.#logger.warn?.(
1119
- `[dsh-dingtalk] result file delivery failed (${error?.code ?? 'unknown'})`,
1120
- );
1121
- let noticeSent = false;
1122
- const providerMessageIds = await this.#send(
1123
- sessionWebhook,
1124
- artifactFailureText(artifact?.fileName, error),
1125
- ).then((ids) => {
1126
- noticeSent = true;
1127
- return ids;
1128
- }).catch(() => []);
1129
- const failureReceipt = createArtifactFailureReceipt({
1130
- artifactId: artifact?.artifactId ?? 'unknown',
1131
- deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
1132
- error,
1133
- providerMessageIds,
1134
- });
1135
- receipts.push(failureReceipt);
1136
- if (noticeSent || failureReceipt.artifacts[0]?.outcome === 'unknown') userVisible = true;
1137
- } finally {
1138
- releaseOutboundArtifact(artifact);
1139
- }
1140
- }
1141
- const receipt = receipts.length === 0
1142
- ? null
1143
- : receipts.length === 1
1144
- ? receipts[0]
1145
- : mergeDeliveryReceipts({
1146
- deliveryId: replyTo,
1147
- presentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
1148
- receipts,
1149
- });
1150
- return { receipt, userVisible };
1087
+ const sendArtifact = async (method, file) => dingtalkFileProviderIds(
1088
+ await this.#api[method]({
1089
+ clientId: this.#clientId,
1090
+ clientSecret: this.#clientSecret,
1091
+ target,
1092
+ file,
1093
+ signal: this.#signal,
1094
+ }),
1095
+ );
1096
+ const delivery = await deliverOutboundArtifacts({
1097
+ artifacts,
1098
+ baseReceipt,
1099
+ deliveryId: replyTo,
1100
+ aggregatePresentation: baseReceipt ? 'dingtalk-text-and-files' : 'dingtalk-files',
1101
+ channelKey: 'dingtalk',
1102
+ signal: this.#signal,
1103
+ sendImage: typeof this.#api.sendImage === 'function'
1104
+ ? (file) => sendArtifact('sendImage', file)
1105
+ : undefined,
1106
+ sendFile: typeof this.#api.sendFile === 'function'
1107
+ ? (file) => sendArtifact('sendFile', file)
1108
+ : undefined,
1109
+ sendFailureNotice: (artifact, error) => this.#send(
1110
+ sessionWebhook,
1111
+ artifactFailureText(artifact?.fileName, error),
1112
+ ),
1113
+ logger: this.#logger,
1114
+ });
1115
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0)
1116
+ + delivery.artifactsSent;
1117
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
1118
+ + delivery.artifactSendErrors;
1119
+ return { receipt: delivery.receipt, userVisible: delivery.userVisible };
1151
1120
  }
1152
1121
  }
1153
1122