@xmanrui/dsh-im 4.14.0 → 4.16.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 (69) hide show
  1. package/README.en.md +10 -2
  2. package/README.md +17 -6
  3. package/lib/client.js +1980 -616
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +16 -4
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +64 -1
  13. package/plugin-src/client/index.js +21 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/session-channel-logos.js +241 -0
  16. package/plugin-src/client/styles.js +22 -7
  17. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  18. package/plugin-src/host/channels/discord/index.mjs +8 -10
  19. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  20. package/plugin-src/host/channels/office/index.mjs +9 -5
  21. package/plugin-src/host/channels/qq/index.mjs +8 -15
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  24. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  25. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  26. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  27. package/plugin-src/host/channels/slack/index.mjs +8 -10
  28. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  29. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  30. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  31. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  32. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  33. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  34. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  35. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  36. package/plugin-src/host/delivery-adapter.mjs +4 -0
  37. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  38. package/plugin-src/host/index.mjs +21 -2
  39. package/plugin-src/host/session-title-prefix.mjs +122 -0
  40. package/scripts/verify-model-setting.mjs +54 -0
  41. package/scripts/verify-session-channel-logos.mjs +59 -0
  42. package/scripts/verify-session-title-prefix.mjs +149 -0
  43. package/src/channels/feishu/bridge.mjs +305 -12
  44. package/src/channels/feishu/feishu-cards.mjs +38 -0
  45. package/src/channels/feishu/feishu-channel.mjs +88 -20
  46. package/src/channels/feishu/plugin-controller.mjs +1 -0
  47. package/src/channels/feishu/repair-manager.mjs +3 -2
  48. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  49. package/src/channels/shared/command-catalog.mjs +102 -0
  50. package/src/channels/shared/i18n-en/feishu.mjs +14 -2
  51. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  52. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  53. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  54. package/src/channels/shared/i18n-en.mjs +2 -0
  55. package/src/channels/shared/model-setting.mjs +43 -8
  56. package/src/channels/shared/session-channel-labels.mjs +26 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  58. package/src/channels/telegram/telegram-api.mjs +18 -6
  59. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  60. package/src/channels/wecom/send-error.mjs +31 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  62. package/src/channels/wecom-app/callback-server.mjs +471 -0
  63. package/src/channels/wecom-app/config-store.mjs +240 -0
  64. package/src/channels/wecom-app/harness-client.mjs +11 -0
  65. package/src/channels/wecom-app/state-store.mjs +107 -0
  66. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  67. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  68. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  69. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -87,6 +87,7 @@ import {
87
87
  menuHelpText,
88
88
  modelCard,
89
89
  presetCard,
90
+ answeredQuestionCard,
90
91
  questionCard,
91
92
  sessionListCard,
92
93
  statusCard,
@@ -166,6 +167,15 @@ const STEP_PUSH_MAX_MESSAGES_PER_TURN = 200;
166
167
  const STEP_PUSH_SUMMARY_MAX_CHARS = 120;
167
168
  const STEP_PUSH_ARGUMENTS_MAX_CHARS = 400;
168
169
  const STEP_PUSH_ERROR_MAX_CHARS = 200;
170
+ /** Silence threshold before the thinking status heartbeat appears. */
171
+ const STEP_PUSH_HEARTBEAT_AFTER_MS = 20_000;
172
+ /** The thinking heartbeat refreshes its elapsed time at this cadence. */
173
+ const STEP_PUSH_HEARTBEAT_REFRESH_MS = 30_000;
174
+ /** One turn never refreshes the thinking heartbeat more than this. */
175
+ const STEP_PUSH_HEARTBEAT_MAX_UPDATES = 30;
176
+ /** One pending heartbeat id is retried at most this many times across
177
+ * recall boundaries before it is abandoned (with a warning). */
178
+ const STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS = 3;
169
179
  /** One post message carries at most this many UTF-8 bytes after its rich-text
170
180
  * content has been JSON encoded (Feishu caps rich-text requests at 30KB). */
171
181
  const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
@@ -529,6 +539,9 @@ export class FeishuHarnessBridge {
529
539
  #pendingInteractions = new Map();
530
540
  #interactionKeys = new Map();
531
541
  #resolvedQuestionReplies = new Map();
542
+ // issue #162:已提交答案的 interactionId → 过期时间(TTL-only,惰性清理,
543
+ // 与 #resolvedQuestionReplies 同风格)。陈旧点击据此给出「已回答」提示。
544
+ #answeredInteractionIds = new Map();
532
545
  // Keep the accepted configuration through the existing queue/reply lifecycle.
533
546
  #acceptedMessageIds = new Map();
534
547
  #interactionTasks = new Set();
@@ -816,14 +829,21 @@ export class FeishuHarnessBridge {
816
829
  accept(event) {
817
830
  if (this.#signal?.aborted) return Promise.resolve();
818
831
  const messageId = nonEmptyString(event?.message?.message_id);
819
- if (!messageId || isBotSender(event)) return Promise.resolve();
832
+ if (!messageId) return Promise.resolve();
833
+ const addressed = this.#isAddressed(event);
834
+ // Bot messages require a known, explicit group mention even in all-message mode.
835
+ if (isBotSender(event) && (
836
+ event?.message?.chat_type !== 'group'
837
+ || !this.#botOpenId
838
+ || !addressed
839
+ || senderOpenId(event) === this.#botOpenId
840
+ )) return Promise.resolve();
820
841
  if (!this.#accessPolicy && !isAllowedSender(event, this.#allowedSenderOpenIds)) {
821
842
  this.#status.messagesRejected += 1;
822
843
  this.#status.lastRejectedAt = new Date().toISOString();
823
844
  this.#logger.warn?.('[dsh-feishu] ignored a message from a sender outside the legacy allowlist');
824
845
  return Promise.resolve();
825
846
  }
826
- const addressed = this.#isAddressed(event);
827
847
  if (event?.message?.chat_type !== 'p2p'
828
848
  && this.#groupResponseMode === FEISHU_GROUP_RESPONSE_MODES.MENTION
829
849
  && !addressed) {
@@ -1771,7 +1791,7 @@ export class FeishuHarnessBridge {
1771
1791
  : t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
1772
1792
  await this.#send(chatId, [
1773
1793
  restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
1774
- t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
1794
+ t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);im:message.group_at_msg.include_bot:readonly(用于接收群内其他机器人 @ 当前机器人的消息);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
1775
1795
  '',
1776
1796
  t('当前设备直接打开:'),
1777
1797
  url,
@@ -2185,7 +2205,35 @@ export class FeishuHarnessBridge {
2185
2205
  if (pending && pending.kind === 'question' && !pending.submitting
2186
2206
  && pending.actor === actor
2187
2207
  && Number(indexText) === pending.index) {
2188
- await this.#submitQuestionAnswer(pending, optionLabel, { chatId });
2208
+ await this.#submitQuestionAnswer(pending, optionLabel, { chatId, questionMessageId: messageId });
2209
+ } else {
2210
+ // issue #162:pending 已清除时区分「已答过」与「其他客户端处理」,
2211
+ // 避免对同一张提问卡的重复点击弹出误导提示。
2212
+ const staleNotice = pending
2213
+ ? INTERACTION_RESOLVED_TEXT()
2214
+ : this.#isAnsweredInteraction(interactionId)
2215
+ ? t('这个问题已经回答过了。')
2216
+ : INTERACTION_RESOLVED_TEXT();
2217
+ await reply(staleNotice).catch(() => undefined);
2218
+ }
2219
+ }
2220
+ return;
2221
+ }
2222
+ // issue #162:自定义答案入口——与 answer: 同构校验;通过后引导用户直接
2223
+ // 发送文字消息(canClaimInteractionReply 语义:同 actor 的文本即答案)。
2224
+ if (action.startsWith('answerCustom:')) {
2225
+ const rest = action.slice('answerCustom:'.length);
2226
+ const sep = rest.indexOf(':');
2227
+ if (sep !== -1) {
2228
+ const interactionId = rest.slice(0, sep);
2229
+ const indexText = rest.slice(sep + 1);
2230
+ const qKey = this.#interactionKeys.get(interactionId);
2231
+ const pending = qKey ? this.#pendingInteractions.get(qKey) : null;
2232
+ if (pending && pending.kind === 'question' && !pending.submitting
2233
+ && pending.actor === actor && Number(indexText) === pending.index) {
2234
+ await reply(t('想自定义答案?直接发送文字消息即可,将作为本题答案提交。')).catch(() => undefined);
2235
+ } else if (!pending && this.#isAnsweredInteraction(interactionId)) {
2236
+ await reply(t('这个问题已经回答过了。')).catch(() => undefined);
2189
2237
  } else {
2190
2238
  await reply(INTERACTION_RESOLVED_TEXT()).catch(() => undefined);
2191
2239
  }
@@ -2598,6 +2646,27 @@ export class FeishuHarnessBridge {
2598
2646
  }
2599
2647
  }
2600
2648
 
2649
+ /**
2650
+ * issue #162:已答状态卡的原地替换。patch 失败仅记录警告并明确降级——
2651
+ * 回执缺失不应影响答案提交,也不得像 #sendCard 那样回退成发送新卡。
2652
+ */
2653
+ async #patchCardMessage(chatId, messageId, cardJson) {
2654
+ if (!messageId) return null;
2655
+ try {
2656
+ const response = await this.#client.im.v1.message.patch({
2657
+ path: { message_id: messageId },
2658
+ data: { content: cardJson },
2659
+ });
2660
+ if (response?.code && response.code !== 0) {
2661
+ throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
2662
+ }
2663
+ return messageId;
2664
+ } catch (error) {
2665
+ this.#logger.warn?.('[dsh-feishu] answered-state card patch failed:', error?.message ?? error);
2666
+ return null;
2667
+ }
2668
+ }
2669
+
2601
2670
  async #sendCard(chatId, cardJson, options = {}) {
2602
2671
  const updateMessageId = nonEmptyString(options.updateMessageId);
2603
2672
  const replyTo = nonEmptyString(options.replyTo);
@@ -3764,6 +3833,10 @@ export class FeishuHarnessBridge {
3764
3833
  const state = this.#stepPushSendState.get(key)
3765
3834
  ?? { lastSentAt: 0, count: 0, breakerLogged: false };
3766
3835
  this.#stepPushSendState.set(key, state);
3836
+ // 真实事件到达:先撤回思考中心跳再推真实消息。
3837
+ // pendingRecallIds 是唯一撤回清单(live 心跳创建时即入队),避免同一 id 双撤回;
3838
+ // 撤回失败的 id 留在队列里待下个边界重试,不能在这里无条件清空。
3839
+ await this.#recallPendingHeartbeats(state);
3767
3840
  if (billable && state.count >= STEP_PUSH_MAX_MESSAGES_PER_TURN) {
3768
3841
  if (!state.breakerLogged) {
3769
3842
  state.breakerLogged = true;
@@ -3771,6 +3844,11 @@ export class FeishuHarnessBridge {
3771
3844
  `[dsh-feishu] step push hit ${STEP_PUSH_MAX_MESSAGES_PER_TURN} messages for this turn; staying silent until it ends`,
3772
3845
  );
3773
3846
  }
3847
+ // 熔断静默期内同样刷新 silenceSince:用户仍在活动,静默窗口重新计时,
3848
+ // 避免看门狗在每个被拦截的事件后重建/撤回心跳。activitySeq 单调递增,
3849
+ // 供心跳创建返回后识别「创建在途期间出现了真实活动」(同毫秒事件也能识别)。
3850
+ state.silenceSince = this.#stepPushClock.now();
3851
+ state.activitySeq = (state.activitySeq ?? 0) + 1;
3774
3852
  return;
3775
3853
  }
3776
3854
  this.#signal?.throwIfAborted();
@@ -3780,6 +3858,8 @@ export class FeishuHarnessBridge {
3780
3858
  // message land after the teardown has begun.
3781
3859
  this.#signal?.throwIfAborted();
3782
3860
  state.lastSentAt = this.#stepPushClock.now();
3861
+ state.silenceSince = state.lastSentAt;
3862
+ state.activitySeq = (state.activitySeq ?? 0) + 1;
3783
3863
  if (billable) state.count += 1;
3784
3864
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
3785
3865
  await this.#sendStepPost(chatId, replyToMessageId, paragraphs, fallbackText);
@@ -3882,6 +3962,154 @@ export class FeishuHarnessBridge {
3882
3962
  || /230020|230006/.test(error?.message ?? '');
3883
3963
  }
3884
3964
 
3965
+ #formatElapsed(ms) {
3966
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
3967
+ return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
3968
+ }
3969
+
3970
+ /**
3971
+ * 撤回待撤回心跳清单:撤回成功(recallMessage 返回 true)的 id 移出队列;
3972
+ * 失败的保留,待下个真实事件/回合结束边界重试,单条最多尝试
3973
+ * STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS 次后放弃并告警(消息可能已被手动
3974
+ * 删除或接口持续受限,避免队列无限增长)。live 心跳撤回失败且仍在队列时
3975
+ * 保留 heartbeatMessageId:消息仍在会话里,看门狗继续原地刷新同一条,
3976
+ * 而不是另建一条造成重复。
3977
+ */
3978
+ async #recallPendingHeartbeats(state) {
3979
+ const remaining = [];
3980
+ for (const entry of state.pendingRecallIds ?? []) {
3981
+ let recalled = false;
3982
+ try {
3983
+ recalled = (await this.#channel?.recallMessage?.(entry.id)) === true;
3984
+ } catch (error) {
3985
+ this.#logger.warn?.('[dsh-feishu] heartbeat recall failed:', error?.message ?? String(error));
3986
+ }
3987
+ if (recalled) continue;
3988
+ const attempts = (entry.attempts ?? 0) + 1;
3989
+ if (attempts >= STEP_PUSH_HEARTBEAT_RECALL_ATTEMPTS) {
3990
+ this.#logger.warn?.(`[dsh-feishu] heartbeat recall gave up after ${attempts} attempts: ${entry.id}`);
3991
+ continue;
3992
+ }
3993
+ remaining.push({ id: entry.id, attempts });
3994
+ }
3995
+ state.pendingRecallIds = remaining;
3996
+ const liveId = state.heartbeatMessageId;
3997
+ if (liveId && !remaining.some((entry) => entry.id === liveId)) state.heartbeatMessageId = null;
3998
+ }
3999
+
4000
+ /**
4001
+ * 撤回一条刚创建、但创建返回时已过时(回合已结束或创建在途期间出现了
4002
+ * 真实活动)的心跳:成功即丢弃;失败则按失败保留策略入队,待下个边界重试。
4003
+ */
4004
+ async #recallLateHeartbeat(state, messageId) {
4005
+ let recalled = false;
4006
+ try {
4007
+ recalled = (await this.#channel?.recallMessage?.(messageId)) === true;
4008
+ } catch (error) {
4009
+ this.#logger.warn?.('[dsh-feishu] late heartbeat recall failed:', error?.message ?? String(error));
4010
+ }
4011
+ if (recalled) return;
4012
+ state.pendingRecallIds = [...(state.pendingRecallIds ?? []), { id: messageId, attempts: 1 }];
4013
+ }
4014
+
4015
+ /**
4016
+ * 思考中状态看门狗:直推回合内静默满阈值时推送/原地刷新一条「⏳ 正在思考中…」
4017
+ * 心跳 post;真实事件或回合结束时撤回。轮询检查可注入 stepPushClock(测试
4018
+ * 手动推进时钟),生命周期归属当前回合(stop 后循环退出)。
4019
+ */
4020
+ #startThinkingStatusWatchdog(key, chatId, replyToMessageId) {
4021
+ const state = this.#stepPushSendState.get(key);
4022
+ if (!state) return { stop: async () => {} };
4023
+ state.heartbeatStopped = false;
4024
+ state.heartbeatMessageId = null;
4025
+ state.heartbeatNextAttemptAt = 0;
4026
+ const watchdog = { stopped: false };
4027
+ void (async () => {
4028
+ try {
4029
+ // 轮询式看门狗:每 10ms 检查一次(真实时间),静默判定读取可注入
4030
+ // 时钟——生产随真实时间自然触发;测试手动推进时钟即可确定性验证。
4031
+ while (!watchdog.stopped && !this.#signal?.aborted && !state.heartbeatStopped) {
4032
+ await new Promise((resolve) => setTimeout(resolve, 10));
4033
+ if (watchdog.stopped || this.#signal?.aborted || state.heartbeatStopped) return;
4034
+ const now = this.#stepPushClock.now();
4035
+ // 静默基线 = silenceSince(回合开始播种,每次真实推送刷新)。
4036
+ const silentFor = now - (state.silenceSince ?? state.turnStartedAt ?? now);
4037
+ if (state.heartbeatMessageId) {
4038
+ if (now - (state.heartbeatUpdatedAt ?? 0) < STEP_PUSH_HEARTBEAT_REFRESH_MS) continue;
4039
+ const elapsed = this.#formatElapsed(now - (state.heartbeatShownAt ?? state.heartbeatUpdatedAt));
4040
+ state.heartbeatUpdatedAt = this.#stepPushClock.now();
4041
+ state.heartbeatUpdates = (state.heartbeatUpdates ?? 0) + 1;
4042
+ if (state.heartbeatUpdates > STEP_PUSH_HEARTBEAT_MAX_UPDATES) {
4043
+ state.heartbeatStopped = true;
4044
+ continue;
4045
+ }
4046
+ try {
4047
+ const response = await this.#client.im.v1.message.update({
4048
+ path: { message_id: state.heartbeatMessageId },
4049
+ data: {
4050
+ msg_type: 'post',
4051
+ content: JSON.stringify({
4052
+ zh_cn: { content: [[{ tag: 'text', text: t('⏳ 正在思考中…(已运行 {elapsed})', { elapsed }) }]] },
4053
+ }),
4054
+ },
4055
+ });
4056
+ if (response?.code && response.code !== 0) {
4057
+ this.#logger.warn?.('[dsh-feishu] heartbeat update failed:', response.msg || response.code);
4058
+ }
4059
+ } catch (error) {
4060
+ // 更新失败:保留心跳(消息仍在会话中),留待真实事件/回合结束撤回。
4061
+ this.#logger.warn?.('[dsh-feishu] heartbeat update failed:', error.message);
4062
+ }
4063
+ continue;
4064
+ }
4065
+ if (now < (state.heartbeatNextAttemptAt ?? 0)) continue;
4066
+ if (silentFor < STEP_PUSH_HEARTBEAT_AFTER_MS) continue;
4067
+ // 记录发起创建时的活动序号:创建请求在途期间若出现真实活动
4068
+ // (真实推送或熔断分支都会推进 activitySeq),这条心跳返回时
4069
+ // 已过时,必须立即撤回而不是登记为当前状态。
4070
+ const activitySeqAtDispatch = state.activitySeq ?? 0;
4071
+ const shownAt = now;
4072
+ const result = await this.#sendStepPost(
4073
+ chatId, replyToMessageId,
4074
+ [[{ tag: 'text', text: t('⏳ 正在思考中…(已运行 {elapsed})', { elapsed: this.#formatElapsed(0) }) }]],
4075
+ t('⏳ 正在思考中…'),
4076
+ );
4077
+ if (watchdog.stopped || this.#signal?.aborted) {
4078
+ // 创建期间回合已结束:立即撤回刚发出的心跳,避免残留。
4079
+ if (result.ok && result.messageId) await this.#recallLateHeartbeat(state, result.messageId);
4080
+ return;
4081
+ }
4082
+ if ((state.activitySeq ?? 0) !== activitySeqAtDispatch) {
4083
+ // 创建期间出现真实活动:这条「正在思考中」会排在真实步骤之后,
4084
+ // 立即撤回且不登记(不进入原地刷新,也不作为 live 心跳)。
4085
+ if (result.ok && result.messageId) await this.#recallLateHeartbeat(state, result.messageId);
4086
+ continue;
4087
+ }
4088
+ if (result.ok) {
4089
+ state.heartbeatMessageId = result.messageId ?? null;
4090
+ state.pendingRecallIds = [...(state.pendingRecallIds ?? []), { id: result.messageId, attempts: 0 }];
4091
+ state.heartbeatShownAt = shownAt;
4092
+ state.heartbeatUpdatedAt = shownAt;
4093
+ } else {
4094
+ // 发送失败:退避后再试,避免每个轮询都重试造成堆积。
4095
+ state.heartbeatNextAttemptAt = now + 5_000;
4096
+ }
4097
+ }
4098
+ } catch (error) {
4099
+ if (!this.#signal?.aborted) {
4100
+ this.#logger.warn?.('[dsh-feishu] thinking status watchdog failed:', error.message);
4101
+ }
4102
+ }
4103
+ })();
4104
+ return {
4105
+ stop: async () => {
4106
+ watchdog.stopped = true;
4107
+ // 回合结束撤回:失败项保留在队列里(跨回合携带),待下个边界重试。
4108
+ await this.#recallPendingHeartbeats(state);
4109
+ },
4110
+ };
4111
+ }
4112
+
3885
4113
  /**
3886
4114
  * 分步直推:`#answerWithStream` 流式分支在开关开启时的完整替代路径。
3887
4115
  * 过程(工具调用、助手中间说明)与最终答案均以富文本 post 逐条直推
@@ -3922,7 +4150,15 @@ export class FeishuHarnessBridge {
3922
4150
  if (oldest !== undefined) this.#stepPushSendState.delete(oldest);
3923
4151
  }
3924
4152
  this.#stepPushSendState.set(key, {
4153
+ // 思考中状态的静默基线 = 回合开始时刻;lastSentAt 仅供真实推送刷新,
4154
+ // 不参与心跳判定(否则首个真实步骤的节流等待会被误判/误伤)。
4155
+ silenceSince: this.#stepPushClock.now(),
4156
+ turnStartedAt: this.#stepPushClock.now(),
3925
4157
  lastSentAt: priorState?.lastSentAt ?? 0,
4158
+ // 撤回失败的心跳跨回合携带:回合边界没撤掉的「正在思考中」在下个
4159
+ // 回合的真实事件/结束时继续重试,而不是被 reopen 静默丢弃。
4160
+ pendingRecallIds: priorState?.pendingRecallIds ?? [],
4161
+ activitySeq: 0,
3926
4162
  count: 0,
3927
4163
  breakerLogged: false,
3928
4164
  });
@@ -3954,7 +4190,10 @@ export class FeishuHarnessBridge {
3954
4190
  messageId,
3955
4191
  );
3956
4192
  };
3957
- const completed = await askInWorkspaceSession({
4193
+ const watchdog = this.#startThinkingStatusWatchdog(key, chatId, messageId);
4194
+ let completed;
4195
+ try {
4196
+ completed = await askInWorkspaceSession({
3958
4197
  deferredDelivery: () => ({ coordinator: this.#deferred, chatId, replyToMessageId: messageId }),
3959
4198
  harness: this.#harness,
3960
4199
  state: this.#state,
@@ -3995,6 +4234,10 @@ export class FeishuHarnessBridge {
3995
4234
  },
3996
4235
  },
3997
4236
  });
4237
+ } finally {
4238
+ // 回合结束(成功/失败/中断):停看门狗并撤回残留思考中心跳。
4239
+ await watchdog.stop();
4240
+ }
3998
4241
  markAskComplete();
3999
4242
  const finalStepText = pendingStep ? pendingStep.text : null;
4000
4243
  pendingStep = null;
@@ -4196,14 +4439,22 @@ export class FeishuHarnessBridge {
4196
4439
  // issue #86:独立交互消息(提问/审批)会落在占位卡下方,呈现前
4197
4440
  // 先换卡,让最终答案落在交互消息之后的新流式卡上。
4198
4441
  onInteraction: async (interaction) => {
4199
- if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
4200
- && typeof controller?.rotate === 'function') {
4201
- await controller.rotate();
4442
+ try {
4443
+ if ((interaction?.kind === 'question' || interaction?.kind === 'approval')
4444
+ && typeof controller?.rotate === 'function') {
4445
+ await controller.rotate();
4446
+ }
4447
+ await baseAskOptions.onInteraction(interaction);
4448
+ } finally {
4449
+ // issue #163:呈现完成(或失败)即解除换卡挂起,此后建的新卡
4450
+ // 必然位于交互消息下方;interactionPresented 不存在时静默跳过。
4451
+ controller?.interactionPresented?.();
4202
4452
  }
4203
- await baseAskOptions.onInteraction(interaction);
4204
4453
  },
4205
4454
  onUpdate: async (update) => {
4206
- await controller.setContent(this.#progressText(update));
4455
+ await controller.setContent(this.#progressText(update), {
4456
+ transient: update.type !== 'text',
4457
+ });
4207
4458
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
4208
4459
  },
4209
4460
  };
@@ -4396,14 +4647,35 @@ export class FeishuHarnessBridge {
4396
4647
  await this.#submitQuestionAnswer(pending, text, {
4397
4648
  chatId: event.message.chat_id,
4398
4649
  messageId,
4650
+ questionMessageId: pending.questionCardMessageId,
4399
4651
  });
4400
4652
  }
4401
4653
 
4402
- async #submitQuestionAnswer(pending, answerText, { chatId, messageId } = {}) {
4654
+ async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
4403
4655
  const question = pending.questions[pending.index];
4404
4656
  if (!question) return;
4405
4657
  pending.chatId = chatId ?? pending.chatId;
4406
4658
 
4659
+ // issue #162:已答状态卡以推进前的题号渲染,并整卡替换原提问卡。
4660
+ const answeredIndex = pending.index;
4661
+ const answeredCardJson = answeredQuestionCard({
4662
+ interactionId: pending.interactionId,
4663
+ // 评审补充(#162):与呈现提问卡保持一致,传入原题的文本/标题/说明,
4664
+ // 否则回执会退化为「请输入你的回答。」。
4665
+ question: question.question,
4666
+ header: question.header,
4667
+ detail: question.detail,
4668
+ options: Array.isArray(question?.options) ? question.options : [],
4669
+ chosen: answerText,
4670
+ index: answeredIndex,
4671
+ total: pending.questions.length,
4672
+ });
4673
+ const patchAnsweredCard = () => this.#patchCardMessage(
4674
+ pending.chatId,
4675
+ questionMessageId ?? pending.questionCardMessageId,
4676
+ answeredCardJson,
4677
+ );
4678
+
4407
4679
  pending.answers.push(harnessAnswerForQuestion(question, answerText));
4408
4680
  pending.index += 1;
4409
4681
  if (pending.index < pending.questions.length) {
@@ -4411,6 +4683,7 @@ export class FeishuHarnessBridge {
4411
4683
  pending.claimedReplyMessageId = null;
4412
4684
  }
4413
4685
  pending.needsPresentation = true;
4686
+ await patchAnsweredCard();
4414
4687
  try {
4415
4688
  await this.#presentInteraction(pending);
4416
4689
  } catch {
@@ -4431,6 +4704,8 @@ export class FeishuHarnessBridge {
4431
4704
  answer: { answers: pending.answers },
4432
4705
  },
4433
4706
  });
4707
+ await patchAnsweredCard();
4708
+ this.#answeredInteractionIds.set(pending.interactionId, Date.now() + RESOLVED_REPLY_TTL_MS);
4434
4709
  this.#rememberResolvedInteraction(key, pending);
4435
4710
  this.#clearPendingInteraction(key, pending.interactionId);
4436
4711
  this.#status.lastError = null;
@@ -4567,6 +4842,7 @@ export class FeishuHarnessBridge {
4567
4842
  submitting: false,
4568
4843
  needsPresentation: true,
4569
4844
  questionMessageIds: new Set(),
4845
+ questionCardMessageId: null,
4570
4846
  inactive: false,
4571
4847
  };
4572
4848
  this.#pendingInteractions.set(key, pending);
@@ -4596,6 +4872,10 @@ export class FeishuHarnessBridge {
4596
4872
  && options.length > 0
4597
4873
  && question.multiSelect !== true;
4598
4874
  let messageId;
4875
+ let presentedAsCard = false;
4876
+ // 评审补充(#162):每次呈现先重置——本题走文本路径(或卡片回退到文本)
4877
+ // 时不得沿用上一题的卡消息 id,否则后一题的回执会覆盖前一张已答卡。
4878
+ pending.questionCardMessageId = null;
4599
4879
  if (interactive) {
4600
4880
  // Single-choice question with options: render each option as a button.
4601
4881
  messageId = await this.#sendCard(
@@ -4610,7 +4890,10 @@ export class FeishuHarnessBridge {
4610
4890
  total: pending.questions.length,
4611
4891
  }),
4612
4892
  { key: pending.key, replyTo: pending.replyToMessageId },
4613
- ).catch(async () => {
4893
+ ).then((sent) => {
4894
+ presentedAsCard = true;
4895
+ return sent;
4896
+ }).catch(async () => {
4614
4897
  // Fall back to the plain-text question if the card cannot be sent.
4615
4898
  // If the text send also fails, let the error propagate so the pending
4616
4899
  // question is not marked as presented and the existing retry logic runs.
@@ -4637,11 +4920,21 @@ export class FeishuHarnessBridge {
4637
4920
  }
4638
4921
  if (messageId) {
4639
4922
  pending.questionMessageIds.add(messageId);
4923
+ // issue #162:仅卡片路径成功时记录——文本回退产生的消息 id 不参与已答回写。
4924
+ if (presentedAsCard) pending.questionCardMessageId = messageId;
4640
4925
  if (pending.inactive) this.#rememberResolvedInteraction(pending.key, pending);
4641
4926
  }
4642
4927
  pending.needsPresentation = false;
4643
4928
  }
4644
4929
 
4930
+ #isAnsweredInteraction(interactionId) {
4931
+ const now = Date.now();
4932
+ for (const [id, expiresAt] of this.#answeredInteractionIds) {
4933
+ if (expiresAt <= now) this.#answeredInteractionIds.delete(id);
4934
+ }
4935
+ return this.#answeredInteractionIds.has(interactionId);
4936
+ }
4937
+
4645
4938
  #rememberResolvedInteraction(key, pending) {
4646
4939
  const expiresAt = Date.now() + RESOLVED_REPLY_TTL_MS;
4647
4940
  for (const messageId of pending.questionMessageIds ?? []) {
@@ -933,6 +933,44 @@ export function questionCard({ interactionId, header, question, detail, options,
933
933
  // question cannot be applied to the current one: answer:<interactionId>:<index>:<label>
934
934
  elements.push(button(buttonText, `answer:${interactionId}:${index}:${label}`));
935
935
  }
936
+ // issue #162:自定义答案入口与 menuCard/steerCard 的「✏️ 更多 / 自定义…」
937
+ // 对齐——文字流本就支持 custom 答案,卡片补上引导入口。
938
+ elements.push(button(t('✏️ 其他答案…'), `answerCustom:${interactionId}:${index}`));
936
939
  }
937
940
  return cardWith(t('❓ 请补充信息{progress}', { progress }), elements);
938
941
  }
942
+
943
+ /**
944
+ * issue #162:提问卡被回答后的已答状态卡——原卡整卡替换为该回执样式:
945
+ * 保留问题与选项文本、标注已选项、不渲染任何按钮(重复点击从根源消失)。
946
+ * 与 questionCard 一样返回 JSON 字符串,调用方直接作为卡 content 使用。
947
+ */
948
+ export function answeredQuestionCard({ interactionId, header, question, detail, options, chosen, index, total }) {
949
+ const elements = [];
950
+ const progress = total > 1 ? `(${index + 1}/${total})` : '';
951
+ if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
952
+ const qText = typeof question === 'string' && question.trim() ? question : t('请输入你的回答。');
953
+ elements.push({ tag: 'div', text: markdown(String(qText)) });
954
+ if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
955
+ if (Array.isArray(options) && options.length > 0) {
956
+ elements.push({ tag: 'hr' });
957
+ let chosenShown = false;
958
+ for (const option of options) {
959
+ const label = typeof option?.label === 'string' ? option.label : '';
960
+ if (!label) continue;
961
+ if (label === chosen) chosenShown = true;
962
+ elements.push({ tag: 'div', text: markdown(label === chosen
963
+ ? t('✅ 已选择:{label}', { label })
964
+ : label) });
965
+ }
966
+ // 自定义文本答案不在预设选项里——单独一行展示所选内容。
967
+ if (!chosenShown && typeof chosen === 'string' && chosen.trim()) {
968
+ elements.push({ tag: 'div', text: markdown(t('✅ 已选择:{label}', { label: chosen })) });
969
+ }
970
+ } else if (typeof chosen === 'string' && chosen.trim()) {
971
+ elements.push({ tag: 'div', text: markdown(t('✅ 已选择:{label}', { label: chosen })) });
972
+ }
973
+ elements.push({ tag: 'hr' });
974
+ elements.push({ tag: 'div', text: markdown(t('回答已提交,对话将继续。')) });
975
+ return cardWith(t('✅ 已回答{progress}', { progress }), elements);
976
+ }