@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
@@ -14,6 +14,8 @@
14
14
  * without button callbacks.
15
15
  */
16
16
 
17
+ import { t } from '../shared/i18n.mjs';
18
+
17
19
  export const MENU_PAGE_SIZE = 10;
18
20
 
19
21
  function plainText(content) {
@@ -56,7 +58,7 @@ function buttonElement(content, actionValue) {
56
58
 
57
59
  function safeTitle(value) {
58
60
  const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
59
- return title || '暂无标题';
61
+ return title || t('暂无标题');
60
62
  }
61
63
 
62
64
  function cardWith(headerText, elements) {
@@ -69,18 +71,18 @@ function cardWith(headerText, elements) {
69
71
 
70
72
  /** The main command menu (buttons + number-reply fallback). */
71
73
  export function menuCard() {
72
- return cardWith('🤖 助手菜单', [
73
- { tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
74
- button('1 · 会话列表', 'sessions'),
75
- button('2 · 工作区', 'workspaces'),
76
- button('3 · 新会话', 'new'),
77
- button('4 · 状态', 'status'),
78
- button('5 · 帮助', 'help'),
74
+ return cardWith(t('🤖 助手菜单'), [
75
+ { tag: 'div', text: markdown(t('**点击按钮或直接回复数字**')) },
76
+ button(t('1 · 会话列表'), 'sessions'),
77
+ button(t('2 · 工作区'), 'workspaces'),
78
+ button(t('3 · 新会话'), 'new'),
79
+ button(t('4 · 状态'), 'status'),
80
+ button(t('5 · 帮助'), 'help'),
79
81
  // Repair must remain number-driven. Apps that need this command do not
80
82
  // have card.action.trigger yet, so rendering it as a callback button would
81
83
  // send the user straight back to Feishu's broken callback setup popup.
82
- { tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
83
- button('7 · 关注列表', 'watchlist'),
84
+ { tag: 'div', text: markdown(t('**6 · 修复卡片按钮**(请直接回复数字 **6**)')) },
85
+ button(t('7 · 关注列表'), 'watchlist'),
84
86
  ]);
85
87
  }
86
88
 
@@ -89,10 +91,10 @@ export function cardActionProbeCard(nonce) {
89
91
  if (typeof nonce !== 'string' || !/^[A-Za-z0-9_-]{16,128}$/.test(nonce)) {
90
92
  throw new TypeError('A safe card-action probe nonce is required');
91
93
  }
92
- return cardWith('🧪 验证卡片按钮', [
94
+ return cardWith(t('🧪 验证卡片按钮'), [
93
95
  {
94
96
  tag: 'div',
95
- text: markdown('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。'),
97
+ text: markdown(t('授权已提交。请点击下方按钮;机器人真实收到回调后才会判定修复成功。')),
96
98
  },
97
99
  {
98
100
  tag: 'column_set',
@@ -103,7 +105,7 @@ export function cardActionProbeCard(nonce) {
103
105
  weight: 1,
104
106
  elements: [{
105
107
  tag: 'button',
106
- text: plainText('完成验证'),
108
+ text: plainText(t('完成验证')),
107
109
  type: 'primary',
108
110
  width: 'fill',
109
111
  behaviors: [{
@@ -140,74 +142,80 @@ export function sessionListCard(workspace, sessions, page, total, watchedSession
140
142
  ],
141
143
  });
142
144
  const elements = [
143
- { tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
145
+ { tag: 'div', text: markdown(t('**工作区**:{workspace}\n共 **{total}** 个会话{paging}', {
146
+ workspace: `\`${workspace}\``,
147
+ total,
148
+ paging: total > MENU_PAGE_SIZE
149
+ ? t('(第 {page}/{pageCount} 页)', { page: page + 1, pageCount })
150
+ : '',
151
+ })) },
144
152
  ...slice.map((session, offset) => {
145
153
  // Page-local numbering: number replies resolve against this page.
146
- const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
154
+ const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? t('(已归档)') : ''}`;
147
155
  const watching = watched(session.sessionId);
148
156
  return row(
149
- buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
157
+ buttonElement(watching ? t('⭐取关') : t('⭐关注'), watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
150
158
  buttonElement(label, `use:${session.sessionId}`),
151
159
  );
152
160
  }),
153
161
  ];
154
- if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
155
- if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
156
- elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
157
- return cardWith('📂 会话列表', elements);
162
+ if (page > 0) elements.push(button(t('◀ 上一页'), `sessions:${page - 1}`));
163
+ if (page + 1 < pageCount) elements.push(button(t('下一页 ▶'), `sessions:${page + 1}`));
164
+ elements.push({ tag: 'div', text: markdown(t('回复数字(1~N)绑定本页会话。')) });
165
+ return cardWith(t('📂 会话列表'), elements);
158
166
  }
159
167
 
160
168
  /** The workspace list card (switch-workspace buttons + reply fallback). */
161
169
  export function workspaceListCard(paths, current) {
162
170
  const elements = paths.length === 0
163
- ? [{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }]
171
+ ? [{ tag: 'div', text: markdown(t('当前 Host 上没有已登记的工作区。')) }]
164
172
  : [
165
- { tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
173
+ { tag: 'div', text: markdown(t('回复数字切换工作区,或点击按钮:')) },
166
174
  ...paths.map((path, index) => button(
167
- `${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
175
+ `${index + 1}. ${path}${path === current ? t('(当前)') : ''}`,
168
176
  `workspace:${path}`,
169
177
  )),
170
178
  ];
171
- return cardWith('🗂 工作区', elements);
179
+ return cardWith(t('🗂 工作区'), elements);
172
180
  }
173
181
 
174
182
  /** The card-menu help text (number-driven, no command memorization). */
175
183
  export function menuHelpText() {
176
184
  return [
177
- '🤖 助手菜单(回复数字即可,无需记命令)',
185
+ t('🤖 助手菜单(回复数字即可,无需记命令)'),
178
186
  '',
179
- '1 · /sessionlist 列出会话(回复数字绑定)',
180
- '2 · /workspacelist 列出工作区(回复数字切换)',
181
- '3 · /new 开启新会话',
182
- '4 · /status 连接状态',
183
- '5 · /help 本帮助',
184
- '6 · /repair 修复卡片按钮(请回复数字 6)',
185
- '7 · /watchlist 关注列表',
187
+ t('1 · /sessionlist 列出会话(回复数字绑定)'),
188
+ t('2 · /workspacelist 列出工作区(回复数字切换)'),
189
+ t('3 · /new 开启新会话'),
190
+ t('4 · /status 连接状态'),
191
+ t('5 · /help 本帮助'),
192
+ t('6 · /repair 修复卡片按钮(请回复数字 6)'),
193
+ t('7 · /watchlist 关注列表'),
186
194
  '',
187
- '直接发送文字/图片即继续当前会话。',
188
- '/session ID 或序号 绑定已有会话',
189
- '/watch ID 或序号 关注会话(完成后推送)',
190
- '/compact 压缩上下文',
191
- '/workspace 绝对路径 切换工作区',
192
- '/presetlist 列出可用 Agent Preset',
193
- '/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
194
- '纯数字 ID:/preset id:<ID>',
195
- '/preset --default 跟随 Host 默认',
195
+ t('直接发送文字/图片即继续当前会话。'),
196
+ t('/session ID 或序号 绑定已有会话'),
197
+ t('/watch ID 或序号 关注会话(完成后推送)'),
198
+ t('/compact 压缩上下文'),
199
+ t('/workspace 绝对路径 切换工作区'),
200
+ t('/presetlist 列出可用 Agent Preset'),
201
+ t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
202
+ t('纯数字 ID:/preset id:<ID>'),
203
+ t('/preset --default 跟随 Host 默认'),
196
204
  ].join('\n');
197
205
  }
198
206
 
199
207
  /** The watch list for one conversation (unwatch buttons + reply fallback). */
200
208
  export function watchListCard(entries) {
201
209
  const elements = entries.length === 0
202
- ? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
210
+ ? [{ tag: 'div', text: markdown(t('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。')) }]
203
211
  : [
204
- { tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
212
+ { tag: 'div', text: markdown(t('任务完成会自动推送,回复数字或点按钮取消关注:')) },
205
213
  ...entries.map((entry, index) => button(
206
214
  `${index + 1}. ${safeTitle(entry.title)}`,
207
215
  `unwatch:${entry.sessionId}`,
208
216
  )),
209
217
  ];
210
- return cardWith('👁 关注列表', elements);
218
+ return cardWith(t('👁 关注列表'), elements);
211
219
  }
212
220
 
213
221
  /**
@@ -216,19 +224,19 @@ export function watchListCard(entries) {
216
224
  */
217
225
  export function completionCard(sessionId, title, reason) {
218
226
  const reasonText = reason === 'completed'
219
- ? '已完成'
227
+ ? t('已完成')
220
228
  : reason === 'stopped'
221
- ? '已停止'
229
+ ? t('已停止')
222
230
  : reason === 'aborted'
223
- ? '已中止'
231
+ ? t('已中止')
224
232
  : reason === 'cancelled'
225
- ? '已取消'
226
- : '已结束';
227
- return cardWith('✅ 任务完成', [
233
+ ? t('已取消')
234
+ : t('已结束');
235
+ return cardWith(t('✅ 任务完成'), [
228
236
  { tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
229
- { tag: 'div', text: markdown(`**状态**:${reasonText}`) },
230
- button('打开会话列表', 'sessions'),
231
- button('工作区', 'workspaces'),
232
- { tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
237
+ { tag: 'div', text: markdown(t('**状态**:{reason}', { reason: reasonText })) },
238
+ button(t('打开会话列表'), 'sessions'),
239
+ button(t('工作区'), 'workspaces'),
240
+ { tag: 'div', text: markdown(t('绑定该会话后可继续追问,输入文字即可。')) },
233
241
  ]);
234
242
  }
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
 
3
3
  import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
4
4
  import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
5
+ import { t } from '../shared/i18n.mjs';
5
6
 
6
7
  const STREAM_ELEMENT_ID = 'stream_md';
7
8
  const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
@@ -114,9 +115,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
114
115
  });
115
116
  }
116
117
 
117
- function deliveryUuid(file, chatId) {
118
+ function deliveryUuid(file, chatId, messageType) {
119
+ const seed = `${file.deliveryKey}\u0000${chatId}`;
118
120
  const digest = createHash('sha256')
119
- .update(`${file.deliveryKey}\u0000${chatId}`)
121
+ .update(messageType === 'file' ? seed : `${seed}\u0000${messageType}`)
120
122
  .digest('hex')
121
123
  .slice(0, 40);
122
124
  return `dshim_${digest}`;
@@ -132,7 +134,7 @@ function streamingCard(initialText) {
132
134
  schema: '2.0',
133
135
  config: {
134
136
  streaming_mode: true,
135
- summary: { content: '正在生成…' },
137
+ summary: { content: t('正在生成…') },
136
138
  streaming_config: {
137
139
  print_frequency_ms: { default: 70 },
138
140
  print_step: { default: 1 },
@@ -157,12 +159,12 @@ export class VerifiedFeishuChannel {
157
159
 
158
160
  constructor({
159
161
  client,
160
- initialText = DEFAULT_INITIAL_TEXT,
162
+ initialText,
161
163
  fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
162
164
  fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
163
165
  }) {
164
166
  this.#client = client;
165
- this.#initialText = initialText;
167
+ this.#initialText = initialText ?? t(DEFAULT_INITIAL_TEXT);
166
168
  this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
167
169
  this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
168
170
  }
@@ -214,7 +216,7 @@ export class VerifiedFeishuChannel {
214
216
  settings: JSON.stringify({
215
217
  config: {
216
218
  streaming_mode: false,
217
- summary: { content: summaryOf(lastContent) || '回答完成' },
219
+ summary: { content: summaryOf(lastContent) || t('回答完成') },
218
220
  },
219
221
  }),
220
222
  sequence: ++sequence,
@@ -230,6 +232,29 @@ export class VerifiedFeishuChannel {
230
232
  }
231
233
 
232
234
  async sendFile(chatId, file, { replyTo, signal } = {}) {
235
+ return this.#sendArtifact(chatId, file, {
236
+ replyTo,
237
+ signal,
238
+ messageType: 'file',
239
+ presentation: 'feishu-file',
240
+ });
241
+ }
242
+
243
+ async sendImage(chatId, file, { replyTo, signal } = {}) {
244
+ return this.#sendArtifact(chatId, file, {
245
+ replyTo,
246
+ signal,
247
+ messageType: 'image',
248
+ presentation: 'feishu-image',
249
+ });
250
+ }
251
+
252
+ async #sendArtifact(chatId, file, {
253
+ replyTo,
254
+ signal,
255
+ messageType,
256
+ presentation,
257
+ }) {
233
258
  signal?.throwIfAborted();
234
259
  if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
235
260
  if (!file || typeof file !== 'object'
@@ -243,13 +268,20 @@ export class VerifiedFeishuChannel {
243
268
  try {
244
269
  uploaded = await waitForFileOperation((operationSignal) => {
245
270
  operationSignal.throwIfAborted();
246
- const pending = this.#client.im.v1.file.create({
247
- data: {
248
- file_type: 'stream',
249
- file_name: file.fileName,
250
- file: file.bytes,
251
- },
252
- });
271
+ const pending = messageType === 'image'
272
+ ? this.#client.im.v1.image.create({
273
+ data: {
274
+ image_type: 'message',
275
+ image: file.bytes,
276
+ },
277
+ })
278
+ : this.#client.im.v1.file.create({
279
+ data: {
280
+ file_type: 'stream',
281
+ file_name: file.fileName,
282
+ file: file.bytes,
283
+ },
284
+ });
253
285
  trackOutboundArtifactProviderPromise(file, pending);
254
286
  return pending;
255
287
  }, {
@@ -262,21 +294,25 @@ export class VerifiedFeishuChannel {
262
294
  throw fileDeliveryError('upload', error);
263
295
  }
264
296
  signal?.throwIfAborted();
265
- const fileKey = uploaded?.file_key;
266
- if (typeof fileKey !== 'string' || !fileKey) {
297
+ const resourceKey = messageType === 'image'
298
+ ? uploaded?.image_key ?? uploaded?.data?.image_key
299
+ : uploaded?.file_key ?? uploaded?.data?.file_key;
300
+ if (typeof resourceKey !== 'string' || !resourceKey) {
267
301
  throw fileDeliveryError('upload', undefined, uploaded?.code);
268
302
  }
269
303
 
270
- const uuid = deliveryUuid(file, chatId);
271
- const content = JSON.stringify({ file_key: fileKey });
304
+ const uuid = deliveryUuid(file, chatId, messageType);
305
+ const content = JSON.stringify(messageType === 'image'
306
+ ? { image_key: resourceKey }
307
+ : { file_key: resourceKey });
272
308
  const request = replyTo
273
309
  ? {
274
310
  path: { message_id: replyTo },
275
- data: { msg_type: 'file', content, uuid },
311
+ data: { msg_type: messageType, content, uuid },
276
312
  }
277
313
  : {
278
314
  params: { receive_id_type: 'chat_id' },
279
- data: { receive_id: chatId, msg_type: 'file', content, uuid },
315
+ data: { receive_id: chatId, msg_type: messageType, content, uuid },
280
316
  };
281
317
  const send = () => {
282
318
  const pending = replyTo
@@ -300,7 +336,7 @@ export class VerifiedFeishuChannel {
300
336
  operationSignal.throwIfAborted();
301
337
 
302
338
  // Feishu documents 230049 as an uncertain asynchronous send result.
303
- // Reuse the same file_key and UUID once so the provider can deduplicate.
339
+ // Reuse the same resource key and UUID once so the provider can deduplicate.
304
340
  if (Number(result?.code) === 230049) {
305
341
  result = await send();
306
342
  operationSignal.throwIfAborted();
@@ -324,7 +360,7 @@ export class VerifiedFeishuChannel {
324
360
  }
325
361
  return createDeliveryReceipt({
326
362
  deliveryId: file.deliveryKey,
327
- presentation: 'feishu-file',
363
+ presentation,
328
364
  providerMessageIds: [messageId],
329
365
  artifacts: [{
330
366
  artifactId: file.artifactId,
@@ -7,6 +7,7 @@ import {
7
7
  connectionTestTargetUnavailable,
8
8
  sendRememberedConnectionTest,
9
9
  } from '../shared/connection-test.mjs';
10
+ import { t } from '../shared/i18n.mjs';
10
11
 
11
12
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
12
13
  const CALLBACK_PROBE_SUCCESS_NOTICE = '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。';
@@ -206,7 +207,7 @@ export class FeishuRuntime {
206
207
  this.#client = new this.#lark.Client(larkConfig);
207
208
  const channel = new VerifiedFeishuChannel({
208
209
  client: this.#client,
209
- initialText: '已连接 DeepSeek Harness,正在思考…',
210
+ initialText: t('已连接 DeepSeek Harness,正在思考…'),
210
211
  });
211
212
  this.#bridge = new FeishuHarnessBridge({
212
213
  client: this.#client,
@@ -340,7 +341,7 @@ export class FeishuRuntime {
340
341
  } catch {
341
342
  void this.#sendCardActionProbeNotice(
342
343
  operatorOpenId,
343
- CALLBACK_PROBE_SEND_FAILURE_NOTICE,
344
+ t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
344
345
  'failure',
345
346
  );
346
347
  throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@@ -348,7 +349,7 @@ export class FeishuRuntime {
348
349
  if (response?.code && response.code !== 0) {
349
350
  void this.#sendCardActionProbeNotice(
350
351
  operatorOpenId,
351
- CALLBACK_PROBE_SEND_FAILURE_NOTICE,
352
+ t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
352
353
  'failure',
353
354
  );
354
355
  throw probeError('card_action_probe_send_failed', '无法发送飞书卡片回调测试');
@@ -358,7 +359,7 @@ export class FeishuRuntime {
358
359
  if (!messageId) {
359
360
  void this.#sendCardActionProbeNotice(
360
361
  operatorOpenId,
361
- CALLBACK_PROBE_SEND_FAILURE_NOTICE,
362
+ t(CALLBACK_PROBE_SEND_FAILURE_NOTICE),
362
363
  'failure',
363
364
  );
364
365
  throw probeError('card_action_probe_send_failed', '飞书未返回测试卡片的消息 ID');
@@ -371,7 +372,7 @@ export class FeishuRuntime {
371
372
  this.#pendingCardActionProbes.delete(messageId);
372
373
  void this.#sendCardActionProbeNotice(
373
374
  operatorOpenId,
374
- CALLBACK_PROBE_TIMEOUT_NOTICE,
375
+ t(CALLBACK_PROBE_TIMEOUT_NOTICE),
375
376
  'timeout',
376
377
  );
377
378
  reject(probeError(
@@ -413,7 +414,7 @@ export class FeishuRuntime {
413
414
  // the callback proof itself.
414
415
  void this.#sendCardActionProbeNotice(
415
416
  operatorOpenId,
416
- CALLBACK_PROBE_SUCCESS_NOTICE,
417
+ t(CALLBACK_PROBE_SUCCESS_NOTICE),
417
418
  'success',
418
419
  ).finally(() => {
419
420
  probe.resolve({
@@ -482,10 +483,10 @@ export class FeishuRuntime {
482
483
  return sendRememberedConnectionTest({
483
484
  state: this.#state,
484
485
  text,
485
- channelLabel: '飞书机器人',
486
+ channelLabel: t('飞书机器人'),
486
487
  send: async (target, content) => {
487
488
  const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
488
- if (!chatId) throw connectionTestTargetUnavailable('飞书机器人');
489
+ if (!chatId) throw connectionTestTargetUnavailable(t('飞书机器人'));
489
490
  await send('chat_id', chatId, content);
490
491
  },
491
492
  });
@@ -500,7 +501,7 @@ export class FeishuRuntime {
500
501
  clearTimeout(probe.timeout);
501
502
  void this.#sendCardActionProbeNotice(
502
503
  probe.expectedOperatorOpenId,
503
- CALLBACK_PROBE_ABORT_NOTICE,
504
+ t(CALLBACK_PROBE_ABORT_NOTICE),
504
505
  'abort',
505
506
  );
506
507
  probe.reject(probeError('abort', '飞书运行时已停止'));
@@ -1,4 +1,5 @@
1
1
  import { ImagePromptError } from '../shared/image-prompt.mjs';
2
+ import { t } from '../shared/i18n.mjs';
2
3
 
3
4
  const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
4
5
  const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
@@ -113,7 +114,7 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
113
114
  throw new ImagePromptError(
114
115
  'image-too-large',
115
116
  `Feishu image exceeds ${maxBytes} bytes`,
116
- '图片超过 5 MB,请压缩后重试。',
117
+ t('图片超过 5 MB,请压缩后重试。'),
117
118
  );
118
119
  }
119
120
  chunks.push(data);
@@ -221,7 +222,7 @@ async function feishuImageDownloadError(error, signal) {
221
222
  return new ImagePromptError(
222
223
  'feishu-image-permission-required',
223
224
  'Feishu image download requires the im:message:readonly tenant scope',
224
- FEISHU_IMAGE_PERMISSION_MESSAGE,
225
+ t(FEISHU_IMAGE_PERMISSION_MESSAGE),
225
226
  { cause: error },
226
227
  );
227
228
  }
@@ -249,7 +250,7 @@ function feishuImageSource(event, client, key) {
249
250
  throw new ImagePromptError(
250
251
  'image-too-large',
251
252
  `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
252
- '图片超过 5 MB,请压缩后重试。',
253
+ t('图片超过 5 MB,请压缩后重试。'),
253
254
  );
254
255
  }
255
256
  return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
 
3
+ import { t } from '../shared/i18n.mjs';
3
4
  import { harnessApprovalText } from '../shared/harness-approval.mjs';
4
5
  import {
5
6
  harnessAnswerForQuestion,
@@ -21,23 +22,23 @@ function clean(value, max = 8_000) {
21
22
 
22
23
  function safeFailure(error) {
23
24
  if (error?.code === 'office-job-alias-invalid') return error.message;
24
- if (error?.code === 'turn-stopped') return '本机 Harness 已停止这次执行。';
25
- if (error?.code === 'office-job-conflict') return 'Office Job 已被领取、取消或结束。';
26
- return '本机 Harness 未能完成任务;请检查 Harness 会话后重试。';
25
+ if (error?.code === 'turn-stopped') return t('本机 Harness 已停止这次执行。');
26
+ if (error?.code === 'office-job-conflict') return t('Office Job 已被领取、取消或结束。');
27
+ return t('本机 Harness 未能完成任务;请检查 Harness 会话后重试。');
27
28
  }
28
29
 
29
30
  function renderPrompt(job, preset) {
30
31
  return [
31
32
  '# AI Office Handoff',
32
33
  '',
33
- '你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。',
34
- '只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。',
34
+ t('你正在本机 DeepSeek Harness 中继续一个来自 AI Office 的任务。'),
35
+ t('只在当前 Workspace 内行动。完成后必须返回:结果摘要、改动文件、验证证据、未解决风险。'),
35
36
  '',
36
- '## 本机 Instruction Preset',
37
+ t('## 本机 Instruction Preset'),
37
38
  preset,
38
39
  '',
39
- ...(clean(job.instruction) ? ['## 本轮补充指令', clean(job.instruction), ''] : []),
40
- '## Office 时间线',
40
+ ...(clean(job.instruction) ? [t('## 本轮补充指令'), clean(job.instruction), ''] : []),
41
+ t('## Office 时间线'),
41
42
  clean(job.markdown, 200_000),
42
43
  ].join('\n');
43
44
  }
@@ -54,7 +55,7 @@ function approvalRequest(interaction) {
54
55
  return {
55
56
  id,
56
57
  kind: 'approval',
57
- title: `批准 ${clean(interaction.payload?.toolName, 120) || 'Harness 工具'}`,
58
+ title: t('批准 {tool}', { tool: clean(interaction.payload?.toolName, 120) || t('Harness 工具') }),
58
59
  prompt,
59
60
  toolName: clean(interaction.payload?.toolName, 120),
60
61
  };
@@ -66,7 +67,7 @@ function approvalRequest(interaction) {
66
67
  return {
67
68
  id,
68
69
  kind: 'question',
69
- title: questions.length > 1 ? `Harness 需要 ${questions.length} 项补充信息` : clean(questions[0].header || questions[0].question, 160),
70
+ title: questions.length > 1 ? t('Harness 需要 {count} 项补充信息', { count: questions.length }) : clean(questions[0].header || questions[0].question, 160),
70
71
  prompt: questions.map((question, index) => harnessQuestionText(question, index, questions.length)).join('\n\n---\n\n'),
71
72
  questions,
72
73
  };
@@ -216,14 +217,14 @@ export class OfficeJobExecutor {
216
217
  const workspace = this.#config.workspaces[job.workspaceAlias];
217
218
  const preset = this.#config.instructionPresets[job.instructionPreset];
218
219
  if (!workspace || !preset) {
219
- const error = new Error('Office Job 引用了本机未配置的 Workspace/Preset alias。');
220
+ const error = new Error(t('Office Job 引用了本机未配置的 Workspace/Preset alias。'));
220
221
  error.code = 'office-job-alias-invalid';
221
222
  throw error;
222
223
  }
223
224
  entry.harness = this.#createHarness({ workspace });
224
- await this.#progress(jobId, entry, { kind: 'status', message: `已领取 Job,准备 Workspace alias:${job.workspaceAlias}` }, true);
225
+ await this.#progress(jobId, entry, { kind: 'status', message: t('已领取 Job,准备 Workspace alias{alias}', { alias: job.workspaceAlias }) }, true);
225
226
  entry.sessionId = await entry.harness.createSession({ signal, workspace });
226
- await this.#progress(jobId, entry, { kind: 'status', message: 'Harness Session 已创建。', sessionId: entry.sessionId }, true);
227
+ await this.#progress(jobId, entry, { kind: 'status', message: t('Harness Session 已创建。'), sessionId: entry.sessionId }, true);
227
228
  const answer = await entry.harness.ask(entry.sessionId, renderPrompt(job, preset), {
228
229
  timeoutMs: 30 * 60_000,
229
230
  signal,
@@ -300,7 +301,7 @@ export class OfficeJobExecutor {
300
301
  }
301
302
 
302
303
  async #handleUpdate(jobId, entry, update) {
303
- const message = update?.type === 'tool' ? `正在使用 ${clean(update.name, 160) || 'Harness 工具'}…`
304
+ const message = update?.type === 'tool' ? t('正在使用 {tool}…', { tool: clean(update.name, 160) || t('Harness 工具') })
304
305
  : clean(update?.text, 4_000);
305
306
  if (!message) return;
306
307
  const key = `${update.type}:${message}`;
@@ -1,5 +1,6 @@
1
1
  import { setTimeout as sleep } from 'node:timers/promises';
2
2
 
3
+ import { t } from '../shared/i18n.mjs';
3
4
  import { OfficeTransport } from './office-transport.mjs';
4
5
  import { OFFICE_PROTOCOL_VERSION } from './protocol.mjs';
5
6
  import { OfficeJobExecutor } from './office-job-executor.mjs';
@@ -9,12 +10,12 @@ const RETRY_DELAYS = Object.freeze([1_000, 3_000, 10_000, 30_000]);
9
10
  function safeConnectionError(error) {
10
11
  const code = typeof error?.code === 'string' ? error.code : 'office-connection-failed';
11
12
  const messages = {
12
- 'invalid-device-token': 'AI Office 拒绝了 Device Token。',
13
- 'office-hook-unavailable': 'AI Office Connector Hook 尚未就绪。',
14
- 'office-protocol-mismatch': 'AI Office Connector 协议版本不兼容。',
15
- 'office-transport-failed': '本机暂时无法访问 AI Office。',
13
+ 'invalid-device-token': t('AI Office 拒绝了 Device Token。'),
14
+ 'office-hook-unavailable': t('AI Office Connector Hook 尚未就绪。'),
15
+ 'office-protocol-mismatch': t('AI Office Connector 协议版本不兼容。'),
16
+ 'office-transport-failed': t('本机暂时无法访问 AI Office。'),
16
17
  };
17
- return { code, message: messages[code] ?? 'AI Office 连接已中断。' };
18
+ return { code, message: messages[code] ?? t('AI Office 连接已中断。') };
18
19
  }
19
20
 
20
21
  export class OfficeRuntime {
@@ -2,6 +2,8 @@ import { createHash } 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
 
7
9
  function cleanString(value) {
@@ -30,7 +32,7 @@ export function deriveQqBotIdentity(appId) {
30
32
 
31
33
  export function maskQqAppId(appId) {
32
34
  const value = cleanString(appId) ?? '';
33
- if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : 'QQ机器人';
35
+ if (value.length <= 10) return value ? `${value.slice(0, 3)}•••` : t('QQ机器人');
34
36
  return `${value.slice(0, 6)}••••${value.slice(-4)}`;
35
37
  }
36
38
 
@@ -1,11 +1,13 @@
1
1
  // QQ markdown 回复投递:长文尽量按结构边界切分,以 msg_type=2 发送,
2
2
  // 平台拒绝 markdown 时逐条回退纯文本。
3
3
 
4
+ import { t } from '../shared/i18n.mjs';
5
+
4
6
  const DEFAULT_CHUNK_LIMIT = 4_500;
5
7
  const CODE_FENCE_OPEN = /^```/;
6
8
  const GFM_TABLE_LINE = /^\|.+\|$/;
7
9
  const PASSIVE_REPLY_LIMIT = Object.freeze({ c2c: 4, group: 5 });
8
- const PARTIAL_REPLY_NOTICE = '回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。';
10
+ const PARTIAL_REPLY_NOTICE = () => t('回答较长,后续内容未能通过 QQ 完整发送,请回复“继续”。');
9
11
 
10
12
  function safeSliceIndex(value, limit) {
11
13
  let index = Math.min(limit, value.length);
@@ -141,7 +143,7 @@ export async function sendMarkdownReply(bot, target, text, { logger } = {}) {
141
143
  if (partialNoticeSent || !target?.msgId) return;
142
144
  partialNoticeSent = true;
143
145
  try {
144
- results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE));
146
+ results.push(await bot.sendText(target, PARTIAL_REPLY_NOTICE()));
145
147
  } catch (error) {
146
148
  logger?.warn?.('[dsh-im:qq] unable to send partial reply notice:', error);
147
149
  }