@xmanrui/dsh-im 1.5.0 → 2.0.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 (83) hide show
  1. package/README.en.md +16 -4
  2. package/README.md +16 -4
  3. package/lib/client.js +3 -3
  4. package/lib/index.js +208 -178
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/feishu/styles.js +1 -1
  7. package/plugin-src/client/styles.js +2 -2
  8. package/plugin-src/host/index.mjs +29 -10
  9. package/src/channels/dingtalk/config-store.mjs +5 -3
  10. package/src/channels/dingtalk/dingtalk-api.mjs +2 -1
  11. package/src/channels/dingtalk/dingtalk-bridge.mjs +45 -40
  12. package/src/channels/dingtalk/dingtalk-card-stream.mjs +3 -1
  13. package/src/channels/dingtalk/dingtalk-controller.mjs +24 -23
  14. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -3
  15. package/src/channels/dingtalk/state-store.mjs +3 -1
  16. package/src/channels/discord/config-store.mjs +2 -1
  17. package/src/channels/discord/discord-api.mjs +25 -1
  18. package/src/channels/discord/discord-runtime.mjs +237 -11
  19. package/src/channels/feishu/bridge.mjs +131 -123
  20. package/src/channels/feishu/feishu-cards.mjs +62 -54
  21. package/src/channels/feishu/feishu-channel.mjs +5 -4
  22. package/src/channels/feishu/feishu-runtime.mjs +10 -9
  23. package/src/channels/feishu/message-utils.mjs +4 -3
  24. package/src/channels/office/office-job-executor.mjs +15 -14
  25. package/src/channels/office/office-runtime.mjs +6 -5
  26. package/src/channels/qq/config-store.mjs +3 -1
  27. package/src/channels/qq/markdown-reply.mjs +4 -2
  28. package/src/channels/qq/qq-bridge.mjs +57 -52
  29. package/src/channels/qq/qq-controller.mjs +17 -16
  30. package/src/channels/qq/qq-runtime.mjs +3 -2
  31. package/src/channels/shared/agent-preset.mjs +3 -1
  32. package/src/channels/shared/compact-command.mjs +15 -14
  33. package/src/channels/shared/connection-test.mjs +6 -4
  34. package/src/channels/shared/control-command.mjs +13 -11
  35. package/src/channels/shared/editable-message-stream.mjs +4 -2
  36. package/src/channels/shared/harness-approval.mjs +23 -21
  37. package/src/channels/shared/harness-client.mjs +3 -2
  38. package/src/channels/shared/harness-question.mjs +8 -6
  39. package/src/channels/shared/i18n-en/dingtalk.mjs +52 -0
  40. package/src/channels/shared/i18n-en/discord.mjs +8 -0
  41. package/src/channels/shared/i18n-en/feishu.mjs +217 -0
  42. package/src/channels/shared/i18n-en/office.mjs +23 -0
  43. package/src/channels/shared/i18n-en/qq.mjs +54 -0
  44. package/src/channels/shared/i18n-en/shared-a.mjs +91 -0
  45. package/src/channels/shared/i18n-en/shared-b.mjs +215 -0
  46. package/src/channels/shared/i18n-en/shared-c.mjs +99 -0
  47. package/src/channels/shared/i18n-en/slack.mjs +27 -0
  48. package/src/channels/shared/i18n-en/telegram.mjs +21 -0
  49. package/src/channels/shared/i18n-en/wecom.mjs +38 -0
  50. package/src/channels/shared/i18n-en/weixin.mjs +80 -0
  51. package/src/channels/shared/i18n-en/whatsapp.mjs +17 -0
  52. package/src/channels/shared/i18n-en.mjs +35 -0
  53. package/src/channels/shared/i18n.mjs +35 -0
  54. package/src/channels/shared/image-prompt.mjs +17 -15
  55. package/src/channels/shared/inbound-file.mjs +5 -3
  56. package/src/channels/shared/model-command.mjs +35 -31
  57. package/src/channels/shared/preset-command.mjs +41 -38
  58. package/src/channels/shared/semantic/artifact-delivery.mjs +1 -1
  59. package/src/channels/shared/semantic/delivery.mjs +38 -0
  60. package/src/channels/shared/text-harness-bridge.mjs +130 -63
  61. package/src/channels/shared/token-bot-controller.mjs +32 -11
  62. package/src/channels/shared/workspace-command.mjs +69 -58
  63. package/src/channels/slack/config-store.mjs +3 -1
  64. package/src/channels/slack/slack-api.mjs +6 -5
  65. package/src/channels/slack/slack-controller.mjs +10 -9
  66. package/src/channels/slack/slack-runtime.mjs +16 -4
  67. package/src/channels/telegram/config-store.mjs +2 -1
  68. package/src/channels/telegram/telegram-api.mjs +72 -6
  69. package/src/channels/telegram/telegram-rich-message.mjs +147 -0
  70. package/src/channels/telegram/telegram-runtime.mjs +373 -5
  71. package/src/channels/wecom/config-store.mjs +3 -1
  72. package/src/channels/wecom/wecom-bridge.mjs +59 -55
  73. package/src/channels/wecom/wecom-controller.mjs +20 -19
  74. package/src/channels/wecom/wecom-runtime.mjs +2 -1
  75. package/src/channels/weixin/config-store.mjs +2 -1
  76. package/src/channels/weixin/weixin-bridge.mjs +50 -49
  77. package/src/channels/weixin/weixin-controller.mjs +22 -21
  78. package/src/channels/weixin/weixin-runtime.mjs +3 -2
  79. package/src/channels/whatsapp/config-store.mjs +3 -1
  80. package/src/channels/whatsapp/whatsapp-bridge.mjs +3 -1
  81. package/src/channels/whatsapp/whatsapp-controller.mjs +15 -14
  82. package/src/channels/whatsapp/whatsapp-runtime.mjs +5 -4
  83. 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.5.0",
3
+ "version": "2.0.1",
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",
@@ -450,7 +450,7 @@ const CSS = String.raw`
450
450
  .bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
451
451
  .bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
452
452
  .bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
453
- .bxf-botActions { flex: none; flex-wrap: nowrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
453
+ .bxf-botActions { flex: none; width: 100%; flex-wrap: wrap; gap: 8px; margin-top: 0; justify-content: flex-end; }
454
454
  .bxf-botActions .bxf-button { flex: none; white-space: nowrap; }
455
455
  .bxf-botActions .bxf-repairButton { color: var(--bxf-accent); border-color: color-mix(in srgb, var(--bxf-accent) 35%, var(--dsw-alias-border-l2, #dee0e3)); }
456
456
  .bxf-botActions .bxf-repairButton:hover:not(:disabled) { background: color-mix(in srgb, var(--bxf-accent) 7%, transparent); }
@@ -206,9 +206,9 @@ const CSS = String.raw`
206
206
  .dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; }
207
207
  .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
208
208
  .dim-panel .dim-cardFooterLayout { min-width: 0; width: 100%; display: flex; flex-direction: column; align-items: stretch; gap: 9px; }
209
- .dim-panel .dim-cardFooterLayout > .dim-cardActions { align-self: flex-end; }
209
+ .dim-panel .dim-cardFooterLayout > .dim-cardActions { align-self: stretch; }
210
210
  .dim-panel .dim-cardFeedback { width: 100%; padding: 8px 10px; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f7f8fa); font: inherit; font-size: 12px; font-weight: 400; line-height: 18px; overflow-wrap: anywhere; white-space: normal; }
211
- .dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
211
+ .dim-panel .dim-cardActions { flex: none; width: 100%; display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; margin: 0; }
212
212
  .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 32px; padding: 0 11px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
213
213
  .dim-panel .dim-cardActions .dim-cardAction:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
214
214
  .dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
@@ -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 = [
@@ -36,10 +37,23 @@ export function createImHostPlugin(internals = {}) {
36
37
  const startDiscord = internals.applyDiscord ?? applyDiscord;
37
38
  const startOffice = internals.applyOffice ?? applyOffice;
38
39
  const startWhatsapp = internals.applyWhatsapp ?? applyWhatsapp;
40
+ const channels = [
41
+ ['feishu', startFeishu],
42
+ ['weixin', startWeixin],
43
+ ['dingtalk', startDingtalk],
44
+ ['wecom', startWecom],
45
+ ['qq', startQq],
46
+ ['slack', startSlack],
47
+ ['telegram', startTelegram],
48
+ ['discord', startDiscord],
49
+ ['whatsapp', startWhatsapp],
50
+ ['office', startOffice],
51
+ ];
39
52
  return Object.freeze({
40
53
  name,
41
54
  inject,
42
55
  async apply(ctx, config = {}) {
56
+ setImHostLanguage(config.language ?? process.env.DSH_IM_LANGUAGE);
43
57
  if (typeof ctx?.inject === 'function') {
44
58
  ctx.inject(['tools', 'systemPrompt'], (artifactCtx) => {
45
59
  installOutboundArtifactTool(artifactCtx);
@@ -47,16 +61,21 @@ export function createImHostPlugin(internals = {}) {
47
61
  } else {
48
62
  installOutboundArtifactTool(ctx);
49
63
  }
50
- await startFeishu(ctx, channelConfig(config, 'feishu'));
51
- await startWeixin(ctx, channelConfig(config, 'weixin'));
52
- await startDingtalk(ctx, channelConfig(config, 'dingtalk'));
53
- await startWecom(ctx, channelConfig(config, 'wecom'));
54
- await startQq(ctx, channelConfig(config, 'qq'));
55
- await startSlack(ctx, channelConfig(config, 'slack'));
56
- await startTelegram(ctx, channelConfig(config, 'telegram'));
57
- await startDiscord(ctx, channelConfig(config, 'discord'));
58
- await startWhatsapp(ctx, channelConfig(config, 'whatsapp'));
59
- await startOffice(ctx, channelConfig(config, 'office'));
64
+ const logger = typeof ctx?.logger === 'function'
65
+ ? ctx.logger(name)
66
+ : (ctx?.logger ?? console);
67
+ const failures = [];
68
+ for (const [channel, start] of channels) {
69
+ try {
70
+ await start(ctx, channelConfig(config, channel));
71
+ } catch (error) {
72
+ failures.push(error);
73
+ logger.error?.(`[dsh-im] failed to activate ${channel}; continuing with the remaining channels`, error);
74
+ }
75
+ }
76
+ if (failures.length === channels.length) {
77
+ throw new AggregateError(failures, 'dsh-im failed to activate every channel');
78
+ }
60
79
  },
61
80
  });
62
81
  }
@@ -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/';
@@ -829,7 +830,7 @@ export function createDingtalkApi({
829
830
  clientId: appKey,
830
831
  clientSecret: appSecret,
831
832
  cardInstanceId,
832
- text: '消息处理失败,请稍后重试。',
833
+ text: t('消息处理失败,请稍后重试。'),
833
834
  signal: cleanupSignal,
834
835
  }).catch(() => undefined);
835
836
  }
@@ -40,12 +40,13 @@ import {
40
40
  createDeliveryReceipt,
41
41
  providerMessageIdsFor,
42
42
  } from '../shared/semantic/delivery.mjs';
43
+ import { t } from '../shared/i18n.mjs';
43
44
 
44
45
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
45
46
  const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
46
47
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
47
48
 
48
- const HELP_TEXT = [
49
+ const HELP_TEXT_LINES = [
49
50
  '钉钉机器人已连接 DeepSeek Harness。',
50
51
  '',
51
52
  '直接发送文字、图片或文件即可继续当前会话。',
@@ -66,7 +67,11 @@ const HELP_TEXT = [
66
67
  '/steer 补充指令 纠偏当前任务',
67
68
  '/status 检查连接状态',
68
69
  '/help 显示本帮助',
69
- ].join('\n');
70
+ ];
71
+
72
+ function helpText() {
73
+ return HELP_TEXT_LINES.map((line) => t(line)).join('\n');
74
+ }
70
75
 
71
76
  function nonEmptyString(value) {
72
77
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -106,13 +111,13 @@ function dingtalkImageErrorUserMessage(error) {
106
111
  while (current && typeof current === 'object' && !seen.has(current)) {
107
112
  seen.add(current);
108
113
  if (current.code === 'image-download-address-failed') {
109
- return '钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。';
114
+ return t('钉钉未能换取图片下载地址,请重新发送;若持续失败,请检查机器人的“企业内机器人发送消息权限”。');
110
115
  }
111
116
  if (current.code === 'invalid-image-download') {
112
- return '钉钉没有返回图片下载地址,请重新发送。';
117
+ return t('钉钉没有返回图片下载地址,请重新发送。');
113
118
  }
114
119
  if (current.code === 'image-content-download-failed') {
115
- return '钉钉返回的图片临时地址无法读取,请重新发送。';
120
+ return t('钉钉返回的图片临时地址无法读取,请重新发送。');
116
121
  }
117
122
  current = current.cause;
118
123
  }
@@ -241,34 +246,34 @@ function fileTarget(message, sender, clientId) {
241
246
  }
242
247
 
243
248
  function artifactFailureText(fileName, error) {
244
- const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
249
+ const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
245
250
  switch (error?.code) {
246
251
  case 'artifact-delivery-uncertain':
247
- return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
252
+ return t('结果文件「{name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。', { name });
248
253
  case 'artifact-permission-required':
249
- return `结果文件「${name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。`;
254
+ return t('结果文件「{name}」已生成,但钉钉应用或机器人缺少文件消息权限。请开通应用 qyapi_base 权限,并确认机器人具备文件消息发送能力。', { name });
250
255
  case 'artifact-too-large':
251
- return `结果文件「${name}」超过当前钉钉机器人可发送的文件大小,未发送。`;
256
+ return t('结果文件「{name}」超过当前钉钉机器人可发送的文件大小,未发送。', { name });
252
257
  case 'artifact-rate-limited':
253
- return `结果文件「${name}」暂时被钉钉限流,未能发送,请稍后重试。`;
258
+ return t('结果文件「{name}」暂时被钉钉限流,未能发送,请稍后重试。', { name });
254
259
  case 'artifact-provider-rejected':
255
- return `结果文件「${name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。`;
260
+ return t('结果文件「{name}」已生成,但钉钉拒绝了该文件消息,请检查文件类型和机器人文件消息配置。', { name });
256
261
  case 'artifact-invalid':
257
262
  case 'artifact-changed':
258
263
  case 'artifact-unavailable':
259
- return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
264
+ return t('结果文件「{name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。', { name });
260
265
  default:
261
- return `结果文件「${name}」已生成,但暂时未能通过钉钉发送,请稍后重试。`;
266
+ return t('结果文件「{name}」已生成,但暂时未能通过钉钉发送,请稍后重试。', { name });
262
267
  }
263
268
  }
264
269
 
265
270
  function progressText(update) {
266
271
  if (update?.type === 'text' && nonEmptyString(update.text)) return update.text;
267
272
  if (update?.type === 'tool') {
268
- if (update.name === 'web_search') return '_正在搜索网络并整理信息…_';
269
- return `_正在使用 ${nonEmptyString(update.name) ?? '工具'}…_`;
273
+ if (update.name === 'web_search') return t('_正在搜索网络并整理信息…_');
274
+ return t('_正在使用 {name}…_', { name: nonEmptyString(update.name) ?? t('工具') });
270
275
  }
271
- return `_${nonEmptyString(update?.text) ?? '正在处理…'}_`;
276
+ return t('_{text}_', { text: nonEmptyString(update?.text) ?? t('正在处理…') });
272
277
  }
273
278
 
274
279
  function canClaimInteractionReply(message, pending, sender) {
@@ -426,9 +431,9 @@ export class DingtalkHarnessBridge {
426
431
  commandRunner,
427
432
  ).catch((error) => {
428
433
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
429
- this.#status.lastError = '钉钉命令处理失败。';
434
+ this.#status.lastError = t('钉钉命令处理失败。');
430
435
  this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
431
- return this.#send(sessionWebhook, CARD_ERROR_TEXT).catch(() => undefined);
436
+ return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
432
437
  }).finally(() => {
433
438
  this.#acceptedMessageIds.delete(messageId);
434
439
  this.#commandTasks.delete(task);
@@ -463,13 +468,13 @@ export class DingtalkHarnessBridge {
463
468
  if (!sessionWebhook) {
464
469
  increment(this.#status, 'messagesRejected');
465
470
  this.#status.lastRejectedAt = new Date().toISOString();
466
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
471
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
467
472
  }
468
473
  return true;
469
474
  })
470
475
  .catch((error) => {
471
476
  if (this.#signal?.aborted) return;
472
- this.#status.lastError = '钉钉审批处理失败。';
477
+ this.#status.lastError = t('钉钉审批处理失败。');
473
478
  this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
474
479
  })
475
480
  .finally(() => {
@@ -611,7 +616,7 @@ export class DingtalkHarnessBridge {
611
616
  } catch {
612
617
  increment(this.#status, 'messagesRejected');
613
618
  this.#status.lastRejectedAt = new Date().toISOString();
614
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
619
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
615
620
  return;
616
621
  }
617
622
 
@@ -628,23 +633,23 @@ export class DingtalkHarnessBridge {
628
633
  let cardStarted = false;
629
634
  try {
630
635
  if (!text && !hasImages && !hasFiles) {
631
- await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
636
+ await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'));
632
637
  return;
633
638
  }
634
639
 
635
640
  const command = text.toLowerCase();
636
641
  if (isPlainText && !hasImages && !hasFiles && command === '/help') {
637
- await this.#send(sessionWebhook, HELP_TEXT);
642
+ await this.#send(sessionWebhook, helpText());
638
643
  return;
639
644
  }
640
645
  if (isPlainText && !hasImages && !hasFiles && command === '/status') {
641
646
  await this.#harness.ensureRunning({ signal: this.#signal });
642
- await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
647
+ await this.#send(sessionWebhook, t('钉钉机器人与 DeepSeek Harness 连接正常。'));
643
648
  return;
644
649
  }
645
650
  if (isPlainText && !hasImages && !hasFiles && command === '/new') {
646
651
  await this.#state.clearSession(key);
647
- await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
652
+ await this.#send(sessionWebhook, t('已开启新会话。请发送你的问题。'));
648
653
  return;
649
654
  }
650
655
  const workspaceCommand = isPlainText && !hasImages && !hasFiles
@@ -684,7 +689,7 @@ export class DingtalkHarnessBridge {
684
689
  signal: this.#signal,
685
690
  logger: this.#logger,
686
691
  });
687
- cardStarted = await cardStream.start(CARD_INITIAL_TEXT);
692
+ cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
688
693
  }
689
694
  const { answer, artifacts = [] } = await askInWorkspaceSession({
690
695
  harness: this.#harness,
@@ -712,7 +717,7 @@ export class DingtalkHarnessBridge {
712
717
  });
713
718
  const answerText = typeof answer === 'string' && answer.trim()
714
719
  ? answer
715
- : artifacts.length > 0 ? '结果文件已生成。' : answer;
720
+ : artifacts.length > 0 ? t('结果文件已生成。') : answer;
716
721
  let textDeliveryError = null;
717
722
  let textReceipt = null;
718
723
  let streamed = false;
@@ -747,11 +752,11 @@ export class DingtalkHarnessBridge {
747
752
  return delivery.receipt;
748
753
  } catch (error) {
749
754
  if (error?.code === 'turn-stopped') {
750
- if (cardStarted) await cardStream.finish('已停止。').catch(() => undefined);
755
+ if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
751
756
  return;
752
757
  }
753
758
  if (this.#signal?.aborted) return;
754
- this.#status.lastError = '钉钉消息处理失败。';
759
+ this.#status.lastError = t('钉钉消息处理失败。');
755
760
  this.#logger.error?.(
756
761
  '[dsh-dingtalk] failed to process an inbound message',
757
762
  safeErrorDiagnostic(error),
@@ -759,7 +764,7 @@ export class DingtalkHarnessBridge {
759
764
  try {
760
765
  const errorText = inboundFileUserMessage(error)
761
766
  ?? dingtalkImageErrorUserMessage(error)
762
- ?? CARD_ERROR_TEXT;
767
+ ?? t(CARD_ERROR_TEXT);
763
768
  const streamed = cardStarted && await cardStream.finish(errorText);
764
769
  if (!streamed) await this.#send(sessionWebhook, errorText);
765
770
  } catch {
@@ -797,14 +802,14 @@ export class DingtalkHarnessBridge {
797
802
  } catch {
798
803
  increment(this.#status, 'messagesRejected');
799
804
  this.#status.lastRejectedAt = new Date().toISOString();
800
- this.#status.lastError = '钉钉消息没有安全的回复地址。';
805
+ this.#status.lastError = t('钉钉消息没有安全的回复地址。');
801
806
  return;
802
807
  }
803
808
 
804
809
  const text = message?.msgtype === 'text' ? nonEmptyString(message?.text?.content) : null;
805
810
  if (!text) {
806
811
  try {
807
- await this.#send(sessionWebhook, '请用文字回答当前问题。');
812
+ await this.#send(sessionWebhook, t('请用文字回答当前问题。'));
808
813
  } catch {
809
814
  this.#logger.error?.('[dsh-dingtalk] failed to reject a non-text interaction reply');
810
815
  }
@@ -815,7 +820,7 @@ export class DingtalkHarnessBridge {
815
820
  if (!pending || pending !== expected || pending.submitting) {
816
821
  if (claimed && (!pending || pending !== expected)) {
817
822
  try {
818
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
823
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
819
824
  } catch {
820
825
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
821
826
  }
@@ -831,7 +836,7 @@ export class DingtalkHarnessBridge {
831
836
  try {
832
837
  await this.#presentInteraction(pending);
833
838
  } catch {
834
- this.#status.lastError = '钉钉交互问题发送失败。';
839
+ this.#status.lastError = t('钉钉交互问题发送失败。');
835
840
  this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
836
841
  pending.interaction.reconnect?.();
837
842
  }
@@ -850,7 +855,7 @@ export class DingtalkHarnessBridge {
850
855
  try {
851
856
  await this.#presentInteraction(pending);
852
857
  } catch {
853
- this.#status.lastError = '钉钉交互问题发送失败。';
858
+ this.#status.lastError = t('钉钉交互问题发送失败。');
854
859
  this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
855
860
  pending.interaction.reconnect?.();
856
861
  }
@@ -874,7 +879,7 @@ export class DingtalkHarnessBridge {
874
879
  if (error?.code === 'interaction-not-pending') {
875
880
  this.#clearPendingInteraction(key, pending.interactionId);
876
881
  try {
877
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
882
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
878
883
  } catch {
879
884
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
880
885
  }
@@ -883,10 +888,10 @@ export class DingtalkHarnessBridge {
883
888
  pending.submitting = false;
884
889
  pending.answers.pop();
885
890
  pending.index -= 1;
886
- this.#status.lastError = '回答提交失败。';
891
+ this.#status.lastError = t('回答提交失败。');
887
892
  this.#logger.error?.('[dsh-dingtalk] failed to answer a Harness interaction');
888
893
  try {
889
- await this.#send(sessionWebhook, '回答提交失败,请重新发送当前问题的答案。');
894
+ await this.#send(sessionWebhook, t('回答提交失败,请重新发送当前问题的答案。'));
890
895
  } catch {
891
896
  this.#logger.error?.('[dsh-dingtalk] failed to send an interaction retry notice');
892
897
  }
@@ -932,7 +937,7 @@ export class DingtalkHarnessBridge {
932
937
  },
933
938
  });
934
939
  try {
935
- await this.#send(sessionWebhook, '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。');
940
+ await this.#send(sessionWebhook, t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'));
936
941
  } catch {
937
942
  this.#logger.error?.('[dsh-dingtalk] failed to send an interaction recovery notice');
938
943
  }
@@ -1018,7 +1023,7 @@ export class DingtalkHarnessBridge {
1018
1023
  return;
1019
1024
  }
1020
1025
  try {
1021
- await this.#send(sessionWebhook, INTERACTION_RESOLVED_TEXT);
1026
+ await this.#send(sessionWebhook, t(INTERACTION_RESOLVED_TEXT));
1022
1027
  } catch {
1023
1028
  this.#logger.error?.('[dsh-dingtalk] failed to send an expired interaction notice');
1024
1029
  }
@@ -1,3 +1,5 @@
1
+ import { t } from '../shared/i18n.mjs';
2
+
1
3
  const DEFAULT_UPDATE_INTERVAL_MS = 500;
2
4
  const FAILURE_TEXT = '消息处理失败,请稍后重试。';
3
5
 
@@ -100,7 +102,7 @@ export function createDingTalkCardStream({
100
102
  if (!cleanupPromise) {
101
103
  cleanupPromise = api.failAiCard({
102
104
  ...cardRequest,
103
- text: FAILURE_TEXT,
105
+ text: t(FAILURE_TEXT),
104
106
  signal: AbortSignal.timeout(5_000),
105
107
  }).then(
106
108
  () => true,
@@ -10,6 +10,7 @@ import {
10
10
  connectionTestMessage,
11
11
  connectionTestTargetUnavailable,
12
12
  } from '../shared/connection-test.mjs';
13
+ import { t } from '../shared/i18n.mjs';
13
14
 
14
15
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
15
16
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@@ -84,7 +85,7 @@ function normalizePendingSender(value) {
84
85
  return {
85
86
  requestId: opaqueRequestId,
86
87
  staffId,
87
- displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
88
+ displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? t('钉钉用户'),
88
89
  requestedAt: cleanString(value.requestedAt),
89
90
  };
90
91
  }
@@ -114,7 +115,7 @@ function publicPendingSender(sender) {
114
115
  function publicApprovedSender(sender) {
115
116
  return {
116
117
  senderKey: sender.senderKey,
117
- displayName: cleanString(sender.displayName) ?? '钉钉用户',
118
+ displayName: cleanString(sender.displayName) ?? t('钉钉用户'),
118
119
  senderIdMasked: maskDingtalkSenderId(sender.staffId),
119
120
  approvedAt: cleanString(sender.approvedAt),
120
121
  };
@@ -206,7 +207,7 @@ export class DingtalkController {
206
207
  if (!clientSecret) {
207
208
  this.#errors.set(
208
209
  latest.botId,
209
- safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
210
+ safeError('missing-secret', t('钉钉机器人凭据缺失,请移除后重新扫码。')),
210
211
  );
211
212
  this.#touch();
212
213
  return;
@@ -217,7 +218,7 @@ export class DingtalkController {
217
218
  } catch {
218
219
  this.#errors.set(
219
220
  latest.botId,
220
- safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
221
+ safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
221
222
  );
222
223
  this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
223
224
  }
@@ -270,10 +271,10 @@ export class DingtalkController {
270
271
  } catch (error) {
271
272
  if (record.controller.signal.aborted || error?.name === 'AbortError') {
272
273
  record.state = 'cancelled';
273
- record.error = safeError('cancelled', '扫码接入已取消。');
274
+ record.error = safeError('cancelled', t('扫码接入已取消。'));
274
275
  } else {
275
276
  record.state = 'failed';
276
- record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
277
+ record.error = safeError('qr-start-failed', t('无法生成钉钉二维码,请稍后重试。'));
277
278
  }
278
279
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
279
280
  this.#touch();
@@ -319,7 +320,7 @@ export class DingtalkController {
319
320
  } catch {
320
321
  this.#errors.set(
321
322
  identity.botId,
322
- safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
323
+ safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
323
324
  );
324
325
  this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
325
326
  }
@@ -337,7 +338,7 @@ export class DingtalkController {
337
338
  }
338
339
  if (nowFrom(this.#clock) >= record.expiresAt) {
339
340
  record.state = 'expired';
340
- record.error = safeError('expired', '二维码已过期,请重新生成。');
341
+ record.error = safeError('expired', t('二维码已过期,请重新生成。'));
341
342
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
342
343
  this.#touch();
343
344
  return publicAttempt(record);
@@ -360,7 +361,7 @@ export class DingtalkController {
360
361
  record.controller.abort();
361
362
  await record.pollTask?.catch(() => undefined);
362
363
  if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
363
- record.error ??= safeError('cancelled', '扫码接入已取消。');
364
+ record.error ??= safeError('cancelled', t('扫码接入已取消。'));
364
365
  }
365
366
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
366
367
  this.#touch();
@@ -380,7 +381,7 @@ export class DingtalkController {
380
381
  } catch (error) {
381
382
  this.#errors.set(
382
383
  botId,
383
- safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
384
+ safeError('connection-failed', t('钉钉连接仍未就绪,请稍后重试。')),
384
385
  );
385
386
  throw error;
386
387
  } finally {
@@ -396,10 +397,10 @@ export class DingtalkController {
396
397
  return this.#withBotTransition(botId, async () => {
397
398
  const runtime = this.#runtimes.get(botId);
398
399
  if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
399
- throw connectionTestTargetUnavailable('钉钉机器人');
400
+ throw connectionTestTargetUnavailable(t('钉钉机器人'));
400
401
  }
401
402
  return runtime.sendConnectionTest(connectionTestMessage(
402
- `钉钉机器人(${maskDingtalkClientId(config.clientId)})`,
403
+ t('钉钉机器人({clientId})', { clientId: maskDingtalkClientId(config.clientId) }),
403
404
  ));
404
405
  });
405
406
  }
@@ -501,14 +502,14 @@ export class DingtalkController {
501
502
  connected,
502
503
  configured: true,
503
504
  bot: {
504
- name: '钉钉机器人',
505
+ name: t('钉钉机器人'),
505
506
  clientIdMasked: maskDingtalkClientId(config.clientId),
506
507
  },
507
508
  health: {
508
509
  status: connected ? 'healthy' : accountError ? 'error' : 'offline',
509
510
  summary: connected
510
- ? '钉钉 Stream 消息连接运行正常'
511
- : accountError?.message ?? '钉钉消息连接当前离线',
511
+ ? t('钉钉 Stream 消息连接运行正常')
512
+ : accountError?.message ?? t('钉钉消息连接当前离线'),
512
513
  lastCheckedAt: currentStatus.lastCheckedAt ?? null,
513
514
  },
514
515
  stats: {
@@ -580,32 +581,32 @@ export class DingtalkController {
580
581
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
581
582
  } else if (state === 'EXPIRED') {
582
583
  record.state = 'expired';
583
- record.error = safeError('expired', '二维码已过期,请重新生成。');
584
+ record.error = safeError('expired', t('二维码已过期,请重新生成。'));
584
585
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
585
586
  } else if (state === 'FAIL') {
586
587
  record.state = 'failed';
587
- record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
588
+ record.error = safeError('authorization-failed', t('钉钉未完成机器人授权,请重新扫码。'));
588
589
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
589
590
  } else {
590
591
  record.state = 'pending';
591
- record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
592
+ record.error = safeError('poll-pending', t('钉钉授权状态暂时不可用,正在重试。'));
592
593
  }
593
594
  } catch (error) {
594
595
  if (record.controller.signal.aborted || error?.name === 'AbortError') {
595
596
  record.state = 'cancelled';
596
- record.error = safeError('cancelled', '扫码接入已取消。');
597
+ record.error = safeError('cancelled', t('扫码接入已取消。'));
597
598
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
598
599
  } else if (record.state === 'connecting') {
599
600
  record.state = 'failed';
600
601
  record.error = safeError(
601
602
  'activation-failed',
602
- '钉钉已授权,但无法安全保存接入配置。',
603
+ t('钉钉已授权,但无法安全保存接入配置。'),
603
604
  );
604
605
  if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
605
606
  this.#logger.error?.('[dsh-dingtalk] bot activation failed');
606
607
  } else {
607
608
  record.state = 'pending';
608
- record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
609
+ record.error = safeError('poll-failed', t('钉钉授权查询暂时失败,正在重试。'));
609
610
  }
610
611
  } finally {
611
612
  this.#touch();
@@ -660,7 +661,7 @@ export class DingtalkController {
660
661
  }
661
662
  this.#errors.set(
662
663
  identity.botId,
663
- safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
664
+ safeError('connection-failed', t('钉钉已接入,但消息连接暂未就绪,请稍后重试。')),
664
665
  );
665
666
  this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
666
667
  }
@@ -681,7 +682,7 @@ export class DingtalkController {
681
682
  await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
682
683
  this.#errors.set(
683
684
  previousConfig.botId,
684
- safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
685
+ safeError('connection-failed', t('钉钉连接未就绪,请稍后重试。')),
685
686
  );
686
687
  throw error;
687
688
  } finally {
@@ -4,6 +4,7 @@ import {
4
4
  DingtalkHarnessBridge,
5
5
  } from './dingtalk-bridge.mjs';
6
6
  import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
7
+ import { t } from '../shared/i18n.mjs';
7
8
 
8
9
  function nonEmptyString(value) {
9
10
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -275,7 +276,7 @@ export class DingtalkRuntime {
275
276
  ? JSON.parse(response.data)
276
277
  : response?.data;
277
278
  } catch {
278
- this.#status.lastError = '钉钉消息格式无效。';
279
+ this.#status.lastError = t('钉钉消息格式无效。');
279
280
  this.#logger.warn?.('[dsh-dingtalk] ignored an invalid callback payload');
280
281
  return;
281
282
  }
@@ -284,7 +285,7 @@ export class DingtalkRuntime {
284
285
  await bridge.accept(message);
285
286
  }).catch(() => {
286
287
  if (signal.aborted || this.#bridge !== bridge) return;
287
- this.#status.lastError = '钉钉消息处理失败。';
288
+ this.#status.lastError = t('钉钉消息处理失败。');
288
289
  this.#logger.error?.('[dsh-dingtalk] callback processing failed');
289
290
  }).finally(() => this.#callbackTasks.delete(task));
290
291
  this.#callbackTasks.add(task);
@@ -352,7 +353,7 @@ export class DingtalkRuntime {
352
353
  return sendRememberedConnectionTest({
353
354
  state: this.#state,
354
355
  text,
355
- channelLabel: '钉钉机器人',
356
+ channelLabel: t('钉钉机器人'),
356
357
  send: async ({ sessionWebhook }, content) => {
357
358
  if (!this.#status.ready || !this.#abortController) {
358
359
  throw new Error('DingTalk runtime is not connected');