@xmanrui/dsh-im 4.3.0 → 4.4.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 (68) hide show
  1. package/lib/client.js +951 -626
  2. package/lib/index.js +233 -233
  3. package/package.json +1 -1
  4. package/plugin-src/client/access-policy-settings.js +351 -0
  5. package/plugin-src/client/channel-card-meta.js +2 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +5 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +1 -0
  8. package/plugin-src/client/channels/feishu/api.js +5 -0
  9. package/plugin-src/client/channels/feishu/index.js +1 -0
  10. package/plugin-src/client/channels/qq/api.js +5 -0
  11. package/plugin-src/client/channels/qq/index.js +1 -0
  12. package/plugin-src/client/channels/shared/token-api.js +5 -0
  13. package/plugin-src/client/channels/shared/token-channel.js +1 -0
  14. package/plugin-src/client/channels/telegram/api.js +2 -17
  15. package/plugin-src/client/channels/telegram/index.js +0 -112
  16. package/plugin-src/client/channels/telegram/styles.js +0 -28
  17. package/plugin-src/client/channels/wecom/api.js +5 -0
  18. package/plugin-src/client/channels/wecom/index.js +1 -0
  19. package/plugin-src/client/channels/weixin/api.js +5 -0
  20. package/plugin-src/client/channels/weixin/index.js +1 -0
  21. package/plugin-src/client/channels/whatsapp/api.js +4 -10
  22. package/plugin-src/client/channels/whatsapp/index.js +1 -125
  23. package/plugin-src/client/channels/whatsapp/styles.js +0 -25
  24. package/plugin-src/client/delivery-settings.js +61 -9
  25. package/plugin-src/client/i18n.js +56 -1
  26. package/plugin-src/client/index.js +1 -0
  27. package/plugin-src/client/styles.js +50 -4
  28. package/plugin-src/host/channels/dingtalk/production.mjs +12 -1
  29. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  30. package/plugin-src/host/channels/feishu/production.mjs +39 -1
  31. package/plugin-src/host/channels/feishu/rpc.mjs +19 -2
  32. package/plugin-src/host/channels/qq/production.mjs +12 -1
  33. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  34. package/plugin-src/host/channels/shared/access-policy-production.mjs +137 -0
  35. package/plugin-src/host/channels/shared/access-policy-rpc.mjs +17 -0
  36. package/plugin-src/host/channels/shared/production.mjs +15 -1
  37. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  38. package/plugin-src/host/channels/slack/production.mjs +12 -1
  39. package/plugin-src/host/channels/slack/rpc.mjs +10 -0
  40. package/plugin-src/host/channels/telegram/rpc.mjs +2 -49
  41. package/plugin-src/host/channels/wecom/production.mjs +12 -1
  42. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  43. package/plugin-src/host/channels/weixin/production.mjs +12 -1
  44. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  45. package/plugin-src/host/channels/whatsapp/production.mjs +16 -2
  46. package/plugin-src/host/channels/whatsapp/rpc.mjs +10 -15
  47. package/src/channels/dingtalk/dingtalk-bridge.mjs +61 -9
  48. package/src/channels/dingtalk/dingtalk-runtime.mjs +4 -0
  49. package/src/channels/discord/discord-runtime.mjs +29 -1
  50. package/src/channels/feishu/bridge.mjs +188 -80
  51. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  52. package/src/channels/qq/qq-bridge.mjs +59 -17
  53. package/src/channels/qq/qq-runtime.mjs +20 -2
  54. package/src/channels/shared/access-policy.mjs +210 -0
  55. package/src/channels/shared/bot-workspace-store.mjs +176 -33
  56. package/src/channels/shared/command-permission.mjs +32 -0
  57. package/src/channels/shared/compact-command.mjs +5 -2
  58. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  59. package/src/channels/shared/inbound-access.mjs +43 -0
  60. package/src/channels/shared/text-harness-bridge.mjs +47 -4
  61. package/src/channels/shared/workspace-command.mjs +10 -1
  62. package/src/channels/slack/slack-runtime.mjs +4 -0
  63. package/src/channels/telegram/telegram-runtime.mjs +5 -13
  64. package/src/channels/wecom/wecom-bridge.mjs +51 -4
  65. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  66. package/src/channels/weixin/weixin-bridge.mjs +59 -16
  67. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  68. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -20
@@ -59,6 +59,11 @@ import {
59
59
  setLastMessageFailure,
60
60
  } from '../shared/message-failure.mjs';
61
61
  import { beginStatusReaction } from '../shared/status-reaction.mjs';
62
+ import {
63
+ COMMAND_PERMISSION_DENIED_MESSAGE,
64
+ evaluateInboundAccess,
65
+ } from '../shared/inbound-access.mjs';
66
+ import { isSharedLocalCommand } from '../shared/command-permission.mjs';
62
67
  import {
63
68
  MENU_PAGE_SIZE,
64
69
  PRESET_FOLLOW_DEFAULT_SENTINEL,
@@ -135,6 +140,17 @@ const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
135
140
  /** Matches fast card commands that should not be queued behind a running task. */
136
141
  const CARD_COMMAND = /^\/(?:m(?:enu)?|new|help|status|compact|(?:sessionlist|sessions)(?:\s|$)|workspacelist|watchlist|archived(?:\s+(on|off))?)$/i;
137
142
 
143
+ function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
144
+ if (hasImages || hasFiles || typeof text !== 'string') return false;
145
+ const command = text.trim();
146
+ return MENU_COMMAND.test(command)
147
+ || REPAIR_COMMAND_PREFIX.test(command)
148
+ || WATCH_COMMAND.test(command)
149
+ || UNWATCH_COMMAND.test(command)
150
+ || WATCHLIST_COMMAND.test(command)
151
+ || ARCHIVED_COMMAND.test(command);
152
+ }
153
+
138
154
  /** Canonical workspace/session help advertised by every bridge family. */
139
155
  const WORKSPACE_HELP_LINES = [
140
156
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
@@ -385,6 +401,7 @@ export class FeishuHarnessBridge {
385
401
  #harness;
386
402
  #state;
387
403
  #contextEnhancement;
404
+ #accessPolicy;
388
405
  #queues = new Map();
389
406
  #batchInputs = new BatchInputManager();
390
407
  #pendingInteractions = new Map();
@@ -451,6 +468,7 @@ export class FeishuHarnessBridge {
451
468
  harness,
452
469
  state,
453
470
  contextEnhancement,
471
+ accessPolicy,
454
472
  status,
455
473
  allowedSenderOpenIds = new Set(),
456
474
  botId,
@@ -488,6 +506,7 @@ export class FeishuHarnessBridge {
488
506
  this.#harness = harness;
489
507
  this.#state = state;
490
508
  this.#contextEnhancement = contextEnhancement;
509
+ this.#accessPolicy = accessPolicy;
491
510
  this.#status = status;
492
511
  this.#allowedSenderOpenIds = allowedSenderOpenIds;
493
512
  this.#botId = nonEmptyString(botId);
@@ -526,10 +545,10 @@ export class FeishuHarnessBridge {
526
545
  if (this.#signal?.aborted) return Promise.resolve();
527
546
  const messageId = nonEmptyString(event?.message?.message_id);
528
547
  if (!messageId || isBotSender(event)) return Promise.resolve();
529
- if (!isAllowedSender(event, this.#allowedSenderOpenIds)) {
548
+ if (!this.#accessPolicy && !isAllowedSender(event, this.#allowedSenderOpenIds)) {
530
549
  this.#status.messagesRejected += 1;
531
550
  this.#status.lastRejectedAt = new Date().toISOString();
532
- this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the allowlist');
551
+ this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the legacy allowlist');
533
552
  return Promise.resolve();
534
553
  }
535
554
  const addressed = this.#isAddressed(event);
@@ -551,6 +570,28 @@ export class FeishuHarnessBridge {
551
570
  return Promise.resolve();
552
571
  }
553
572
 
573
+ const commandMessage = extractInboundMessage(event, this.#client);
574
+ const commandText = nonEmptyString(commandMessage.content) ?? '';
575
+ const hasImages = hasInboundImages(commandMessage);
576
+ const hasFiles = hasInboundFiles(commandMessage);
577
+ const conversationType = event.message.chat_type === 'p2p' ? 'direct'
578
+ : event.message.chat_type === 'group' ? 'group' : null;
579
+ const access = evaluateInboundAccess(this.#accessPolicy, {
580
+ conversationType,
581
+ senderIds: senderOpenId(event),
582
+ text: commandText,
583
+ hasImages,
584
+ hasFiles,
585
+ isCommand: isSharedLocalCommand(commandText, {
586
+ hasImages,
587
+ hasFiles,
588
+ }) || isFeishuLocalCommand(commandText, { hasImages, hasFiles })
589
+ || (!hasImages && !hasFiles && NUMBER_REPLY.test(commandText) && this.#menus.has(key)),
590
+ });
591
+ if (!access.allowed) {
592
+ this.#acceptedMessageIds.set(messageId, null);
593
+ return this.#finishAccessDecision(event, messageId, access);
594
+ }
554
595
  if (event.message.chat_type === 'p2p') {
555
596
  const chatId = nonEmptyString(event.message.chat_id);
556
597
  if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
@@ -558,11 +599,9 @@ export class FeishuHarnessBridge {
558
599
 
559
600
  this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
560
601
  this.#contextEnhancement,
561
- event.message.chat_type === 'p2p' ? 'direct' : event.message.chat_type === 'group' ? 'group' : null,
602
+ conversationType,
562
603
  ));
563
604
  const processingReaction = this.#beginReaction(messageId);
564
- const commandMessage = extractInboundMessage(event, this.#client);
565
- const commandText = nonEmptyString(commandMessage.content) ?? '';
566
605
  const batchText = event.message.message_type === 'text'
567
606
  ? nonEmptyString(extractText(event)) ?? ''
568
607
  : '';
@@ -774,7 +813,7 @@ export class FeishuHarnessBridge {
774
813
  await this.#state.markSeen(messageId);
775
814
  this.#status.lastMessageAt = new Date().toISOString();
776
815
  this.#status.messagesReceived += 1;
777
- if (result?.message) await this.#send(event.message.chat_id, result.message);
816
+ if (result?.message) await this.#send(event.message.chat_id, result.message, { replyTo: event.message.message_id });
778
817
  this.#status.lastError = null;
779
818
  })
780
819
  .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
@@ -792,6 +831,38 @@ export class FeishuHarnessBridge {
792
831
  return current;
793
832
  }
794
833
 
834
+ #finishAccessDecision(event, messageId, access) {
835
+ let current;
836
+ current = Promise.resolve().then(async () => {
837
+ if (this.#state.hasSeen(messageId)) return;
838
+ await this.#state.markSeen(messageId);
839
+ if (access.reason === 'command-not-allowed') {
840
+ this.#status.lastMessageAt = new Date().toISOString();
841
+ this.#status.messagesReceived += 1;
842
+ await this.#send(
843
+ event.message.chat_id,
844
+ t(COMMAND_PERMISSION_DENIED_MESSAGE),
845
+ { replyTo: event.message.message_id },
846
+ );
847
+ this.#status.messagesReplied += 1;
848
+ this.#status.lastReplyAt = new Date().toISOString();
849
+ } else {
850
+ this.#status.messagesRejected += 1;
851
+ this.#status.lastRejectedAt = new Date().toISOString();
852
+ }
853
+ this.#status.lastError = null;
854
+ }).catch((error) => {
855
+ if (this.#signal?.aborted) return;
856
+ this.#status.lastError = error?.message ?? String(error);
857
+ this.#logger.warn?.('[dsh-feishu] failed to apply inbound access policy');
858
+ }).finally(() => {
859
+ this.#acceptedMessageIds.delete(messageId);
860
+ this.#commandTasks.delete(current);
861
+ });
862
+ this.#commandTasks.add(current);
863
+ return current;
864
+ }
865
+
795
866
  #enqueueMessage(event, messageId, key, processingReaction, {
796
867
  releaseMessageId = true,
797
868
  alreadyRecorded = false,
@@ -843,7 +914,7 @@ export class FeishuHarnessBridge {
843
914
  const text = options.appendMessage
844
915
  ? `${messageFailureText(failure)}\n\n${options.appendMessage}`
845
916
  : messageFailureText(failure);
846
- await this.#send(chatId, text).catch(() => undefined);
917
+ await this.#send(chatId, text, { replyTo: options.replyTo }).catch(() => undefined);
847
918
  return failure;
848
919
  }
849
920
 
@@ -874,6 +945,7 @@ export class FeishuHarnessBridge {
874
945
  await this.#send(
875
946
  event.message.chat_id,
876
947
  failureText,
948
+ { replyTo: event.message.message_id },
877
949
  ).catch(() => undefined);
878
950
  }
879
951
 
@@ -1489,10 +1561,11 @@ export class FeishuHarnessBridge {
1489
1561
  ?? nonEmptyString(event?.operator?.operator_id?.user_id)
1490
1562
  ?? nonEmptyString(event?.open_id)
1491
1563
  ?? nonEmptyString(event?.user_id);
1492
- const operatorAllowed = operatorOpenId !== null
1493
- && (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
1494
- if (!operatorAllowed) {
1495
- this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
1564
+ if (!operatorOpenId) return Promise.resolve();
1565
+ if (!this.#accessPolicy
1566
+ && !this.#allowedSenderOpenIds.has('*')
1567
+ && !this.#allowedSenderOpenIds.has(operatorOpenId)) {
1568
+ this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed legacy sender');
1496
1569
  return Promise.resolve();
1497
1570
  }
1498
1571
  const actionValue = callbackObject(event?.action?.value);
@@ -1531,16 +1604,36 @@ export class FeishuHarnessBridge {
1531
1604
  ?? nonEmptyString(event?.message_id);
1532
1605
  const route = messageId ? this.#cardKeys.get(messageId) : null;
1533
1606
  if (!route) {
1607
+ // A route is also the trusted direct/group scope for the unified policy.
1608
+ // Without it, fail closed instead of producing an unauthorised side effect.
1609
+ if (this.#accessPolicy) return Promise.resolve();
1610
+ // Legacy callers without a unified policy still receive the current
1611
+ // expired-card guidance introduced by the upstream thread-reply fix.
1534
1612
  // The card predates this process (the in-memory mapping resets on
1535
1613
  // restart) or never came from us: nudge instead of staying silent.
1536
1614
  const chatId = nonEmptyString(event?.context?.open_chat_id)
1537
1615
  ?? nonEmptyString(event?.open_chat_id)
1538
1616
  ?? nonEmptyString(event?.chat_id);
1539
1617
  if (chatId) {
1540
- this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
1618
+ this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo: messageId }).catch(() => undefined);
1541
1619
  }
1542
1620
  return Promise.resolve();
1543
1621
  }
1622
+ const conversationType = route.key.startsWith('p2p:') ? 'direct'
1623
+ : route.key.startsWith('group:') ? 'group' : null;
1624
+ const access = evaluateInboundAccess(this.#accessPolicy, {
1625
+ conversationType,
1626
+ senderIds: operatorOpenId,
1627
+ isCommand: true,
1628
+ });
1629
+ if (!access.allowed) {
1630
+ if (access.reason === 'command-not-allowed') {
1631
+ return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
1632
+ .catch(() => undefined);
1633
+ }
1634
+ this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
1635
+ return Promise.resolve();
1636
+ }
1544
1637
  // A used card is recent even if it was first created long ago.
1545
1638
  this.#cardKeys.delete(messageId);
1546
1639
  this.#cardKeys.set(messageId, route);
@@ -1590,7 +1683,7 @@ export class FeishuHarnessBridge {
1590
1683
  await this.#sendSteer(entry, formText);
1591
1684
  return;
1592
1685
  }
1593
- await this.#send(entry.chatId, t('请输入补充指令后再提交。'));
1686
+ await this.#send(entry.chatId, t('请输入补充指令后再提交。'), { replyTo: entry.messageId ?? null });
1594
1687
  return;
1595
1688
  }
1596
1689
  }
@@ -1630,7 +1723,7 @@ export class FeishuHarnessBridge {
1630
1723
  }
1631
1724
  this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
1632
1725
  let tracked;
1633
- tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'))
1726
+ tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'), { replyTo: entry.messageId ?? null })
1634
1727
  .catch(() => undefined)
1635
1728
  .finally(() => {
1636
1729
  this.#cardActionTasks.delete(tracked);
@@ -1711,7 +1804,7 @@ export class FeishuHarnessBridge {
1711
1804
  })
1712
1805
  .catch(async (error) => {
1713
1806
  if (this.#signal?.aborted) return;
1714
- await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
1807
+ await this.#sendFailure(entry.chatId, error, { logLabel: 'card action', replyTo: entry.messageId });
1715
1808
  })
1716
1809
  .finally(() => {
1717
1810
  if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
@@ -1752,10 +1845,13 @@ export class FeishuHarnessBridge {
1752
1845
  sessionPage = 0,
1753
1846
  selections = [],
1754
1847
  }) {
1848
+ // Confirmations triggered by a card interaction stay anchored to the
1849
+ // card's message so they land inside the same Feishu topic.
1850
+ const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
1755
1851
  if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
1756
1852
  const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
1757
1853
  await this.#showSessions(
1758
- { chatId, key },
1854
+ { chatId, key, replyTo: messageId },
1759
1855
  sessionWorkspace,
1760
1856
  page,
1761
1857
  { updateMessageId: messageId },
@@ -1763,17 +1859,17 @@ export class FeishuHarnessBridge {
1763
1859
  return;
1764
1860
  }
1765
1861
  if (action === 'workspaces') {
1766
- await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
1862
+ await this.#showWorkspaces({ chatId, key, replyTo: messageId }, { updateMessageId: messageId });
1767
1863
  return;
1768
1864
  }
1769
1865
  if (action === 'watchlist') {
1770
- await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1866
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1771
1867
  return;
1772
1868
  }
1773
1869
  // 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
1774
1870
  if (action === 'watch_add' || action === 'watch_remove') {
1775
1871
  if (selections.length === 0) {
1776
- await this.#send(chatId, t('请先选择至少一个会话。'));
1872
+ await reply(t('请先选择至少一个会话。'));
1777
1873
  return;
1778
1874
  }
1779
1875
  let changed = 0;
@@ -1794,6 +1890,7 @@ export class FeishuHarnessBridge {
1794
1890
  const result = await this.#runWatch(key, chatId, sessionId, {
1795
1891
  notify: false,
1796
1892
  validatedTarget,
1893
+ replyTo: messageId,
1797
1894
  });
1798
1895
  if (result.changed) changed += 1;
1799
1896
  else if (!result.ok) failed += 1;
@@ -1819,54 +1916,53 @@ export class FeishuHarnessBridge {
1819
1916
  ? t('所选会话已在关注列表中。')
1820
1917
  : t('所选会话已不在关注列表中。');
1821
1918
  try {
1822
- await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1919
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1823
1920
  } catch (error) {
1824
1921
  this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
1825
1922
  }
1826
- await this.#send(chatId, summary).catch((error) => {
1923
+ await reply(summary).catch((error) => {
1827
1924
  this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
1828
1925
  });
1829
1926
  return;
1830
1927
  }
1831
1928
  if (action === 'new') {
1832
1929
  if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
1833
- await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
1930
+ await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
1834
1931
  return;
1835
1932
  }
1836
1933
  await this.#state.clearSession(key);
1837
- await this.#send(chatId, t('已开启全新 Harness 会话。'));
1838
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1934
+ await reply(t('已开启全新 Harness 会话。'));
1935
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1839
1936
  return;
1840
1937
  }
1841
1938
  if (action === 'use:current') {
1842
1939
  const sessionId = this.#state.sessionFor(key);
1843
1940
  if (typeof sessionId !== 'string' || !sessionId) {
1844
- await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
1941
+ await reply(t('当前没有绑定的会话,请先从会话列表选择。'));
1845
1942
  return;
1846
1943
  }
1847
- await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
1944
+ await reply(t('已就绪,直接发消息即可继续当前会话。'));
1848
1945
  return;
1849
1946
  }
1850
1947
  if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
1851
1948
  const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
1852
1949
  await this.#state?.setIncludeArchivedSessions?.(next);
1853
- await this.#send(
1854
- chatId,
1950
+ await reply(
1855
1951
  next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
1856
1952
  );
1857
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1953
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1858
1954
  return;
1859
1955
  }
1860
1956
  if (action === 'repair') {
1861
- await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
1957
+ await reply(t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
1862
1958
  return;
1863
1959
  }
1864
1960
  if (action === 'compact') {
1865
- await this.#handleCompact(key, chatId);
1961
+ await this.#handleCompact(key, chatId, messageId);
1866
1962
  return;
1867
1963
  }
1868
1964
  if (action === 'stop') {
1869
- await this.#handleStop(key, chatId);
1965
+ await this.#handleStop(key, chatId, messageId);
1870
1966
  return;
1871
1967
  }
1872
1968
  if (action === 'steer') {
@@ -1877,10 +1973,10 @@ export class FeishuHarnessBridge {
1877
1973
  if (action.startsWith('steer:')) {
1878
1974
  const raw = action.slice('steer:'.length);
1879
1975
  if (raw === 'custom') {
1880
- await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
1976
+ await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
1881
1977
  return;
1882
1978
  }
1883
- await this.#sendSteer({ key, chatId }, raw);
1979
+ await this.#sendSteer({ key, chatId, messageId }, raw);
1884
1980
  return;
1885
1981
  }
1886
1982
  if (action === 'presets') {
@@ -1900,7 +1996,7 @@ export class FeishuHarnessBridge {
1900
1996
  return;
1901
1997
  }
1902
1998
  if (action === 'back_to_menu') {
1903
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1999
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1904
2000
  return;
1905
2001
  }
1906
2002
  if (action === 'preset_default') {
@@ -1923,18 +2019,18 @@ export class FeishuHarnessBridge {
1923
2019
  return;
1924
2020
  }
1925
2021
  if (action.startsWith('use:')) {
1926
- await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId });
2022
+ await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId, replyTo: messageId });
1927
2023
  return;
1928
2024
  }
1929
2025
  if (action.startsWith('workspace:')) {
1930
- await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId });
2026
+ await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId, replyTo: messageId });
1931
2027
  return;
1932
2028
  }
1933
2029
  if (action.startsWith('unwatch:')) {
1934
- const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
2030
+ const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length), { replyTo: messageId });
1935
2031
  if (result.ok && messageId) {
1936
2032
  await this.#showSessions(
1937
- { chatId, key },
2033
+ { chatId, key, replyTo: messageId },
1938
2034
  sessionWorkspace,
1939
2035
  sessionPage,
1940
2036
  { updateMessageId: messageId },
@@ -1946,10 +2042,11 @@ export class FeishuHarnessBridge {
1946
2042
  const sessionId = action.slice('watch:'.length);
1947
2043
  const result = await this.#runWatch(key, chatId, sessionId, {
1948
2044
  workspaceHint: sessionWorkspace,
2045
+ replyTo: messageId,
1949
2046
  });
1950
2047
  if (result.ok && messageId) {
1951
2048
  await this.#showSessions(
1952
- { chatId, key },
2049
+ { chatId, key, replyTo: messageId },
1953
2050
  sessionWorkspace,
1954
2051
  sessionPage,
1955
2052
  { updateMessageId: messageId },
@@ -1978,48 +2075,50 @@ export class FeishuHarnessBridge {
1978
2075
  }
1979
2076
 
1980
2077
  async #handleMenuPick(menu, number, { chatId, key, event }) {
2078
+ const replyTo = event?.message?.message_id ?? null;
2079
+ const reply = (text) => this.#send(chatId, text, { replyTo });
1981
2080
  if (menu.kind === 'menu') {
1982
2081
  // Number fallback for the total menu:
1983
2082
  // 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
1984
2083
  const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
1985
2084
  const action = actions[number - 1];
1986
2085
  if (!action) {
1987
- await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
2086
+ await reply(t('菜单没有这个编号,回复 /m 重新打开。'));
1988
2087
  return;
1989
2088
  }
1990
2089
  if (action === 'repair') {
1991
2090
  await this.#handleRepairCommand(event, '/repair');
1992
2091
  return;
1993
2092
  }
1994
- await this.#handleCardAction(action, { chatId, key });
2093
+ await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
1995
2094
  return;
1996
2095
  }
1997
2096
  if (menu.kind === 'sessions') {
1998
2097
  const session = menu.sessions[number - 1];
1999
2098
  if (!session?.sessionId) {
2000
- await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
2099
+ await reply(t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
2001
2100
  return;
2002
2101
  }
2003
2102
  // The number label sits on the session (bind) button of the row.
2004
- await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
2103
+ await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key, messageId: replyTo });
2005
2104
  return;
2006
2105
  }
2007
2106
  if (menu.kind === 'workspaces') {
2008
2107
  const workspace = menu.paths[number - 1];
2009
2108
  if (!workspace) {
2010
- await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
2109
+ await reply(t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
2011
2110
  return;
2012
2111
  }
2013
- await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
2112
+ await this.#handleCardAction(`workspace:${workspace}`, { chatId, key, messageId: replyTo });
2014
2113
  return;
2015
2114
  }
2016
2115
  if (menu.kind === 'watches') {
2017
2116
  const entry = menu.entries[number - 1];
2018
2117
  if (!entry?.sessionId) {
2019
- await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
2118
+ await reply(t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
2020
2119
  return;
2021
2120
  }
2022
- await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
2121
+ await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key, messageId: replyTo });
2023
2122
  }
2024
2123
  }
2025
2124
 
@@ -2075,7 +2174,7 @@ export class FeishuHarnessBridge {
2075
2174
  },
2076
2175
  );
2077
2176
  } catch (error) {
2078
- await this.#sendFailure(chatId, error, { logLabel: 'session list' });
2177
+ await this.#sendFailure(chatId, error, { logLabel: 'session list', replyTo });
2079
2178
  }
2080
2179
  }
2081
2180
 
@@ -2092,35 +2191,37 @@ export class FeishuHarnessBridge {
2092
2191
  { key, updateMessageId, replyTo },
2093
2192
  );
2094
2193
  } catch (error) {
2095
- await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
2194
+ await this.#sendFailure(chatId, error, { logLabel: 'workspace list', replyTo });
2096
2195
  }
2097
2196
  }
2098
2197
 
2099
- async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
2198
+ async #bindSession(key, chatId, sessionId, { updateMessageId = null, replyTo = null } = {}) {
2100
2199
  try {
2101
2200
  const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
2102
2201
  const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
2103
2202
  await this.#send(chatId, [
2104
2203
  t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
2105
2204
  t('发送 /history 查看最近对话。'),
2106
- ].join('\n'));
2107
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2205
+ ].join('\n'), { replyTo });
2206
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2108
2207
  } catch (error) {
2109
2208
  await this.#sendFailure(chatId, error, {
2110
2209
  logLabel: 'session binding',
2210
+ replyTo,
2111
2211
  userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
2112
2212
  });
2113
2213
  }
2114
2214
  }
2115
2215
 
2116
- async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
2216
+ async #switchWorkspace(key, chatId, workspace, { updateMessageId = null, replyTo = null } = {}) {
2117
2217
  try {
2118
2218
  const current = await this.#harness.switchWorkspace(workspace);
2119
- await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
2120
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2219
+ await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }), { replyTo });
2220
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2121
2221
  } catch (error) {
2122
2222
  await this.#sendFailure(chatId, error, {
2123
2223
  logLabel: 'workspace switch',
2224
+ replyTo,
2124
2225
  userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
2125
2226
  });
2126
2227
  }
@@ -2344,7 +2445,7 @@ export class FeishuHarnessBridge {
2344
2445
  catalog._currentId = settings.agentPreset;
2345
2446
  await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
2346
2447
  } catch (error) {
2347
- await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
2448
+ await this.#sendFailure(chatId, error, { logLabel: 'preset card', replyTo: updateMessageId });
2348
2449
  }
2349
2450
  }
2350
2451
 
@@ -2369,7 +2470,7 @@ export class FeishuHarnessBridge {
2369
2470
  }
2370
2471
  await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
2371
2472
  } catch (error) {
2372
- await this.#sendFailure(chatId, error, { logLabel: 'model card' });
2473
+ await this.#sendFailure(chatId, error, { logLabel: 'model card', replyTo: updateMessageId });
2373
2474
  }
2374
2475
  }
2375
2476
 
@@ -2397,7 +2498,7 @@ export class FeishuHarnessBridge {
2397
2498
  }
2398
2499
  await this.#send(chatId, lines.join('\n'), { replyTo });
2399
2500
  } catch (error) {
2400
- await this.#sendFailure(chatId, error, { logLabel: 'status text' });
2501
+ await this.#sendFailure(chatId, error, { logLabel: 'status text', replyTo });
2401
2502
  }
2402
2503
  }
2403
2504
 
@@ -2449,7 +2550,7 @@ export class FeishuHarnessBridge {
2449
2550
 
2450
2551
  await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
2451
2552
  } catch (error) {
2452
- await this.#sendFailure(chatId, error, { logLabel: 'status card' });
2553
+ await this.#sendFailure(chatId, error, { logLabel: 'status card', replyTo: updateMessageId });
2453
2554
  }
2454
2555
  }
2455
2556
 
@@ -2467,21 +2568,21 @@ export class FeishuHarnessBridge {
2467
2568
  /**
2468
2569
  * Run the /compact command and show the result.
2469
2570
  */
2470
- async #handleCompact(key, chatId) {
2571
+ async #handleCompact(key, chatId, replyTo = null) {
2471
2572
  try {
2472
2573
  const result = await runCompactCommand(
2473
2574
  '/compact', this.#harness, this.#state, key, { signal: this.#signal },
2474
2575
  );
2475
- await this.#send(chatId, result?.message || t('上下文压缩失败。'));
2576
+ await this.#send(chatId, result?.message || t('上下文压缩失败。'), { replyTo });
2476
2577
  } catch (error) {
2477
- await this.#sendFailure(chatId, error, { logLabel: 'compact' });
2578
+ await this.#sendFailure(chatId, error, { logLabel: 'compact', replyTo });
2478
2579
  }
2479
2580
  }
2480
2581
 
2481
2582
  /**
2482
2583
  * Stop the running task in the bound session (mirrors `/stop`).
2483
2584
  */
2484
- async #handleStop(key, chatId) {
2585
+ async #handleStop(key, chatId, replyTo = null) {
2485
2586
  try {
2486
2587
  const result = await runControlCommand(
2487
2588
  '/stop', this.#harness, this.#state, key, {
@@ -2495,9 +2596,9 @@ export class FeishuHarnessBridge {
2495
2596
  this.#approvals.closeRoute(key),
2496
2597
  ]);
2497
2598
  }
2498
- await this.#send(chatId, result?.message || t('/stop 执行完成。'));
2599
+ await this.#send(chatId, result?.message || t('/stop 执行完成。'), { replyTo });
2499
2600
  } catch (error) {
2500
- await this.#sendFailure(chatId, error, { logLabel: 'stop' });
2601
+ await this.#sendFailure(chatId, error, { logLabel: 'stop', replyTo });
2501
2602
  }
2502
2603
  }
2503
2604
 
@@ -2522,26 +2623,26 @@ export class FeishuHarnessBridge {
2522
2623
  control: { owner: this, key },
2523
2624
  },
2524
2625
  );
2525
- await this.#send(chatId, result?.message || t('已提交补充指令。'));
2626
+ await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
2526
2627
  }
2527
2628
 
2528
2629
  /**
2529
2630
  * Reset the preset to follow the Host default.
2530
2631
  */
2531
- async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
2632
+ async #handlePresetDefault(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2532
2633
  try {
2533
2634
  const result = await runPresetCommand(
2534
2635
  '/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
2535
2636
  );
2536
2637
  for (const reply of result?.messages ?? [result?.message]) {
2537
- if (reply) await this.#send(chatId, reply);
2638
+ if (reply) await this.#send(chatId, reply, { replyTo });
2538
2639
  }
2539
2640
  } catch (error) {
2540
- await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
2641
+ await this.#sendFailure(chatId, error, { logLabel: 'preset reset', replyTo: updateMessageId });
2541
2642
  return;
2542
2643
  }
2543
2644
  try {
2544
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2645
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2545
2646
  } catch (error) {
2546
2647
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
2547
2648
  }
@@ -2550,21 +2651,21 @@ export class FeishuHarnessBridge {
2550
2651
  /**
2551
2652
  * Handle preset selection from the preset dropdown.
2552
2653
  */
2553
- async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
2654
+ async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null, replyTo = null } = {}) {
2554
2655
  try {
2555
2656
  const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
2556
2657
  const result = await runPresetCommand(
2557
2658
  `/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
2558
2659
  );
2559
2660
  for (const reply of result?.messages ?? [result?.message]) {
2560
- if (reply) await this.#send(chatId, reply);
2661
+ if (reply) await this.#send(chatId, reply, { replyTo });
2561
2662
  }
2562
2663
  } catch (error) {
2563
- await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
2664
+ await this.#sendFailure(chatId, error, { logLabel: 'preset selection', replyTo: updateMessageId });
2564
2665
  return;
2565
2666
  }
2566
2667
  try {
2567
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2668
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2568
2669
  } catch (error) {
2569
2670
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
2570
2671
  }
@@ -2579,7 +2680,7 @@ export class FeishuHarnessBridge {
2579
2680
  * catalog validation, busy checks, pending-interaction checks and the
2580
2681
  * session binding lock stay in one place.
2581
2682
  */
2582
- async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
2683
+ async #handleModelSelect(key, chatId, modelId, { updateMessageId = null, replyTo = null } = {}) {
2583
2684
  try {
2584
2685
  const result = await runModelCommand(
2585
2686
  `/model ${modelId}`, this.#harness, this.#state, key, {
@@ -2589,14 +2690,14 @@ export class FeishuHarnessBridge {
2589
2690
  },
2590
2691
  );
2591
2692
  for (const reply of result?.messages ?? [result?.message]) {
2592
- if (reply) await this.#send(chatId, reply);
2693
+ if (reply) await this.#send(chatId, reply, { replyTo });
2593
2694
  }
2594
2695
  } catch (error) {
2595
- await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
2696
+ await this.#sendFailure(chatId, error, { logLabel: 'model selection', replyTo: updateMessageId });
2596
2697
  return;
2597
2698
  }
2598
2699
  try {
2599
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2700
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2600
2701
  } catch (error) {
2601
2702
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
2602
2703
  }
@@ -2867,6 +2968,13 @@ export class FeishuHarnessBridge {
2867
2968
  chatId,
2868
2969
  lastSeq,
2869
2970
  ...(watchStartedAt !== null ? { watchStartedAt } : {}),
2971
+ // Remember where the watch was created so completion pushes can be
2972
+ // delivered as replies inside the same Feishu topic.
2973
+ ...(replyTo
2974
+ ? { replyToMessageId: replyTo }
2975
+ : existingEntry?.replyToMessageId
2976
+ ? { replyToMessageId: existingEntry.replyToMessageId }
2977
+ : {}),
2870
2978
  });
2871
2979
  } catch (error) {
2872
2980
  await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
@@ -2992,7 +3100,7 @@ export class FeishuHarnessBridge {
2992
3100
  await this.#sendCard(
2993
3101
  entry.chatId,
2994
3102
  completionCard(sessionId, entry.title, reason),
2995
- { key },
3103
+ { key, replyTo: entry.replyToMessageId ?? null },
2996
3104
  );
2997
3105
  const current = this.#state.watchEntry?.(key, sessionId);
2998
3106
  if (!current