@xmanrui/dsh-im 4.36.1 → 4.38.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 (63) hide show
  1. package/README.en.md +14 -2
  2. package/README.md +13 -1
  3. package/assets/logo-plugin-message-link-gradient.webp +0 -0
  4. package/lib/client.js +417 -189
  5. package/lib/index.js +317 -316
  6. package/package.json +2 -2
  7. package/plugin-src/client/bot-general-settings.js +120 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +3 -1
  9. package/plugin-src/client/channels/feishu/api.js +6 -2
  10. package/plugin-src/client/channels/qq/api.js +3 -1
  11. package/plugin-src/client/channels/shared/token-api.js +3 -1
  12. package/plugin-src/client/channels/wecom/api.js +3 -1
  13. package/plugin-src/client/channels/wecom-app/api.js +3 -1
  14. package/plugin-src/client/channels/weixin/api.js +3 -1
  15. package/plugin-src/client/channels/whatsapp/api.js +3 -1
  16. package/plugin-src/client/delivery-settings.js +5 -1
  17. package/plugin-src/client/i18n.js +14 -0
  18. package/plugin-src/client/styles.js +19 -0
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  20. package/plugin-src/host/channels/email/rpc.mjs +1 -0
  21. package/plugin-src/host/channels/feishu/production.mjs +1 -0
  22. package/plugin-src/host/channels/feishu/rpc.mjs +31 -0
  23. package/plugin-src/host/channels/imessage/rpc.mjs +1 -0
  24. package/plugin-src/host/channels/matrix/rpc.mjs +10 -0
  25. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  26. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  27. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  28. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  29. package/plugin-src/host/channels/wecom-app/rpc.mjs +10 -0
  30. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  31. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  32. package/scripts/verify-package.mjs +11 -6
  33. package/src/channels/dingtalk/dingtalk-bridge.mjs +31 -2
  34. package/src/channels/dingtalk/dingtalk-menu.mjs +1 -1
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +2 -1
  36. package/src/channels/discord/discord-runtime.mjs +3 -1
  37. package/src/channels/feishu/bridge.mjs +91 -15
  38. package/src/channels/feishu/feishu-cards.mjs +16 -11
  39. package/src/channels/feishu/feishu-runtime.mjs +10 -1
  40. package/src/channels/feishu/multi-bot-controller.mjs +18 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +2 -1
  42. package/src/channels/feishu/slash-command-panel.mjs +1 -0
  43. package/src/channels/feishu/step-push-mode.mjs +21 -0
  44. package/src/channels/matrix/matrix-crypto.mjs +19 -5
  45. package/src/channels/qq/qq-bridge.mjs +40 -2
  46. package/src/channels/qq/qq-menu.mjs +2 -2
  47. package/src/channels/shared/bot-workspace-store.mjs +69 -1
  48. package/src/channels/shared/busy-message-dispatcher.mjs +94 -0
  49. package/src/channels/shared/clear-command.mjs +85 -0
  50. package/src/channels/shared/command-catalog.mjs +3 -0
  51. package/src/channels/shared/command-permission.mjs +2 -0
  52. package/src/channels/shared/control-command.mjs +20 -11
  53. package/src/channels/shared/harness-approval.mjs +102 -2
  54. package/src/channels/shared/i18n-en/feishu.mjs +6 -4
  55. package/src/channels/shared/i18n-en/qq.mjs +2 -1
  56. package/src/channels/shared/i18n-en/shared-b.mjs +27 -0
  57. package/src/channels/shared/i18n-en/shared-c.mjs +4 -0
  58. package/src/channels/shared/message-mode.mjs +19 -0
  59. package/src/channels/shared/text-harness-bridge.mjs +48 -5
  60. package/src/channels/wecom/wecom-bridge.mjs +26 -2
  61. package/src/channels/wecom/wecom-cards.mjs +1 -1
  62. package/src/channels/wecom-app/wecom-app-bridge.mjs +26 -2
  63. package/src/channels/weixin/weixin-bridge.mjs +22 -2
@@ -19,6 +19,11 @@ const APPROVAL_UNAVAILABLE_TEXT = '机器人端无法处理这次审批,请到
19
19
  const APPROVAL_PROMPT = '请精准回复「批准」或「拒绝」(也支持:同意 / 不同意 / yes / no)。';
20
20
  const APPROVAL_AFTER_QUESTION_PROMPT = '请先完成当前问题,再精准回复「批准」或「拒绝」。';
21
21
  const APPROVAL_RESOLVED_TEXT = '该审批已处理,无需再次回复。';
22
+ const APPROVAL_TIMEOUT_TEXT = '审批已超时,已自动拒绝此次操作。';
23
+ const APPROVAL_TIMEOUT_UNCONFIRMED_TEXT = '审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。';
24
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 60 * 60_000;
25
+ const EXPIRY_RETRY_BASE_MS = 30_000;
26
+ const EXPIRY_RETRY_MAX_MS = 5 * 60_000;
22
27
  const RESOLVED_ROUTE_TTL_MS = 5 * 60_000;
23
28
  const MAX_RESOLVED_ROUTES = 2_048;
24
29
 
@@ -102,6 +107,7 @@ function approvalResult(pending, outcome) {
102
107
  }
103
108
 
104
109
  function approvalOutcomeText(outcome) {
110
+ if (outcome === 'expired') return t(APPROVAL_TIMEOUT_TEXT);
105
111
  if (outcome === 'unavailable') return t(APPROVAL_UNAVAILABLE_TEXT);
106
112
  if (outcome === 'allowed-once') return t('已批准,仅对本次操作有效。');
107
113
  if (outcome === 'rejected') return t('已拒绝此次操作。');
@@ -114,10 +120,14 @@ export class HarnessApprovalQueue {
114
120
  #byId = new Map();
115
121
  #routes = new Map();
116
122
  #resolvedRoutes = new Map();
123
+ #approvalTimeoutMs;
117
124
 
118
- constructor({ label = 'IM', logger = console } = {}) {
125
+ constructor({ label = 'IM', logger = console, approvalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS } = {}) {
119
126
  this.#label = label;
120
127
  this.#logger = logger;
128
+ this.#approvalTimeoutMs = Number.isFinite(approvalTimeoutMs) && approvalTimeoutMs > 0
129
+ ? approvalTimeoutMs
130
+ : DEFAULT_APPROVAL_TIMEOUT_MS;
121
131
  }
122
132
 
123
133
  hasPending(key) {
@@ -184,6 +194,10 @@ export class HarnessApprovalQueue {
184
194
  }
185
195
  await pending.activationTask?.catch(() => undefined);
186
196
  await pending.presentationTask?.catch(() => undefined);
197
+ if (!pending.inactive && pending.expiring) {
198
+ await send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT));
199
+ return;
200
+ }
187
201
  if (pending.inactive || pending.resolving) {
188
202
  await send(t(APPROVAL_RESOLVED_TEXT));
189
203
  return;
@@ -338,11 +352,22 @@ export class HarnessApprovalQueue {
338
352
  closedOutcome: null,
339
353
  resolutionNotified: false,
340
354
  activationTask: null,
355
+ timeoutTimer: null,
356
+ expired: false,
357
+ expiring: false,
358
+ expiryAttempts: 0,
359
+ expiryNoticeSent: false,
341
360
  };
342
361
  this.#byId.set(approvalId, pending);
343
362
  const route = this.#routes.get(key) ?? { items: [] };
344
363
  route.items.push(pending);
345
364
  this.#routes.set(key, route);
365
+ pending.timeoutTimer = setTimeout(() => {
366
+ void this.#expire(pending).catch((error) => {
367
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
368
+ });
369
+ }, this.#approvalTimeoutMs);
370
+ pending.timeoutTimer.unref?.();
346
371
  if (route.items[0] === pending) await this.#present(pending);
347
372
  return true;
348
373
  }
@@ -377,13 +402,15 @@ export class HarnessApprovalQueue {
377
402
  const shouldNotify = pending.presented || presentationTask;
378
403
  const send = pending.send;
379
404
  const next = this.#remove(pending);
405
+ // The host reports our own timeout rejection as a plain rejection.
406
+ const outcome = pending.expiring && resolution.outcome === 'rejected' ? 'expired' : resolution.outcome;
380
407
  await this.#transition(next, async () => {
381
408
  let delivered = pending.presented;
382
409
  if (presentationTask) {
383
410
  delivered = await presentationTask.then(() => true, () => false);
384
411
  }
385
412
  if (shouldNotify && delivered) {
386
- await this.#notifyResolved(pending, resolution.outcome, send);
413
+ await this.#notifyResolved(pending, outcome, send);
387
414
  }
388
415
  });
389
416
  return true;
@@ -453,6 +480,70 @@ export class HarnessApprovalQueue {
453
480
  }
454
481
  }
455
482
 
483
+ async #expire(pending) {
484
+ pending.timeoutTimer = null;
485
+ pending.expired = true;
486
+ // An in-flight decision wins; #submit expires the approval if it fails.
487
+ if (pending.inactive || pending.resolving || pending.submitting) return;
488
+ pending.resolving = true;
489
+ pending.expiring = true;
490
+ // Keep the route barrier: an earlier confirmation must finish before a
491
+ // later approval can be presented.
492
+ await pending.activationTask?.catch(() => undefined);
493
+ if (pending.inactive || this.#byId.get(pending.approvalId) !== pending) return;
494
+ await this.#releaseExpired(pending);
495
+ }
496
+
497
+ async #releaseExpired(pending) {
498
+ pending.timeoutTimer = null;
499
+ if (pending.inactive) return;
500
+ let outcome = 'expired';
501
+ try {
502
+ // Reject rather than withdraw: a withdrawal only retires the IM side and
503
+ // would leave a shared Web approval waiting without a bound.
504
+ await pending.interaction.respond(
505
+ approvalResult(pending, 'rejected'),
506
+ { signal: AbortSignal.timeout(5_000) },
507
+ );
508
+ } catch (error) {
509
+ if (error?.code !== 'interaction-not-pending') {
510
+ if (pending.inactive) return;
511
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to release an expired approval:`, error);
512
+ this.#retryExpiry(pending);
513
+ if (!pending.expiryNoticeSent && pending.presented) {
514
+ pending.expiryNoticeSent = true;
515
+ await pending.send(t(APPROVAL_TIMEOUT_UNCONFIRMED_TEXT)).catch(() => undefined);
516
+ }
517
+ return;
518
+ }
519
+ outcome = 'resolved';
520
+ }
521
+ if (pending.inactive) return;
522
+ const presentationTask = pending.presentationTask;
523
+ const shouldNotify = pending.presented || presentationTask;
524
+ const send = pending.send;
525
+ const next = this.#remove(pending);
526
+ pending.closedOutcome = outcome;
527
+ await this.#transition(next, async () => {
528
+ let delivered = pending.presented;
529
+ if (presentationTask) {
530
+ delivered = await presentationTask.then(() => true, () => false);
531
+ }
532
+ if (shouldNotify && delivered) await this.#notifyResolved(pending, outcome, send);
533
+ });
534
+ }
535
+
536
+ #retryExpiry(pending) {
537
+ const delay = Math.min(EXPIRY_RETRY_BASE_MS * 2 ** pending.expiryAttempts, EXPIRY_RETRY_MAX_MS);
538
+ pending.expiryAttempts += 1;
539
+ pending.timeoutTimer = setTimeout(() => {
540
+ void this.#releaseExpired(pending).catch((error) => {
541
+ this.#logger.warn?.(`[dsh-im:${this.#label}] failed to expire an approval:`, error);
542
+ });
543
+ }, delay);
544
+ pending.timeoutTimer.unref?.();
545
+ }
546
+
456
547
  async #submit(pending, outcome) {
457
548
  pending.submitting = true;
458
549
  try {
@@ -478,6 +569,11 @@ export class HarnessApprovalQueue {
478
569
  if (pending.inactive) return;
479
570
  pending.submitting = false;
480
571
  this.#logger.error?.(`[dsh-im:${this.#label}] failed to submit an approval:`, error);
572
+ // The deadline passed while this decision was in flight.
573
+ if (pending.expired) {
574
+ await this.#expire(pending);
575
+ return;
576
+ }
481
577
  await pending.send(t('审批提交失败,请重新回复「批准」或「拒绝」。')).catch(() => undefined);
482
578
  return;
483
579
  }
@@ -536,6 +632,10 @@ export class HarnessApprovalQueue {
536
632
  #remove(pending) {
537
633
  if (pending.inactive) return null;
538
634
  pending.inactive = true;
635
+ if (pending.timeoutTimer) {
636
+ clearTimeout(pending.timeoutTimer);
637
+ pending.timeoutTimer = null;
638
+ }
539
639
  this.#rememberResolvedRoute(pending.key);
540
640
  this.#byId.delete(pending.approvalId);
541
641
  const route = this.#routes.get(pending.key);
@@ -259,10 +259,10 @@ export default {
259
259
  '/presetlist or /presets List available Agent Presets',
260
260
  '🎮 任务控制': '🎮 Task controls',
261
261
  '/steer 指令 给 Agent 补充指令': '/steer INSTRUCTION Steer the Agent',
262
- '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 补充指令 — 给 Agent 发送指令\n10. 🗄 归档切换 — 显示/隐藏归档会话\n11. 📊 状态 — 查看系统连接状态\n12. 📖 帮助 — 查看本帮助':
263
- '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. Steer task — send an instruction to the Agent\n10. 🗄 Archived toggle — show or hide archived sessions\n11. 📊 Status — view connection status\n12. 📖 Help — view this help',
264
- '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
265
- '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/sessionlist --limit N` or `/sessions --limit N` — list only the first N sessions in the current workspace\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
262
+ '**📋 卡片功能**\n\n1. 会话下拉 — 切换当前绑定会话\n2. 工作区下拉 — 切换工作区\n3. 🤖 预设下拉 — 切换 Agent 预设\n4. 🧠 模型下拉 — 切换模型\n5. 🆕 新会话 — 开启全新会话\n6. 📋 会话/关注 — 查看/绑定会话,管理关注\n7. ⏹ 停止 — 停止当前任务\n8. 📐 压缩 — 压缩当前会话上下文\n9. 🧹 清空上下文 — 清空上下文并保留绑定和历史记录\n10. 补充指令 — 给 Agent 发送指令\n11. 🗄 归档切换 — 显示/隐藏归档会话\n12. 📊 状态 — 查看系统连接状态\n13. 📖 帮助 — 查看本帮助':
263
+ '**📋 Card features**\n\n1. Session dropdown — switch the bound session\n2. Workspace dropdown — switch workspace\n3. 🤖 Preset dropdown — switch Agent Preset\n4. 🧠 Model dropdown — switch model\n5. 🆕 New session — start fresh\n6. 📋 Sessions/watches — view or bind sessions and manage watches\n7. ⏹ Stop — stop the current task\n8. 📐 Compact — compact the current session context\n9. 🧹 Clear context — clear context while keeping the binding and history\n10. Steer task — send an instruction to the Agent\n11. 🗄 Archived toggle — show or hide archived sessions\n12. 📊 Status — view connection status\n13. 📖 Help — view this help',
264
+ '**⌨️ 文本命令**\n\n`/m` — 打开菜单卡片\n`/new` — 开启全新会话\n`/session ID` — 绑定已有会话\n`/sessionlist [工作区]` 或 `/sessions [工作区]` — 列出会话\n`/sessionlist --limit N` 或 `/sessions --limit N` — 仅列出当前工作区前 N 个会话\n`/workspace 工作区序号或绝对路径` — 切换工作区\n`/workspacelist` — 列出工作区\n`/status` — 查看连接状态\n`/compact` — 压缩上下文\n`/clear` — 清空上下文并保留 Session 绑定和历史记录\n`/stop` — 停止当前任务\n`/steer 指令` — 补充指令\n`/watch ID` — 关注会话\n`/watchlist` — 关注列表\n`/unwatch ID` — 取消关注\n`/archived on/off` — 归档显隐\n`/presetlist` 或 `/presets` — 列出预设\n`/preset [序号/ID]` — 切换预设\n`/preset --default` — 跟随默认\n`/models` — 列出模型\n`/reasoninglist` 或 `/reasonings` — 按序号列出当前模型可用推理等级\n`/reasoning [序号、等级ID或 --default]` — 查看或切换当前推理等级\n`/model [序号或完整模型ID] [推理等级ID]` — 查看或切换当前会话模型\n`/batch` — 开启批量输入(仅私聊,最多 10 条文字)\n`/send` — 提交当前批次\n`/cancel` — 取消当前批次\n`/repair` — 补全飞书权限与卡片回调':
265
+ '**⌨️ Text commands**\n\n`/m` — open the menu card\n`/new` — start a new session\n`/session ID` — bind an existing session\n`/sessionlist [workspace]` or `/sessions [workspace]` — list sessions\n`/sessionlist --limit N` or `/sessions --limit N` — list only the first N sessions in the current workspace\n`/workspace <workspace index or absolute path>` — switch workspace\n`/workspacelist` — list workspaces\n`/status` — view connection status\n`/compact` — compact context\n`/clear` — clear context while keeping the Session binding and history\n`/stop` — stop the current task\n`/steer INSTRUCTION` — steer the task\n`/watch ID` — watch a session\n`/watchlist` — list watched sessions\n`/unwatch ID` — stop watching\n`/archived on/off` — show or hide archived sessions\n`/presetlist` or `/presets` — list presets\n`/preset [index/ID]` — switch preset\n`/preset --default` — follow default\n`/models` — list models\n`/reasoninglist` or `/reasonings` — list reasoning efforts for the current model\n`/reasoning [index, effort ID, or --default]` — show or switch reasoning effort\n`/model [index or full model ID] [reasoning effort ID]` — show or switch the current Session model\n`/batch` — start batch input (direct messages only, up to 10 text messages)\n`/send` — submit the current batch\n`/cancel` — cancel the current batch\n`/repair` — complete Feishu permissions and the card callback',
266
266
  '**💡 数字兜底**\n回复数字快速操作:\n**1**工作区列表 · **2**新会话 · **3**会话/关注\n**4**状态 · **5**补全权限 · **6**帮助':
267
267
  '**💡 Number fallback**\nReply with a number for a quick action:\n**1** Workspace list · **2** New session · **3** Sessions/watches\n**4** Status · **5** Complete permissions · **6** Help',
268
268
  '从下方下拉选择补充指令;最后一项可自定义输入。':
@@ -319,6 +319,8 @@ export default {
319
319
  '/watch ID 或序号 关注会话(完成后推送)':
320
320
  '/watch ID or index Watch a session (push when done)',
321
321
  '/compact 压缩上下文': '/compact Compact the context',
322
+ '🧹 清空上下文': '🧹 Clear context',
323
+ '清空当前会话上下文并保留绑定和历史记录': 'Clear the current session context while keeping the binding and history',
322
324
  '/workspace 绝对路径 切换工作区': '/workspace <absolute path> Switch workspace',
323
325
  '/presetlist 列出可用 Agent Preset': '/presetlist List available Agent Presets',
324
326
  '任务完成会自动推送,回复数字或点按钮取消关注:':
@@ -29,7 +29,8 @@ export default {
29
29
  '发送 /m 打开助手中心,点击按钮或回复当前列表的数字选择。': 'Send /m to open the assistant menu. Click a button or reply with a number from the current list.',
30
30
  '设置:会话、工作区、模式/预设、模型。': 'Settings: session, workspace, mode / preset, model.',
31
31
  '会话:新会话、会话列表、归档显示切换。': 'Sessions: new session, session list, show or hide archived sessions.',
32
- '任务:停止、压缩、快捷或自定义补充指令。': 'Tasks: stop, compact, quick or custom steering instructions.',
32
+ '任务:停止、压缩、清空上下文、快捷或自定义补充指令。':
33
+ 'Tasks: stop, compact, clear context, quick or custom steering instructions.',
33
34
  '信息:状态、帮助。': 'Information: status, help.',
34
35
  '菜单有效期为 15 分钟;发送普通消息会退出数字选择。': 'Menus expire after 15 minutes. Sending an ordinary message exits number selection.',
35
36
  '回答问题、审批和批量输入时,数字优先用于当前交互。': 'During questions, approvals and batch input, numbers belong to that interaction.',
@@ -244,6 +244,32 @@ export default {
244
244
  '当前 Harness 未注册 /compact 命令,请确认上下文压缩组件已启用。':
245
245
  'The current Harness has not registered the /compact command; please make sure the context compaction component is enabled.',
246
246
 
247
+ // clear-command.mjs
248
+ '/clear 清空当前会话上下文(保留 Session 绑定和历史记录)':
249
+ '/clear Clear the current session context (keep the Session binding and history)',
250
+ '清空当前会话上下文并保留会话绑定':
251
+ 'Clear the current session context and keep the Session binding',
252
+ '用法:/clear(不带参数且不可附带图片或文件)':
253
+ 'Usage: /clear (no arguments, images or files)',
254
+ '当前聊天绑定的会话已不存在,未清空上下文;请先发送新消息开启会话。':
255
+ 'The Session bound to this chat no longer exists; the context was not cleared. Send a new message to start a Session first.',
256
+ '当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。':
257
+ 'The current Session is generating a reply or waiting for an interaction. Finish it or send /stop before running /clear.',
258
+ '上下文清理已取消,当前会话未修改。':
259
+ 'Context clearing was cancelled; the current Session was not changed.',
260
+ '工作区或机器人状态已发生变化,未清空当前会话,请重试。':
261
+ 'The workspace or bot state changed, so the current Session was not cleared. Please try again.',
262
+ '当前 Host 不支持 /clear,当前会话未修改。':
263
+ 'The current Host does not support /clear; the current Session was not changed.',
264
+ '清空当前会话上下文失败,当前会话未修改,请稍后重试。':
265
+ 'Failed to clear the current Session context; the Session was not changed. Please try again later.',
266
+ '当前机器人没有可用的会话状态,未清空当前会话。':
267
+ 'This bot has no available Session state; the current Session was not cleared.',
268
+ '当前聊天还没有可清空的会话,请先发送一条消息。':
269
+ 'This chat has no Session to clear yet; please send a message first.',
270
+ '当前会话上下文已清空;Session 绑定和历史记录仍保留。':
271
+ 'The current Session context was cleared; its binding and history were retained.',
272
+
247
273
  // control-command.mjs
248
274
  '用法:/stop(不带参数)': 'Usage: /stop (no arguments)',
249
275
  '用法:/version(不带参数)': 'Usage: /version (no arguments)',
@@ -258,6 +284,7 @@ export default {
258
284
  'No session is bound to this chat, so an additional instruction cannot be given. Bind a session first.',
259
285
  '任务已结束,没有正在运行的任务,无法补充指令。请直接发送消息开始新任务。':
260
286
  'The task has ended and there is no running task to steer. Send a message to start a new task.',
287
+ '已排队,等待前序消息处理。': 'Queued. Waiting for the preceding messages to finish.',
261
288
  '已提交补充指令,Agent 会在下一步读取。':
262
289
  'Additional instruction submitted; the Agent will read it at the next step.',
263
290
  };
@@ -56,6 +56,10 @@ export default {
56
56
  'Please finish the current question first, then reply exactly with 「批准」 (approve) or 「拒绝」 (reject).',
57
57
  '该审批已处理,无需再次回复。':
58
58
  'This approval has already been handled; no need to reply again.',
59
+ '审批已超时,已自动拒绝此次操作。':
60
+ 'This approval timed out and the operation was rejected automatically.',
61
+ '审批已超时,正在自动拒绝,但暂未得到确认,稍后会自动重试。':
62
+ 'This approval timed out. Automatic rejection has not been confirmed yet and will be retried shortly.',
59
63
  'DeepSeek Harness 需要你的审批:':
60
64
  'DeepSeek Harness needs your approval:',
61
65
  '工具:{tool}': 'Tool: {tool}',
@@ -0,0 +1,19 @@
1
+ export const SET_MESSAGE_MODE_ENDPOINT = 'bot.message-mode.set';
2
+
3
+ export function normalizeBusyMessageMode(value) {
4
+ return value === 'steer' ? 'steer' : 'queue';
5
+ }
6
+
7
+ export function validateBusyMessageMode(value) {
8
+ if (value !== 'queue' && value !== 'steer') {
9
+ throw new TypeError('Message mode must be queue or steer');
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export function validMessageModePayload(payload) {
15
+ return payload !== null && typeof payload === 'object' && !Array.isArray(payload)
16
+ && Object.keys(payload).length === 2
17
+ && typeof payload.botId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(payload.botId)
18
+ && (payload.busyMessageMode === 'queue' || payload.busyMessageMode === 'steer');
19
+ }
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from './busy-message-dispatcher.mjs';
1
2
  import { randomUUID } from 'node:crypto';
2
3
 
3
4
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from './deferred-delivery-coordinator.mjs';
@@ -14,6 +15,7 @@ import {
14
15
  } from './context-enhancement.mjs';
15
16
  import { runWorkspaceCommand } from './workspace-command.mjs';
16
17
  import { runCompactCommand } from './compact-command.mjs';
18
+ import { isClearCommand, runClearCommand } from './clear-command.mjs';
17
19
  import { isHistoryCommand, runHistoryCommand } from './history-command.mjs';
18
20
  import {
19
21
  isControlCommand,
@@ -178,6 +180,7 @@ export class TextHarnessBridge {
178
180
  #signal;
179
181
  #keepaliveIntervalMs;
180
182
  #queues = new Map();
183
+ #busyMessages = new BusyMessageDispatcher();
181
184
  #pendingInteractions = new Map();
182
185
  #interactionKeys = new Map();
183
186
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -236,7 +239,7 @@ export class TextHarnessBridge {
236
239
  return structuredClone(this.#status);
237
240
  }
238
241
 
239
- accept(message, { contextSnapshot, accessDecision } = {}) {
242
+ accept(message, { contextSnapshot, accessDecision, busyMessageMode } = {}) {
240
243
  if (this.#signal?.aborted) return Promise.resolve();
241
244
  const conversationId = cleanText(message?.conversationId);
242
245
  const kind = message?.kind === 'group' ? 'group' : 'direct';
@@ -300,7 +303,7 @@ export class TextHarnessBridge {
300
303
  });
301
304
  normalized.statusReaction = statusReaction;
302
305
 
303
- const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId);
306
+ const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId, busyMessageMode);
304
307
  void processing.then(
305
308
  () => statusReaction.success(),
306
309
  () => statusReaction.error(),
@@ -308,7 +311,7 @@ export class TextHarnessBridge {
308
311
  return processing;
309
312
  }
310
313
 
311
- #acceptAcceptedMessage(normalized, messageId, senderId) {
314
+ #acceptAcceptedMessage(normalized, messageId, senderId, busyMessageMode) {
312
315
  if (normalized.kind === 'direct') {
313
316
  rememberConnectionTestTarget(
314
317
  this.#state,
@@ -319,6 +322,18 @@ export class TextHarnessBridge {
319
322
  const key = `${normalized.kind}:${normalized.conversationId}`;
320
323
  const pending = this.#pendingInteractions.get(key);
321
324
  const text = controlTextOf(normalized);
325
+ if (isClearCommand(text) && (
326
+ this.#queues.has(key)
327
+ || pending
328
+ || this.#approvals.hasPending(key)
329
+ || this.#batches.status(key).phase !== 'idle'
330
+ )) {
331
+ return this.#finishLocalMessage(
332
+ normalized,
333
+ messageId,
334
+ t('当前会话正在生成回复或等待交互,请先完成交互或发送 /stop,再执行 /clear。'),
335
+ );
336
+ }
322
337
  const batchCommand = isBatchInputCommand(text);
323
338
  if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
324
339
  return this.#finishLocalMessage(
@@ -373,7 +388,8 @@ export class TextHarnessBridge {
373
388
  }
374
389
  const collectingBatch = normalized.kind === 'direct'
375
390
  && this.#batches.status(key).phase === 'collecting';
376
- const commandRunner = collectingBatch ? null : isHistoryCommand(text) ? runHistoryCommand
391
+ const commandRunner = collectingBatch ? null : isClearCommand(text) ? runClearCommand
392
+ : isHistoryCommand(text) ? runHistoryCommand
377
393
  : hasInboundFiles(normalized) ? null : isControlCommand(text)
378
394
  ? runControlCommand
379
395
  : (isModelCommand(text)
@@ -458,7 +474,33 @@ export class TextHarnessBridge {
458
474
  pending.queue = current;
459
475
  return current;
460
476
  }
461
- return this.#enqueueMessage(normalized, messageId, senderId, key);
477
+ return this.#busyMessages.dispatch({
478
+ key, messageId, text: normalized.content,
479
+ commandText: text, mode: busyMessageMode,
480
+ onFailure: (error) => this.#signal?.aborted || error?.code === 'turn-stopped'
481
+ ? normalized.statusReaction?.clear() : normalized.statusReaction?.error(),
482
+ eligible: () => normalized.plainText !== false
483
+ && !hasInboundImages(normalized) && !hasInboundFiles(normalized)
484
+ && !hasReplyReference(normalized)
485
+ && canAutomaticallySteer(this.#accessPolicy, normalized.kind,
486
+ [senderId, normalized.senderAlternateId].filter(Boolean)),
487
+ harness: this.#harness, state: this.#state, status: this.#status,
488
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
489
+ acceptedMessageIds: this.#acceptedMessageIds,
490
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
491
+ enhancement: {
492
+ snapshot: this.#acceptedMessageIds.get(messageId),
493
+ source: () => {
494
+ const source = normalized.contextSource?.();
495
+ return { channel: this.#descriptor.key, senderId, senderName: source?.senderName,
496
+ conversationTitle: source?.conversationTitle,
497
+ chatId: source?.chatId ?? normalized.conversationId, threadId: source?.threadId };
498
+ },
499
+ },
500
+ isQueued: () => this.#queues.has(key),
501
+ enqueue: (options) => this.#enqueueMessage(normalized, messageId, senderId, key, options),
502
+ send: (reply) => this.#bot.sendText(normalized.replyTarget, reply),
503
+ });
462
504
  }
463
505
 
464
506
  #finishLocalMessage(message, messageId, reply, { recordReceived = true } = {}) {
@@ -522,6 +564,7 @@ export class TextHarnessBridge {
522
564
  }
523
565
 
524
566
  async waitForIdle() {
567
+ await this.#busyMessages.whenIdle();
525
568
  await Promise.allSettled([
526
569
  ...this.#queues.values(),
527
570
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { imageInputLimits } from '../shared/image-input-policy.mjs';
2
3
  import { imageDownloadLimitMessage } from '../shared/image-prompt.mjs';
3
4
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
@@ -20,6 +21,7 @@ import {
20
21
  isBatchInputCommand,
21
22
  } from '../shared/batch-input.mjs';
22
23
  import { runCompactCommand } from '../shared/compact-command.mjs';
24
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
23
25
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
24
26
  import {
25
27
  isControlCommand,
@@ -82,6 +84,7 @@ function helpText() {
82
84
  t('/menu 或 /m 打开可点击的功能菜单'),
83
85
  t('/new 开启一个全新会话'),
84
86
  t('/compact 压缩当前会话的较早上下文'),
87
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
85
88
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
86
89
  t('/workspace 工作区序号或绝对路径 切换工作区'),
87
90
  t('/workspacelist 列出工作区绝对路径'),
@@ -565,6 +568,7 @@ export class WecomHarnessBridge {
565
568
  #signal;
566
569
  #fileUploadTimeoutMs;
567
570
  #queues = new Map();
571
+ #busyMessages = new BusyMessageDispatcher();
568
572
  #pendingInteractions = new Map();
569
573
  #interactionKeys = new Map();
570
574
  // Keep the accepted configuration through the existing queue/reply lifecycle.
@@ -870,6 +874,7 @@ export class WecomHarnessBridge {
870
874
  return { message: t('已开启新会话。请发送你的问题。') };
871
875
  }
872
876
  return await runWorkspaceCommand(text, this.#harness, key)
877
+ ?? await runClearCommand(text, this.#harness, this.#state, key, options)
873
878
  ?? await runCompactCommand(text, this.#harness, this.#state, key, options);
874
879
  }
875
880
 
@@ -951,7 +956,8 @@ export class WecomHarnessBridge {
951
956
  const menuCommand = !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
952
957
  && (parseWecomMenu(commandText) || ['/help', '/status', '/new'].includes(commandText.toLowerCase())
953
958
  || this.#cardFrames.has(frame));
954
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
959
+ const commandRunner = isClearCommand(commandText) ? runClearCommand
960
+ : isHistoryCommand(commandText) ? runHistoryCommand
955
961
  : hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
956
962
  ? runControlCommand
957
963
  : (isModelCommand(commandText)
@@ -1048,7 +1054,23 @@ export class WecomHarnessBridge {
1048
1054
  pending.queue = current;
1049
1055
  return current;
1050
1056
  }
1051
- return this.#enqueueMessage(frame, messageId, key);
1057
+ return this.#busyMessages.dispatch({
1058
+ key, messageId, text: commandText,
1059
+ eligible: () => isNativeWecomText(frame) && !hasReplyReference(commandMessage)
1060
+ && !parseWecomMenu(commandText) && !this.#cardFrames.has(frame)
1061
+ && canAutomaticallySteer(this.#accessPolicy, conversationType, senderId),
1062
+ harness: this.#harness, state: this.#state, status: this.#status,
1063
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
1064
+ acceptedMessageIds: this.#acceptedMessageIds,
1065
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
1066
+ enhancement: {
1067
+ snapshot: this.#acceptedMessageIds.get(messageId),
1068
+ source: () => ({ channel: 'wecom', senderId, chatId }),
1069
+ },
1070
+ isQueued: () => this.#queues.has(key),
1071
+ enqueue: (options) => this.#enqueueMessage(frame, messageId, key, options),
1072
+ send: (reply) => this.#sendImmediate(frame, chatId, reply),
1073
+ });
1052
1074
  }
1053
1075
 
1054
1076
  #enqueueMessage(frame, messageId, key, {
@@ -1151,6 +1173,7 @@ export class WecomHarnessBridge {
1151
1173
  }
1152
1174
 
1153
1175
  async waitForIdle() {
1176
+ await this.#busyMessages.whenIdle();
1154
1177
  await Promise.allSettled([
1155
1178
  ...this.#queues.values(),
1156
1179
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -1176,6 +1199,7 @@ export class WecomHarnessBridge {
1176
1199
  hasFiles: hasInboundFiles(message),
1177
1200
  pendingInteraction: this.#pendingInteractions.has(key)
1178
1201
  || this.#approvals.hasPending(key),
1202
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
1179
1203
  control: { owner: this, key },
1180
1204
  deferredDelivery: this.#deferred,
1181
1205
  enhancement: captureContextEnhancementSource(
@@ -48,7 +48,7 @@ export function wecomMenu({ workspace, workspaces = [] } = {}) {
48
48
  entries: [
49
49
  [t('切换工作区'), 'select:workspace'],
50
50
  [t('🆕 新会话'), '/new'],
51
- [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
51
+ [t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'], [t('🧹 清空上下文'), '/clear'],
52
52
  [t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
53
53
  ],
54
54
  };
@@ -1,3 +1,4 @@
1
+ import { BusyMessageDispatcher, canAutomaticallySteer } from '../shared/busy-message-dispatcher.mjs';
1
2
  import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
2
3
  import {
3
4
  harnessAnswerForQuestion,
@@ -11,6 +12,7 @@ import {
11
12
  isBatchInputCommand,
12
13
  } from '../shared/batch-input.mjs';
13
14
  import { runCompactCommand } from '../shared/compact-command.mjs';
15
+ import { isClearCommand, runClearCommand } from '../shared/clear-command.mjs';
14
16
  import { isHistoryCommand, runHistoryCommand } from '../shared/history-command.mjs';
15
17
  import {
16
18
  isControlCommand,
@@ -75,6 +77,7 @@ const HELP_TEXT = () => [
75
77
  t('直接发送文字或图片即可继续当前会话。'),
76
78
  t('/new 开启一个全新会话'),
77
79
  t('/compact 压缩当前会话的较早上下文'),
80
+ t('/clear 清空当前会话上下文(保留 Session 绑定和历史记录)'),
78
81
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
79
82
  t('/workspace 工作区序号或绝对路径 切换工作区'),
80
83
  t('/workspacelist 列出工作区绝对路径'),
@@ -202,6 +205,7 @@ export class WecomAppBridge {
202
205
  #streamRegistry;
203
206
  #maxChunkBytes;
204
207
  #queues = new Map();
208
+ #busyMessages = new BusyMessageDispatcher();
205
209
  #acceptedMessageIds = new Map();
206
210
  #commandTasks = new Set();
207
211
  #approvalTasks = new Set();
@@ -311,7 +315,8 @@ export class WecomAppBridge {
311
315
  return this.#finishBatchResult(messageId, key, sender, result);
312
316
  }
313
317
  }
314
- const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
318
+ const commandRunner = isClearCommand(commandText) ? runClearCommand
319
+ : isHistoryCommand(commandText) ? runHistoryCommand
315
320
  : isControlCommand(commandText)
316
321
  ? runControlCommand
317
322
  : (isModelCommand(commandText)
@@ -385,7 +390,24 @@ export class WecomAppBridge {
385
390
  pending.queue = current;
386
391
  return current;
387
392
  }
388
- return this.#enqueueMessage(message, messageId, key, { sink });
393
+ return this.#busyMessages.dispatch({
394
+ key, messageId, text: commandText,
395
+ eligible: () => message.msgtype === 'text' && !hasImages
396
+ && !hasReplyReference(wecomAppInboundMessage(message, this.#api))
397
+ && canAutomaticallySteer(this.#accessPolicy, 'direct', sender),
398
+ harness: this.#harness, state: this.#state, status: this.#status,
399
+ signal: this.#signal, control: { owner: this, key }, logger: this.#logger,
400
+ acceptedMessageIds: this.#acceptedMessageIds,
401
+ pendingInteraction: () => this.#pendingInteractions.has(key) || this.#approvals.hasPending(key),
402
+ enhancement: {
403
+ snapshot: this.#acceptedMessageIds.get(messageId),
404
+ source: () => ({ channel: 'wecom-app', senderId: sender, chatId: sender }),
405
+ },
406
+ isQueued: () => this.#queues.has(key),
407
+ enqueue: (options) => this.#enqueueMessage(message, messageId, key, { ...options, sink }),
408
+ send: (reply) => this.#send(sender, reply),
409
+ onError: (error) => this.#handleInteractionFailure(message, messageId, error),
410
+ });
389
411
  }
390
412
 
391
413
  #enqueueMessage(message, messageId, key, {
@@ -472,6 +494,7 @@ export class WecomAppBridge {
472
494
  }
473
495
 
474
496
  async waitForIdle() {
497
+ await this.#busyMessages.whenIdle();
475
498
  await Promise.allSettled([
476
499
  ...this.#queues.values(),
477
500
  ...[...this.#pendingInteractions.values()].flatMap((pending) => (
@@ -501,6 +524,7 @@ export class WecomAppBridge {
501
524
  hasFiles: false,
502
525
  pendingInteraction: this.#pendingInteractions.has(key)
503
526
  || this.#approvals.hasPending(key),
527
+ busy: this.#queues.has(key) || this.#batchInputs.status(key).phase !== 'idle',
504
528
  control: { owner: this, key },
505
529
  deferredDelivery: this.#deferred,
506
530
  enhancement: captureContextEnhancementSource(