@xmanrui/dsh-im 4.2.1 → 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 (74) hide show
  1. package/README.en.md +4 -2
  2. package/README.md +4 -2
  3. package/lib/client.js +1267 -787
  4. package/lib/index.js +237 -235
  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 +207 -102
  27. package/plugin-src/client/delivery-settings.js +61 -9
  28. package/plugin-src/client/i18n.js +71 -3
  29. package/plugin-src/client/index.js +1 -0
  30. package/plugin-src/client/styles.js +59 -8
  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/scripts/verify-package.mjs +4 -3
  51. package/src/channels/dingtalk/dingtalk-bridge.mjs +61 -9
  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 +286 -120
  55. package/src/channels/feishu/feishu-runtime.mjs +4 -0
  56. package/src/channels/feishu/message-utils.mjs +5 -0
  57. package/src/channels/qq/qq-bridge.mjs +59 -17
  58. package/src/channels/qq/qq-runtime.mjs +25 -3
  59. package/src/channels/shared/access-policy.mjs +210 -0
  60. package/src/channels/shared/bot-workspace-store.mjs +176 -33
  61. package/src/channels/shared/command-permission.mjs +32 -0
  62. package/src/channels/shared/compact-command.mjs +5 -2
  63. package/src/channels/shared/context-enhancement.mjs +73 -20
  64. package/src/channels/shared/i18n-en/shared-c.mjs +2 -0
  65. package/src/channels/shared/inbound-access.mjs +43 -0
  66. package/src/channels/shared/text-harness-bridge.mjs +47 -4
  67. package/src/channels/shared/workspace-command.mjs +10 -1
  68. package/src/channels/slack/slack-runtime.mjs +4 -0
  69. package/src/channels/telegram/telegram-runtime.mjs +5 -13
  70. package/src/channels/wecom/wecom-bridge.mjs +51 -4
  71. package/src/channels/wecom/wecom-runtime.mjs +4 -0
  72. package/src/channels/weixin/weixin-bridge.mjs +59 -16
  73. package/src/channels/weixin/weixin-runtime.mjs +4 -0
  74. 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
  : '';
@@ -686,7 +725,7 @@ export class FeishuHarnessBridge {
686
725
  ? pending.queue
687
726
  : null,
688
727
  isQuestionPending: () => this.#pendingInteractions.has(key),
689
- send: (text) => this.#send(event.message.chat_id, text),
728
+ send: (text) => this.#send(event.message.chat_id, text, { replyTo: event.message.message_id }),
690
729
  });
691
730
  if (approvalReply) {
692
731
  const processing = approvalReply.process(async () => {
@@ -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
 
@@ -851,7 +922,7 @@ export class FeishuHarnessBridge {
851
922
  if (error?.code === 'turn-stopped') {
852
923
  await this.#removeProcessingReaction(messageId, processingReaction);
853
924
  if (error?.batchInputMessage) {
854
- await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined);
925
+ await this.#send(event.message.chat_id, error.batchInputMessage, { replyTo: event.message.message_id }).catch(() => undefined);
855
926
  }
856
927
  return;
857
928
  }
@@ -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
 
@@ -940,7 +1012,7 @@ export class FeishuHarnessBridge {
940
1012
  ]);
941
1013
  }
942
1014
  for (const reply of result?.messages ?? [result?.message]) {
943
- if (reply) await this.#send(event.message.chat_id, reply);
1015
+ if (reply) await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
944
1016
  }
945
1017
  this.#status.lastError = null;
946
1018
  }
@@ -964,7 +1036,7 @@ export class FeishuHarnessBridge {
964
1036
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
965
1037
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
966
1038
  if (!text && !hasImages && !hasFiles) {
967
- await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
1039
+ await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
968
1040
  return;
969
1041
  }
970
1042
 
@@ -973,11 +1045,11 @@ export class FeishuHarnessBridge {
973
1045
  return;
974
1046
  }
975
1047
  if (commandText === '/help') {
976
- await this.#send(event.message.chat_id, menuHelpText());
1048
+ await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
977
1049
  return;
978
1050
  }
979
1051
  if (MENU_COMMAND.test(commandText)) {
980
- await this.#sendMenuCard(key, event.message.chat_id);
1052
+ await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
981
1053
  return;
982
1054
  }
983
1055
  if (commandText === '/new') {
@@ -985,53 +1057,54 @@ export class FeishuHarnessBridge {
985
1057
  await this.#send(
986
1058
  event.message.chat_id,
987
1059
  t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
1060
+ { replyTo: event.message.message_id },
988
1061
  );
989
1062
  return;
990
1063
  }
991
1064
  await this.#state.clearSession(key);
992
- await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'));
993
- await this.#sendMenuCard(key, event.message.chat_id);
1065
+ await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'), { replyTo: event.message.message_id });
1066
+ await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
994
1067
  return;
995
1068
  }
996
1069
  if (commandText === '/status') {
997
- await this.#showStatusText(key, event.message.chat_id);
1070
+ await this.#showStatusText(key, event.message.chat_id, event.message.message_id);
998
1071
  return;
999
1072
  }
1000
1073
  if (commandText === '/compact') {
1001
1074
  const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
1002
1075
  if (compactCommand) {
1003
- await this.#send(event.message.chat_id, compactCommand.message);
1076
+ await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
1004
1077
  }
1005
1078
  return;
1006
1079
  }
1007
1080
  if (SESSION_LIST_PREFIX.test(commandText)) {
1008
1081
  const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
1009
- await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
1082
+ await this.#showSessions({ chatId: event.message.chat_id, key, replyTo: event.message.message_id }, selector, 0);
1010
1083
  return;
1011
1084
  }
1012
1085
  if (WORKSPACE_LIST_COMMAND.test(commandText)) {
1013
- await this.#showWorkspaces({ chatId: event.message.chat_id, key });
1086
+ await this.#showWorkspaces({ chatId: event.message.chat_id, key, replyTo: event.message.message_id });
1014
1087
  return;
1015
1088
  }
1016
1089
  if (WATCH_COMMAND.test(commandText)) {
1017
1090
  const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
1018
- await this.#runWatch(key, event.message.chat_id, target);
1091
+ await this.#runWatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
1019
1092
  return;
1020
1093
  }
1021
1094
  if (UNWATCH_COMMAND.test(commandText)) {
1022
1095
  const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
1023
- await this.#runUnwatch(key, event.message.chat_id, target);
1096
+ await this.#runUnwatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
1024
1097
  return;
1025
1098
  }
1026
1099
  if (WATCHLIST_COMMAND.test(commandText)) {
1027
- await this.#showWatchList(key, event.message.chat_id);
1100
+ await this.#showWatchList(key, event.message.chat_id, { replyTo: event.message.message_id });
1028
1101
  return;
1029
1102
  }
1030
1103
  if (ARCHIVED_COMMAND.test(commandText)) {
1031
1104
  const match = ARCHIVED_COMMAND.exec(commandText);
1032
1105
  const value = match[1]?.toLowerCase();
1033
1106
  if (value !== 'on' && value !== 'off') {
1034
- await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'));
1107
+ await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'), { replyTo: event.message.message_id });
1035
1108
  return;
1036
1109
  }
1037
1110
  if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
@@ -1040,6 +1113,7 @@ export class FeishuHarnessBridge {
1040
1113
  await this.#send(
1041
1114
  event.message.chat_id,
1042
1115
  value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
1116
+ { replyTo: event.message.message_id },
1043
1117
  );
1044
1118
  return;
1045
1119
  }
@@ -1059,7 +1133,7 @@ export class FeishuHarnessBridge {
1059
1133
  : await runWorkspaceCommand(text, this.#harness, key);
1060
1134
  if (workspaceCommand) {
1061
1135
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
1062
- await this.#send(event.message.chat_id, reply);
1136
+ await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
1063
1137
  }
1064
1138
  return;
1065
1139
  }
@@ -1073,7 +1147,7 @@ export class FeishuHarnessBridge {
1073
1147
  { signal: this.#signal },
1074
1148
  );
1075
1149
  if (compactCommand) {
1076
- await this.#send(event.message.chat_id, compactCommand.message);
1150
+ await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
1077
1151
  return;
1078
1152
  }
1079
1153
 
@@ -1487,10 +1561,11 @@ export class FeishuHarnessBridge {
1487
1561
  ?? nonEmptyString(event?.operator?.operator_id?.user_id)
1488
1562
  ?? nonEmptyString(event?.open_id)
1489
1563
  ?? nonEmptyString(event?.user_id);
1490
- const operatorAllowed = operatorOpenId !== null
1491
- && (this.#allowedSenderOpenIds.has('*') || this.#allowedSenderOpenIds.has(operatorOpenId));
1492
- if (!operatorAllowed) {
1493
- 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');
1494
1569
  return Promise.resolve();
1495
1570
  }
1496
1571
  const actionValue = callbackObject(event?.action?.value);
@@ -1529,16 +1604,36 @@ export class FeishuHarnessBridge {
1529
1604
  ?? nonEmptyString(event?.message_id);
1530
1605
  const route = messageId ? this.#cardKeys.get(messageId) : null;
1531
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.
1532
1612
  // The card predates this process (the in-memory mapping resets on
1533
1613
  // restart) or never came from us: nudge instead of staying silent.
1534
1614
  const chatId = nonEmptyString(event?.context?.open_chat_id)
1535
1615
  ?? nonEmptyString(event?.open_chat_id)
1536
1616
  ?? nonEmptyString(event?.chat_id);
1537
1617
  if (chatId) {
1538
- this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined);
1618
+ this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo: messageId }).catch(() => undefined);
1539
1619
  }
1540
1620
  return Promise.resolve();
1541
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
+ }
1542
1637
  // A used card is recent even if it was first created long ago.
1543
1638
  this.#cardKeys.delete(messageId);
1544
1639
  this.#cardKeys.set(messageId, route);
@@ -1588,7 +1683,7 @@ export class FeishuHarnessBridge {
1588
1683
  await this.#sendSteer(entry, formText);
1589
1684
  return;
1590
1685
  }
1591
- await this.#send(entry.chatId, t('请输入补充指令后再提交。'));
1686
+ await this.#send(entry.chatId, t('请输入补充指令后再提交。'), { replyTo: entry.messageId ?? null });
1592
1687
  return;
1593
1688
  }
1594
1689
  }
@@ -1628,7 +1723,7 @@ export class FeishuHarnessBridge {
1628
1723
  }
1629
1724
  this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
1630
1725
  let tracked;
1631
- tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'))
1726
+ tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'), { replyTo: entry.messageId ?? null })
1632
1727
  .catch(() => undefined)
1633
1728
  .finally(() => {
1634
1729
  this.#cardActionTasks.delete(tracked);
@@ -1709,7 +1804,7 @@ export class FeishuHarnessBridge {
1709
1804
  })
1710
1805
  .catch(async (error) => {
1711
1806
  if (this.#signal?.aborted) return;
1712
- await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' });
1807
+ await this.#sendFailure(entry.chatId, error, { logLabel: 'card action', replyTo: entry.messageId });
1713
1808
  })
1714
1809
  .finally(() => {
1715
1810
  if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
@@ -1750,10 +1845,13 @@ export class FeishuHarnessBridge {
1750
1845
  sessionPage = 0,
1751
1846
  selections = [],
1752
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 });
1753
1851
  if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
1754
1852
  const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
1755
1853
  await this.#showSessions(
1756
- { chatId, key },
1854
+ { chatId, key, replyTo: messageId },
1757
1855
  sessionWorkspace,
1758
1856
  page,
1759
1857
  { updateMessageId: messageId },
@@ -1761,17 +1859,17 @@ export class FeishuHarnessBridge {
1761
1859
  return;
1762
1860
  }
1763
1861
  if (action === 'workspaces') {
1764
- await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId });
1862
+ await this.#showWorkspaces({ chatId, key, replyTo: messageId }, { updateMessageId: messageId });
1765
1863
  return;
1766
1864
  }
1767
1865
  if (action === 'watchlist') {
1768
- await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1866
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1769
1867
  return;
1770
1868
  }
1771
1869
  // 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
1772
1870
  if (action === 'watch_add' || action === 'watch_remove') {
1773
1871
  if (selections.length === 0) {
1774
- await this.#send(chatId, t('请先选择至少一个会话。'));
1872
+ await reply(t('请先选择至少一个会话。'));
1775
1873
  return;
1776
1874
  }
1777
1875
  let changed = 0;
@@ -1792,6 +1890,7 @@ export class FeishuHarnessBridge {
1792
1890
  const result = await this.#runWatch(key, chatId, sessionId, {
1793
1891
  notify: false,
1794
1892
  validatedTarget,
1893
+ replyTo: messageId,
1795
1894
  });
1796
1895
  if (result.changed) changed += 1;
1797
1896
  else if (!result.ok) failed += 1;
@@ -1817,54 +1916,53 @@ export class FeishuHarnessBridge {
1817
1916
  ? t('所选会话已在关注列表中。')
1818
1917
  : t('所选会话已不在关注列表中。');
1819
1918
  try {
1820
- await this.#showWatchList(key, chatId, { updateMessageId: messageId });
1919
+ await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1821
1920
  } catch (error) {
1822
1921
  this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
1823
1922
  }
1824
- await this.#send(chatId, summary).catch((error) => {
1923
+ await reply(summary).catch((error) => {
1825
1924
  this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
1826
1925
  });
1827
1926
  return;
1828
1927
  }
1829
1928
  if (action === 'new') {
1830
1929
  if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
1831
- await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
1930
+ await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
1832
1931
  return;
1833
1932
  }
1834
1933
  await this.#state.clearSession(key);
1835
- await this.#send(chatId, t('已开启全新 Harness 会话。'));
1836
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1934
+ await reply(t('已开启全新 Harness 会话。'));
1935
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1837
1936
  return;
1838
1937
  }
1839
1938
  if (action === 'use:current') {
1840
1939
  const sessionId = this.#state.sessionFor(key);
1841
1940
  if (typeof sessionId !== 'string' || !sessionId) {
1842
- await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。'));
1941
+ await reply(t('当前没有绑定的会话,请先从会话列表选择。'));
1843
1942
  return;
1844
1943
  }
1845
- await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。'));
1944
+ await reply(t('已就绪,直接发消息即可继续当前会话。'));
1846
1945
  return;
1847
1946
  }
1848
1947
  if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
1849
1948
  const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
1850
1949
  await this.#state?.setIncludeArchivedSessions?.(next);
1851
- await this.#send(
1852
- chatId,
1950
+ await reply(
1853
1951
  next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
1854
1952
  );
1855
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1953
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1856
1954
  return;
1857
1955
  }
1858
1956
  if (action === 'repair') {
1859
- await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
1957
+ await reply(t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
1860
1958
  return;
1861
1959
  }
1862
1960
  if (action === 'compact') {
1863
- await this.#handleCompact(key, chatId);
1961
+ await this.#handleCompact(key, chatId, messageId);
1864
1962
  return;
1865
1963
  }
1866
1964
  if (action === 'stop') {
1867
- await this.#handleStop(key, chatId);
1965
+ await this.#handleStop(key, chatId, messageId);
1868
1966
  return;
1869
1967
  }
1870
1968
  if (action === 'steer') {
@@ -1875,10 +1973,10 @@ export class FeishuHarnessBridge {
1875
1973
  if (action.startsWith('steer:')) {
1876
1974
  const raw = action.slice('steer:'.length);
1877
1975
  if (raw === 'custom') {
1878
- await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId });
1976
+ await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
1879
1977
  return;
1880
1978
  }
1881
- await this.#sendSteer({ key, chatId }, raw);
1979
+ await this.#sendSteer({ key, chatId, messageId }, raw);
1882
1980
  return;
1883
1981
  }
1884
1982
  if (action === 'presets') {
@@ -1898,7 +1996,7 @@ export class FeishuHarnessBridge {
1898
1996
  return;
1899
1997
  }
1900
1998
  if (action === 'back_to_menu') {
1901
- await this.#sendMenuCard(key, chatId, { updateMessageId: messageId });
1999
+ await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
1902
2000
  return;
1903
2001
  }
1904
2002
  if (action === 'preset_default') {
@@ -1921,18 +2019,18 @@ export class FeishuHarnessBridge {
1921
2019
  return;
1922
2020
  }
1923
2021
  if (action.startsWith('use:')) {
1924
- 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 });
1925
2023
  return;
1926
2024
  }
1927
2025
  if (action.startsWith('workspace:')) {
1928
- 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 });
1929
2027
  return;
1930
2028
  }
1931
2029
  if (action.startsWith('unwatch:')) {
1932
- 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 });
1933
2031
  if (result.ok && messageId) {
1934
2032
  await this.#showSessions(
1935
- { chatId, key },
2033
+ { chatId, key, replyTo: messageId },
1936
2034
  sessionWorkspace,
1937
2035
  sessionPage,
1938
2036
  { updateMessageId: messageId },
@@ -1944,10 +2042,11 @@ export class FeishuHarnessBridge {
1944
2042
  const sessionId = action.slice('watch:'.length);
1945
2043
  const result = await this.#runWatch(key, chatId, sessionId, {
1946
2044
  workspaceHint: sessionWorkspace,
2045
+ replyTo: messageId,
1947
2046
  });
1948
2047
  if (result.ok && messageId) {
1949
2048
  await this.#showSessions(
1950
- { chatId, key },
2049
+ { chatId, key, replyTo: messageId },
1951
2050
  sessionWorkspace,
1952
2051
  sessionPage,
1953
2052
  { updateMessageId: messageId },
@@ -1976,48 +2075,50 @@ export class FeishuHarnessBridge {
1976
2075
  }
1977
2076
 
1978
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 });
1979
2080
  if (menu.kind === 'menu') {
1980
2081
  // Number fallback for the total menu:
1981
2082
  // 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
1982
2083
  const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
1983
2084
  const action = actions[number - 1];
1984
2085
  if (!action) {
1985
- await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。'));
2086
+ await reply(t('菜单没有这个编号,回复 /m 重新打开。'));
1986
2087
  return;
1987
2088
  }
1988
2089
  if (action === 'repair') {
1989
2090
  await this.#handleRepairCommand(event, '/repair');
1990
2091
  return;
1991
2092
  }
1992
- await this.#handleCardAction(action, { chatId, key });
2093
+ await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
1993
2094
  return;
1994
2095
  }
1995
2096
  if (menu.kind === 'sessions') {
1996
2097
  const session = menu.sessions[number - 1];
1997
2098
  if (!session?.sessionId) {
1998
- await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
2099
+ await reply(t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
1999
2100
  return;
2000
2101
  }
2001
2102
  // The number label sits on the session (bind) button of the row.
2002
- await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
2103
+ await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key, messageId: replyTo });
2003
2104
  return;
2004
2105
  }
2005
2106
  if (menu.kind === 'workspaces') {
2006
2107
  const workspace = menu.paths[number - 1];
2007
2108
  if (!workspace) {
2008
- await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
2109
+ await reply(t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
2009
2110
  return;
2010
2111
  }
2011
- await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
2112
+ await this.#handleCardAction(`workspace:${workspace}`, { chatId, key, messageId: replyTo });
2012
2113
  return;
2013
2114
  }
2014
2115
  if (menu.kind === 'watches') {
2015
2116
  const entry = menu.entries[number - 1];
2016
2117
  if (!entry?.sessionId) {
2017
- await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
2118
+ await reply(t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
2018
2119
  return;
2019
2120
  }
2020
- await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
2121
+ await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key, messageId: replyTo });
2021
2122
  }
2022
2123
  }
2023
2124
 
@@ -2030,7 +2131,7 @@ export class FeishuHarnessBridge {
2030
2131
  }
2031
2132
 
2032
2133
  async #showSessions(
2033
- { chatId, key },
2134
+ { chatId, key, replyTo = null },
2034
2135
  selector,
2035
2136
  page = 0,
2036
2137
  { updateMessageId = null } = {},
@@ -2039,14 +2140,14 @@ export class FeishuHarnessBridge {
2039
2140
  const signal = this.#cardDataSignal();
2040
2141
  const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal });
2041
2142
  if (resolved.error) {
2042
- await this.#send(chatId, resolved.error);
2143
+ await this.#send(chatId, resolved.error, { replyTo });
2043
2144
  return;
2044
2145
  }
2045
2146
  const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
2046
2147
  const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
2047
2148
  const workspace = listed?.workspace ?? resolved.workspace;
2048
2149
  if (sessions.length === 0) {
2049
- await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }));
2150
+ await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }), { replyTo });
2050
2151
  return;
2051
2152
  }
2052
2153
  const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
@@ -2065,6 +2166,7 @@ export class FeishuHarnessBridge {
2065
2166
  {
2066
2167
  key,
2067
2168
  updateMessageId,
2169
+ replyTo,
2068
2170
  // Keep the canonical selector result for later page callbacks. The
2069
2171
  // list response's workspace is display data and is not authoritative.
2070
2172
  sessionWorkspace: resolved.workspace,
@@ -2072,11 +2174,11 @@ export class FeishuHarnessBridge {
2072
2174
  },
2073
2175
  );
2074
2176
  } catch (error) {
2075
- await this.#sendFailure(chatId, error, { logLabel: 'session list' });
2177
+ await this.#sendFailure(chatId, error, { logLabel: 'session list', replyTo });
2076
2178
  }
2077
2179
  }
2078
2180
 
2079
- async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) {
2181
+ async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
2080
2182
  try {
2081
2183
  const { current, paths } = await workspacePathSnapshot(
2082
2184
  this.#harness,
@@ -2086,38 +2188,40 @@ export class FeishuHarnessBridge {
2086
2188
  await this.#sendCard(
2087
2189
  chatId,
2088
2190
  workspaceListCard(paths, current),
2089
- { key, updateMessageId },
2191
+ { key, updateMessageId, replyTo },
2090
2192
  );
2091
2193
  } catch (error) {
2092
- await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
2194
+ await this.#sendFailure(chatId, error, { logLabel: 'workspace list', replyTo });
2093
2195
  }
2094
2196
  }
2095
2197
 
2096
- async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) {
2198
+ async #bindSession(key, chatId, sessionId, { updateMessageId = null, replyTo = null } = {}) {
2097
2199
  try {
2098
2200
  const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
2099
2201
  const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
2100
2202
  await this.#send(chatId, [
2101
2203
  t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
2102
2204
  t('发送 /history 查看最近对话。'),
2103
- ].join('\n'));
2104
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2205
+ ].join('\n'), { replyTo });
2206
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2105
2207
  } catch (error) {
2106
2208
  await this.#sendFailure(chatId, error, {
2107
2209
  logLabel: 'session binding',
2210
+ replyTo,
2108
2211
  userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
2109
2212
  });
2110
2213
  }
2111
2214
  }
2112
2215
 
2113
- async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) {
2216
+ async #switchWorkspace(key, chatId, workspace, { updateMessageId = null, replyTo = null } = {}) {
2114
2217
  try {
2115
2218
  const current = await this.#harness.switchWorkspace(workspace);
2116
- await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }));
2117
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2219
+ await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }), { replyTo });
2220
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2118
2221
  } catch (error) {
2119
2222
  await this.#sendFailure(chatId, error, {
2120
2223
  logLabel: 'workspace switch',
2224
+ replyTo,
2121
2225
  userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
2122
2226
  });
2123
2227
  }
@@ -2144,6 +2248,7 @@ export class FeishuHarnessBridge {
2144
2248
 
2145
2249
  async #sendCard(chatId, cardJson, options = {}) {
2146
2250
  const updateMessageId = nonEmptyString(options.updateMessageId);
2251
+ const replyTo = nonEmptyString(options.replyTo);
2147
2252
 
2148
2253
  if (updateMessageId) {
2149
2254
  try {
@@ -2161,6 +2266,28 @@ export class FeishuHarnessBridge {
2161
2266
  }
2162
2267
  }
2163
2268
 
2269
+ // A brand-new card triggered by an inbound message is delivered as a
2270
+ // threaded reply so it lands inside the same Feishu topic. Falls back to
2271
+ // a plain chat message when the referenced message is gone.
2272
+ const content = cardJson;
2273
+ if (replyTo) {
2274
+ try {
2275
+ const response = await this.#client.im.v1.message.reply({
2276
+ path: { message_id: replyTo },
2277
+ data: { msg_type: 'interactive', content },
2278
+ });
2279
+ if (response?.code && response.code !== 0) {
2280
+ throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
2281
+ }
2282
+ const repliedMessageId = nonEmptyString(response?.data?.message_id);
2283
+ if (repliedMessageId) {
2284
+ this.#rememberCardRoute(repliedMessageId, chatId, options);
2285
+ return repliedMessageId;
2286
+ }
2287
+ } catch (error) {
2288
+ this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
2289
+ }
2290
+ }
2164
2291
  const response = await this.#client.im.v1.message.create({
2165
2292
  params: { receive_id_type: 'chat_id' },
2166
2293
  data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
@@ -2173,7 +2300,7 @@ export class FeishuHarnessBridge {
2173
2300
  return messageId;
2174
2301
  }
2175
2302
 
2176
- async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) {
2303
+ async #sendMenuCard(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2177
2304
  let currentSessionId = null;
2178
2305
  let directSessionTitle = null;
2179
2306
  try {
@@ -2268,7 +2395,7 @@ export class FeishuHarnessBridge {
2268
2395
  currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
2269
2396
  sessions, archiveVisible, presetCatalog, modelCatalog,
2270
2397
  }),
2271
- { key, updateMessageId },
2398
+ { key, updateMessageId, replyTo },
2272
2399
  );
2273
2400
  }
2274
2401
 
@@ -2318,7 +2445,7 @@ export class FeishuHarnessBridge {
2318
2445
  catalog._currentId = settings.agentPreset;
2319
2446
  await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
2320
2447
  } catch (error) {
2321
- await this.#sendFailure(chatId, error, { logLabel: 'preset card' });
2448
+ await this.#sendFailure(chatId, error, { logLabel: 'preset card', replyTo: updateMessageId });
2322
2449
  }
2323
2450
  }
2324
2451
 
@@ -2343,14 +2470,14 @@ export class FeishuHarnessBridge {
2343
2470
  }
2344
2471
  await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
2345
2472
  } catch (error) {
2346
- await this.#sendFailure(chatId, error, { logLabel: 'model card' });
2473
+ await this.#sendFailure(chatId, error, { logLabel: 'model card', replyTo: updateMessageId });
2347
2474
  }
2348
2475
  }
2349
2476
 
2350
2477
  /**
2351
2478
  * Gather system status and show the status card.
2352
2479
  */
2353
- async #showStatusText(key, chatId) {
2480
+ async #showStatusText(key, chatId, replyTo = null) {
2354
2481
  try {
2355
2482
  await this.#harness.ensureRunning({ signal: this.#signal });
2356
2483
  const lines = [t('连接正常')];
@@ -2369,9 +2496,9 @@ export class FeishuHarnessBridge {
2369
2496
  : (settings.agentPreset || t('跟随默认')),
2370
2497
  }));
2371
2498
  }
2372
- await this.#send(chatId, lines.join('\n'));
2499
+ await this.#send(chatId, lines.join('\n'), { replyTo });
2373
2500
  } catch (error) {
2374
- await this.#sendFailure(chatId, error, { logLabel: 'status text' });
2501
+ await this.#sendFailure(chatId, error, { logLabel: 'status text', replyTo });
2375
2502
  }
2376
2503
  }
2377
2504
 
@@ -2423,7 +2550,7 @@ export class FeishuHarnessBridge {
2423
2550
 
2424
2551
  await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
2425
2552
  } catch (error) {
2426
- await this.#sendFailure(chatId, error, { logLabel: 'status card' });
2553
+ await this.#sendFailure(chatId, error, { logLabel: 'status card', replyTo: updateMessageId });
2427
2554
  }
2428
2555
  }
2429
2556
 
@@ -2441,21 +2568,21 @@ export class FeishuHarnessBridge {
2441
2568
  /**
2442
2569
  * Run the /compact command and show the result.
2443
2570
  */
2444
- async #handleCompact(key, chatId) {
2571
+ async #handleCompact(key, chatId, replyTo = null) {
2445
2572
  try {
2446
2573
  const result = await runCompactCommand(
2447
2574
  '/compact', this.#harness, this.#state, key, { signal: this.#signal },
2448
2575
  );
2449
- await this.#send(chatId, result?.message || t('上下文压缩失败。'));
2576
+ await this.#send(chatId, result?.message || t('上下文压缩失败。'), { replyTo });
2450
2577
  } catch (error) {
2451
- await this.#sendFailure(chatId, error, { logLabel: 'compact' });
2578
+ await this.#sendFailure(chatId, error, { logLabel: 'compact', replyTo });
2452
2579
  }
2453
2580
  }
2454
2581
 
2455
2582
  /**
2456
2583
  * Stop the running task in the bound session (mirrors `/stop`).
2457
2584
  */
2458
- async #handleStop(key, chatId) {
2585
+ async #handleStop(key, chatId, replyTo = null) {
2459
2586
  try {
2460
2587
  const result = await runControlCommand(
2461
2588
  '/stop', this.#harness, this.#state, key, {
@@ -2469,9 +2596,9 @@ export class FeishuHarnessBridge {
2469
2596
  this.#approvals.closeRoute(key),
2470
2597
  ]);
2471
2598
  }
2472
- await this.#send(chatId, result?.message || t('/stop 执行完成。'));
2599
+ await this.#send(chatId, result?.message || t('/stop 执行完成。'), { replyTo });
2473
2600
  } catch (error) {
2474
- await this.#sendFailure(chatId, error, { logLabel: 'stop' });
2601
+ await this.#sendFailure(chatId, error, { logLabel: 'stop', replyTo });
2475
2602
  }
2476
2603
  }
2477
2604
 
@@ -2496,26 +2623,26 @@ export class FeishuHarnessBridge {
2496
2623
  control: { owner: this, key },
2497
2624
  },
2498
2625
  );
2499
- await this.#send(chatId, result?.message || t('已提交补充指令。'));
2626
+ await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
2500
2627
  }
2501
2628
 
2502
2629
  /**
2503
2630
  * Reset the preset to follow the Host default.
2504
2631
  */
2505
- async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) {
2632
+ async #handlePresetDefault(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2506
2633
  try {
2507
2634
  const result = await runPresetCommand(
2508
2635
  '/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
2509
2636
  );
2510
2637
  for (const reply of result?.messages ?? [result?.message]) {
2511
- if (reply) await this.#send(chatId, reply);
2638
+ if (reply) await this.#send(chatId, reply, { replyTo });
2512
2639
  }
2513
2640
  } catch (error) {
2514
- await this.#sendFailure(chatId, error, { logLabel: 'preset reset' });
2641
+ await this.#sendFailure(chatId, error, { logLabel: 'preset reset', replyTo: updateMessageId });
2515
2642
  return;
2516
2643
  }
2517
2644
  try {
2518
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2645
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2519
2646
  } catch (error) {
2520
2647
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
2521
2648
  }
@@ -2524,21 +2651,21 @@ export class FeishuHarnessBridge {
2524
2651
  /**
2525
2652
  * Handle preset selection from the preset dropdown.
2526
2653
  */
2527
- async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) {
2654
+ async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null, replyTo = null } = {}) {
2528
2655
  try {
2529
2656
  const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
2530
2657
  const result = await runPresetCommand(
2531
2658
  `/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
2532
2659
  );
2533
2660
  for (const reply of result?.messages ?? [result?.message]) {
2534
- if (reply) await this.#send(chatId, reply);
2661
+ if (reply) await this.#send(chatId, reply, { replyTo });
2535
2662
  }
2536
2663
  } catch (error) {
2537
- await this.#sendFailure(chatId, error, { logLabel: 'preset selection' });
2664
+ await this.#sendFailure(chatId, error, { logLabel: 'preset selection', replyTo: updateMessageId });
2538
2665
  return;
2539
2666
  }
2540
2667
  try {
2541
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2668
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2542
2669
  } catch (error) {
2543
2670
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
2544
2671
  }
@@ -2553,7 +2680,7 @@ export class FeishuHarnessBridge {
2553
2680
  * catalog validation, busy checks, pending-interaction checks and the
2554
2681
  * session binding lock stay in one place.
2555
2682
  */
2556
- async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) {
2683
+ async #handleModelSelect(key, chatId, modelId, { updateMessageId = null, replyTo = null } = {}) {
2557
2684
  try {
2558
2685
  const result = await runModelCommand(
2559
2686
  `/model ${modelId}`, this.#harness, this.#state, key, {
@@ -2563,14 +2690,14 @@ export class FeishuHarnessBridge {
2563
2690
  },
2564
2691
  );
2565
2692
  for (const reply of result?.messages ?? [result?.message]) {
2566
- if (reply) await this.#send(chatId, reply);
2693
+ if (reply) await this.#send(chatId, reply, { replyTo });
2567
2694
  }
2568
2695
  } catch (error) {
2569
- await this.#sendFailure(chatId, error, { logLabel: 'model selection' });
2696
+ await this.#sendFailure(chatId, error, { logLabel: 'model selection', replyTo: updateMessageId });
2570
2697
  return;
2571
2698
  }
2572
2699
  try {
2573
- await this.#sendMenuCard(key, chatId, { updateMessageId });
2700
+ await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
2574
2701
  } catch (error) {
2575
2702
  this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
2576
2703
  }
@@ -2787,11 +2914,12 @@ export class FeishuHarnessBridge {
2787
2914
  notify = true,
2788
2915
  validatedTarget = null,
2789
2916
  workspaceHint = null,
2917
+ replyTo = null,
2790
2918
  } = {}) {
2791
2919
  const watchRequestedAt = Date.now();
2792
2920
  const reply = async (message) => {
2793
2921
  if (!notify) return;
2794
- await this.#send(chatId, message).catch((error) => {
2922
+ await this.#send(chatId, message, { replyTo }).catch((error) => {
2795
2923
  this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message);
2796
2924
  });
2797
2925
  };
@@ -2840,6 +2968,13 @@ export class FeishuHarnessBridge {
2840
2968
  chatId,
2841
2969
  lastSeq,
2842
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
+ : {}),
2843
2978
  });
2844
2979
  } catch (error) {
2845
2980
  await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
@@ -2854,10 +2989,10 @@ export class FeishuHarnessBridge {
2854
2989
  return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) };
2855
2990
  }
2856
2991
 
2857
- async #runUnwatch(key, chatId, target, { notify = true } = {}) {
2992
+ async #runUnwatch(key, chatId, target, { notify = true, replyTo = null } = {}) {
2858
2993
  const reply = async (message) => {
2859
2994
  if (!notify) return;
2860
- await this.#send(chatId, message).catch((error) => {
2995
+ await this.#send(chatId, message, { replyTo }).catch((error) => {
2861
2996
  this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message);
2862
2997
  });
2863
2998
  };
@@ -2883,7 +3018,7 @@ export class FeishuHarnessBridge {
2883
3018
  return { ok: true, changed: true, entry };
2884
3019
  }
2885
3020
 
2886
- async #showWatchList(key, chatId, { updateMessageId = null } = {}) {
3021
+ async #showWatchList(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
2887
3022
  const entries = this.#state.watchEntries?.(key) ?? [];
2888
3023
  // 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
2889
3024
  let availableSessions = [];
@@ -2913,6 +3048,7 @@ export class FeishuHarnessBridge {
2913
3048
  {
2914
3049
  key,
2915
3050
  updateMessageId,
3051
+ replyTo,
2916
3052
  sessionWorkspace: currentWorkspace,
2917
3053
  },
2918
3054
  );
@@ -2964,7 +3100,7 @@ export class FeishuHarnessBridge {
2964
3100
  await this.#sendCard(
2965
3101
  entry.chatId,
2966
3102
  completionCard(sessionId, entry.title, reason),
2967
- { key },
3103
+ { key, replyTo: entry.replyToMessageId ?? null },
2968
3104
  );
2969
3105
  const current = this.#state.watchEntry?.(key, sessionId);
2970
3106
  if (!current
@@ -3068,17 +3204,18 @@ export class FeishuHarnessBridge {
3068
3204
  actor: senderOpenId(event),
3069
3205
  chatId: event.message.chat_id,
3070
3206
  requiresMention: event.message.chat_type !== 'p2p',
3207
+ replyToMessageId: event.message.message_id,
3071
3208
  }),
3072
3209
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
3073
3210
  files,
3074
3211
  };
3075
3212
  }
3076
3213
 
3077
- async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
3214
+ async #sendAnswerText(chatId, answer, { deliveryId, presentation, replyTo = null }) {
3078
3215
  const providerMessageIds = [];
3079
3216
  for (const chunk of splitText(answer)) {
3080
3217
  this.#signal?.throwIfAborted();
3081
- const messageId = await this.#send(chatId, chunk);
3218
+ const messageId = await this.#send(chatId, chunk, { replyTo });
3082
3219
  if (messageId) providerMessageIds.push(messageId);
3083
3220
  }
3084
3221
  return createDeliveryReceipt({
@@ -3182,6 +3319,7 @@ export class FeishuHarnessBridge {
3182
3319
  {
3183
3320
  deliveryId: messageId,
3184
3321
  presentation: 'feishu-text',
3322
+ replyTo: messageId,
3185
3323
  },
3186
3324
  );
3187
3325
  } catch (error) {
@@ -3252,6 +3390,7 @@ export class FeishuHarnessBridge {
3252
3390
  {
3253
3391
  deliveryId: messageId,
3254
3392
  presentation: 'feishu-text-fallback',
3393
+ replyTo: messageId,
3255
3394
  },
3256
3395
  );
3257
3396
  } catch (fallbackError) {
@@ -3302,6 +3441,7 @@ export class FeishuHarnessBridge {
3302
3441
  {
3303
3442
  deliveryId: messageId,
3304
3443
  presentation: 'feishu-text-fallback',
3444
+ replyTo: messageId,
3305
3445
  },
3306
3446
  );
3307
3447
  } catch (fallbackError) {
@@ -3368,11 +3508,11 @@ export class FeishuHarnessBridge {
3368
3508
  const pending = this.#pendingInteractions.get(key);
3369
3509
  if (!pending || pending !== expected || pending.submitting) {
3370
3510
  if (this.#isResolvedQuestionReply(event, key)) {
3371
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3511
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3372
3512
  return;
3373
3513
  }
3374
3514
  if (claimed && (!pending || pending !== expected)) {
3375
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT());
3515
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id });
3376
3516
  return;
3377
3517
  }
3378
3518
  return this.#enqueueMessage(event, messageId, key, processingReaction, {
@@ -3430,7 +3570,7 @@ export class FeishuHarnessBridge {
3430
3570
  if (error?.code === 'interaction-not-pending') {
3431
3571
  this.#rememberResolvedInteraction(key, pending);
3432
3572
  this.#clearPendingInteraction(key, pending.interactionId);
3433
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3573
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3434
3574
  return;
3435
3575
  }
3436
3576
  pending.submitting = false;
@@ -3448,12 +3588,13 @@ export class FeishuHarnessBridge {
3448
3588
  actor,
3449
3589
  chatId,
3450
3590
  requiresMention,
3591
+ replyToMessageId,
3451
3592
  }) {
3452
3593
  if (await this.#approvals.handleRequested(interaction, {
3453
3594
  key,
3454
3595
  actor,
3455
3596
  requiresMention,
3456
- send: (text) => this.#send(chatId, text),
3597
+ send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
3457
3598
  })) return;
3458
3599
 
3459
3600
  // Approval requests return above; the existing question state machine stays unchanged.
@@ -3484,6 +3625,7 @@ export class FeishuHarnessBridge {
3484
3625
  await this.#send(
3485
3626
  chatId,
3486
3627
  t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
3628
+ { replyTo: replyToMessageId },
3487
3629
  ).catch(() => undefined);
3488
3630
  return;
3489
3631
  }
@@ -3519,6 +3661,7 @@ export class FeishuHarnessBridge {
3519
3661
  answers: [],
3520
3662
  index: 0,
3521
3663
  chatId,
3664
+ replyToMessageId,
3522
3665
  queue: null,
3523
3666
  claimedReplyMessageId: null,
3524
3667
  submitting: false,
@@ -3553,6 +3696,9 @@ export class FeishuHarnessBridge {
3553
3696
  pending.questions.length,
3554
3697
  { requiresMention: pending.requiresMention },
3555
3698
  ),
3699
+ // Reply to the message that started the turn so the question lands in
3700
+ // the same Feishu thread/topic instead of the group's default area.
3701
+ { replyTo: pending.replyToMessageId },
3556
3702
  );
3557
3703
  if (messageId) {
3558
3704
  pending.questionMessageIds.add(messageId);
@@ -3585,7 +3731,7 @@ export class FeishuHarnessBridge {
3585
3731
  await this.#state.markSeen(messageId);
3586
3732
  this.#status.lastMessageAt = new Date().toISOString();
3587
3733
  this.#status.messagesReceived += 1;
3588
- await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
3734
+ await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
3589
3735
  }
3590
3736
 
3591
3737
  #takePendingInteraction(key, interactionId) {
@@ -3651,13 +3797,33 @@ export class FeishuHarnessBridge {
3651
3797
  else processingReaction.success();
3652
3798
  }
3653
3799
 
3654
- async #send(chatId, text) {
3800
+ async #send(chatId, text, { replyTo } = {}) {
3801
+ const content = JSON.stringify({ text });
3802
+ if (replyTo) {
3803
+ try {
3804
+ const response = await this.#client.im.v1.message.reply({
3805
+ path: { message_id: replyTo },
3806
+ data: { msg_type: 'text', content },
3807
+ });
3808
+ if (response?.code && response.code !== 0) {
3809
+ throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
3810
+ }
3811
+ return nonEmptyString(response?.data?.message_id);
3812
+ } catch (error) {
3813
+ // The referenced message may be gone (recalled/deleted); keep the
3814
+ // delivery promise by falling back to a plain chat message.
3815
+ this.#logger.warn?.(
3816
+ '[dsh-feishu] threaded reply failed; falling back to a plain message:',
3817
+ error?.message ?? String(error),
3818
+ );
3819
+ }
3820
+ }
3655
3821
  const response = await this.#client.im.v1.message.create({
3656
3822
  params: { receive_id_type: 'chat_id' },
3657
3823
  data: {
3658
3824
  receive_id: chatId,
3659
3825
  msg_type: 'text',
3660
- content: JSON.stringify({ text }),
3826
+ content,
3661
3827
  },
3662
3828
  });
3663
3829
  if (response?.code && response.code !== 0) {