@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.
- package/README.en.md +10 -2
- package/README.md +17 -6
- package/lib/client.js +1980 -616
- package/lib/index.js +303 -293
- package/package.json +6 -2
- package/plugin-src/client/access-policy-settings.js +6 -0
- package/plugin-src/client/channels/feishu/index.js +16 -4
- package/plugin-src/client/channels/wecom-app/api.js +124 -0
- package/plugin-src/client/channels/wecom-app/index.js +612 -0
- package/plugin-src/client/channels/wecom-app/styles.js +25 -0
- package/plugin-src/client/delivery-settings.js +7 -0
- package/plugin-src/client/i18n.js +64 -1
- package/plugin-src/client/index.js +21 -0
- package/plugin-src/client/model-setting.js +135 -69
- package/plugin-src/client/session-channel-logos.js +241 -0
- package/plugin-src/client/styles.js +22 -7
- package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
- package/plugin-src/host/channels/discord/index.mjs +8 -10
- package/plugin-src/host/channels/feishu/index.mjs +8 -15
- package/plugin-src/host/channels/office/index.mjs +9 -5
- package/plugin-src/host/channels/qq/index.mjs +8 -15
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
- package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
- package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
- package/plugin-src/host/channels/shared/startup.mjs +47 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/plugin-src/host/channels/slack/index.mjs +8 -10
- package/plugin-src/host/channels/telegram/index.mjs +8 -10
- package/plugin-src/host/channels/wecom/index.mjs +8 -15
- package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
- package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
- package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
- package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
- package/plugin-src/host/channels/weixin/index.mjs +8 -15
- package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
- package/plugin-src/host/delivery-adapter.mjs +4 -0
- package/plugin-src/host/delivery-suggestions.mjs +4 -0
- package/plugin-src/host/index.mjs +21 -2
- package/plugin-src/host/session-title-prefix.mjs +122 -0
- package/scripts/verify-model-setting.mjs +54 -0
- package/scripts/verify-session-channel-logos.mjs +59 -0
- package/scripts/verify-session-title-prefix.mjs +149 -0
- package/src/channels/feishu/bridge.mjs +305 -12
- package/src/channels/feishu/feishu-cards.mjs +38 -0
- package/src/channels/feishu/feishu-channel.mjs +88 -20
- package/src/channels/feishu/plugin-controller.mjs +1 -0
- package/src/channels/feishu/repair-manager.mjs +3 -2
- package/src/channels/shared/bot-workspace-store.mjs +11 -3
- package/src/channels/shared/command-catalog.mjs +102 -0
- package/src/channels/shared/i18n-en/feishu.mjs +14 -2
- package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
- package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/model-setting.mjs +43 -8
- package/src/channels/shared/session-channel-labels.mjs +26 -0
- package/src/channels/shared/text-harness-bridge.mjs +2 -26
- package/src/channels/telegram/telegram-api.mjs +18 -6
- package/src/channels/telegram/telegram-runtime.mjs +34 -32
- package/src/channels/wecom/send-error.mjs +31 -0
- package/src/channels/wecom/wecom-bridge.mjs +59 -17
- package/src/channels/wecom-app/callback-server.mjs +471 -0
- package/src/channels/wecom-app/config-store.mjs +240 -0
- package/src/channels/wecom-app/harness-client.mjs +11 -0
- package/src/channels/wecom-app/state-store.mjs +107 -0
- package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
- package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
- package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
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
|
-
).
|
|
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
|
+
}
|