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