Merge pull request #104 from evanfang0054/fix/feishu-thread-card-actions

fix(feishu): thread card-action confirmations to the card message
This commit is contained in:
Evan Fang 2026-09-01 10:05:40 +08:00 • committed by GitHub
commit 075d2713c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 213 additions and 79 deletions

File diff suppressed because one or more lines are too long

View file

@ -774,7 +774,7 @@ export class FeishuHarnessBridge {
await this.#state.markSeen(messageId); await this.#state.markSeen(messageId);
this.#status.lastMessageAt = new Date().toISOString(); this.#status.lastMessageAt = new Date().toISOString();
this.#status.messagesReceived += 1; this.#status.messagesReceived += 1;
if (result?.message) await this.#send(event.message.chat_id, result.message); if (result?.message) await this.#send(event.message.chat_id, result.message, { replyTo: event.message.message_id });
this.#status.lastError = null; this.#status.lastError = null;
}) })
.then(() => this.#finishReaction(messageId, processingReaction, 'DONE')) .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
@ -843,7 +843,7 @@ export class FeishuHarnessBridge {
const text = options.appendMessage const text = options.appendMessage
? `${messageFailureText(failure)}\n\n${options.appendMessage}` ? `${messageFailureText(failure)}\n\n${options.appendMessage}`
: messageFailureText(failure); : messageFailureText(failure);
await this.#send(chatId, text).catch(() => undefined); await this.#send(chatId, text, { replyTo: options.replyTo }).catch(() => undefined);
return failure; return failure;
} }
@ -874,6 +874,7 @@ export class FeishuHarnessBridge {
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
failureText, failureText,
{ replyTo: event.message.message_id },
).catch(() => undefined); ).catch(() => undefined);
} }
@ -1537,7 +1538,7 @@ export class FeishuHarnessBridge {
?? nonEmptyString(event?.open_chat_id) ?? nonEmptyString(event?.open_chat_id)
?? nonEmptyString(event?.chat_id); ?? nonEmptyString(event?.chat_id);
if (chatId) { if (chatId) {
this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。')).catch(() => undefined); this.#send(chatId, t('这个菜单已过期,请回复 /m 重新打开。'), { replyTo: messageId }).catch(() => undefined);
} }
return Promise.resolve(); return Promise.resolve();
} }
@ -1590,7 +1591,7 @@ export class FeishuHarnessBridge {
await this.#sendSteer(entry, formText); await this.#sendSteer(entry, formText);
return; return;
} }
await this.#send(entry.chatId, t('请输入补充指令后再提交。')); await this.#send(entry.chatId, t('请输入补充指令后再提交。'), { replyTo: entry.messageId ?? null });
return; return;
} }
} }
@ -1630,7 +1631,7 @@ export class FeishuHarnessBridge {
} }
this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks'); this.#logger.warn?.('[dsh-feishu] card action queue is full; dropping callbacks');
let tracked; let tracked;
tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。')) tracked = this.#send(entry.chatId, t('操作过于频繁,请稍后再试。'), { replyTo: entry.messageId ?? null })
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
this.#cardActionTasks.delete(tracked); this.#cardActionTasks.delete(tracked);
@ -1711,7 +1712,7 @@ export class FeishuHarnessBridge {
}) })
.catch(async (error) => { .catch(async (error) => {
if (this.#signal?.aborted) return; if (this.#signal?.aborted) return;
await this.#sendFailure(entry.chatId, error, { logLabel: 'card action' }); await this.#sendFailure(entry.chatId, error, { logLabel: 'card action', replyTo: entry.messageId });
}) })
.finally(() => { .finally(() => {
if (this.#cardActionInFlight.get(dedupeKey) === tracked) { if (this.#cardActionInFlight.get(dedupeKey) === tracked) {
@ -1752,10 +1753,13 @@ export class FeishuHarnessBridge {
sessionPage = 0, sessionPage = 0,
selections = [], selections = [],
}) { }) {
// Confirmations triggered by a card interaction stay anchored to the
// card's message so they land inside the same Feishu topic.
const reply = (text) => this.#send(chatId, text, { replyTo: messageId });
if (action === 'sessions' || /^sessions:\d+$/.test(action)) { if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length)); const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
await this.#showSessions( await this.#showSessions(
{ chatId, key }, { chatId, key, replyTo: messageId },
sessionWorkspace, sessionWorkspace,
page, page,
{ updateMessageId: messageId }, { updateMessageId: messageId },
@ -1763,17 +1767,17 @@ export class FeishuHarnessBridge {
return; return;
} }
if (action === 'workspaces') { if (action === 'workspaces') {
await this.#showWorkspaces({ chatId, key }, { updateMessageId: messageId }); await this.#showWorkspaces({ chatId, key, replyTo: messageId }, { updateMessageId: messageId });
return; return;
} }
if (action === 'watchlist') { if (action === 'watchlist') {
await this.#showWatchList(key, chatId, { updateMessageId: messageId }); await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
// 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组 // 多选关注下拉:action=watch_add / watch_remove,选中项在 selections 数组
if (action === 'watch_add' || action === 'watch_remove') { if (action === 'watch_add' || action === 'watch_remove') {
if (selections.length === 0) { if (selections.length === 0) {
await this.#send(chatId, t('请先选择至少一个会话。')); await reply(t('请先选择至少一个会话。'));
return; return;
} }
let changed = 0; let changed = 0;
@ -1794,6 +1798,7 @@ export class FeishuHarnessBridge {
const result = await this.#runWatch(key, chatId, sessionId, { const result = await this.#runWatch(key, chatId, sessionId, {
notify: false, notify: false,
validatedTarget, validatedTarget,
replyTo: messageId,
}); });
if (result.changed) changed += 1; if (result.changed) changed += 1;
else if (!result.ok) failed += 1; else if (!result.ok) failed += 1;
@ -1819,54 +1824,53 @@ export class FeishuHarnessBridge {
? t('所选会话已在关注列表中。') ? t('所选会话已在关注列表中。')
: t('所选会话已不在关注列表中。'); : t('所选会话已不在关注列表中。');
try { try {
await this.#showWatchList(key, chatId, { updateMessageId: messageId }); await this.#showWatchList(key, chatId, { updateMessageId: messageId, replyTo: messageId });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message); this.#logger.warn?.('[dsh-feishu] watch list refresh failed:', error.message);
} }
await this.#send(chatId, summary).catch((error) => { await reply(summary).catch((error) => {
this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message); this.#logger.warn?.('[dsh-feishu] watch batch summary failed:', error.message);
}); });
return; return;
} }
if (action === 'new') { if (action === 'new') {
if (this.#queues.has(key) || this.#hasPendingInteraction(key)) { if (this.#queues.has(key) || this.#hasPendingInteraction(key)) {
await this.#send(chatId, t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。')); await reply(t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'));
return; return;
} }
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#send(chatId, t('已开启全新 Harness 会话。')); await reply(t('已开启全新 Harness 会话。'));
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId }); await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
if (action === 'use:current') { if (action === 'use:current') {
const sessionId = this.#state.sessionFor(key); const sessionId = this.#state.sessionFor(key);
if (typeof sessionId !== 'string' || !sessionId) { if (typeof sessionId !== 'string' || !sessionId) {
await this.#send(chatId, t('当前没有绑定的会话,请先从会话列表选择。')); await reply(t('当前没有绑定的会话,请先从会话列表选择。'));
return; return;
} }
await this.#send(chatId, t('已就绪,直接发消息即可继续当前会话。')); await reply(t('已就绪,直接发消息即可继续当前会话。'));
return; return;
} }
if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') { if (action === 'archive_toggle' || action === 'archive:on' || action === 'archive:off') {
const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false); const next = action === 'archive:on' ? true : action === 'archive:off' ? false : !(this.#state?.includesArchivedSessions?.() ?? false);
await this.#state?.setIncludeArchivedSessions?.(next); await this.#state?.setIncludeArchivedSessions?.(next);
await this.#send( await reply(
chatId,
next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'), next ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
); );
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId }); await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
if (action === 'repair') { if (action === 'repair') {
await this.#send(chatId, t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。')); await reply(t('修复需在私聊中验证接入者身份,请直接发送 /repair 开始。'));
return; return;
} }
if (action === 'compact') { if (action === 'compact') {
await this.#handleCompact(key, chatId); await this.#handleCompact(key, chatId, messageId);
return; return;
} }
if (action === 'stop') { if (action === 'stop') {
await this.#handleStop(key, chatId); await this.#handleStop(key, chatId, messageId);
return; return;
} }
if (action === 'steer') { if (action === 'steer') {
@ -1877,10 +1881,10 @@ export class FeishuHarnessBridge {
if (action.startsWith('steer:')) { if (action.startsWith('steer:')) {
const raw = action.slice('steer:'.length); const raw = action.slice('steer:'.length);
if (raw === 'custom') { if (raw === 'custom') {
await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId }); await this.#sendCard(chatId, customSteerCard(), { key, updateMessageId: messageId, replyTo: messageId });
return; return;
} }
await this.#sendSteer({ key, chatId }, raw); await this.#sendSteer({ key, chatId, messageId }, raw);
return; return;
} }
if (action === 'presets') { if (action === 'presets') {
@ -1900,7 +1904,7 @@ export class FeishuHarnessBridge {
return; return;
} }
if (action === 'back_to_menu') { if (action === 'back_to_menu') {
await this.#sendMenuCard(key, chatId, { updateMessageId: messageId }); await this.#sendMenuCard(key, chatId, { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
if (action === 'preset_default') { if (action === 'preset_default') {
@ -1923,18 +1927,18 @@ export class FeishuHarnessBridge {
return; return;
} }
if (action.startsWith('use:')) { if (action.startsWith('use:')) {
await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId }); await this.#bindSession(key, chatId, action.slice('use:'.length), { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
if (action.startsWith('workspace:')) { if (action.startsWith('workspace:')) {
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId }); await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length), { updateMessageId: messageId, replyTo: messageId });
return; return;
} }
if (action.startsWith('unwatch:')) { if (action.startsWith('unwatch:')) {
const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length)); const result = await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length), { replyTo: messageId });
if (result.ok && messageId) { if (result.ok && messageId) {
await this.#showSessions( await this.#showSessions(
{ chatId, key }, { chatId, key, replyTo: messageId },
sessionWorkspace, sessionWorkspace,
sessionPage, sessionPage,
{ updateMessageId: messageId }, { updateMessageId: messageId },
@ -1946,10 +1950,11 @@ export class FeishuHarnessBridge {
const sessionId = action.slice('watch:'.length); const sessionId = action.slice('watch:'.length);
const result = await this.#runWatch(key, chatId, sessionId, { const result = await this.#runWatch(key, chatId, sessionId, {
workspaceHint: sessionWorkspace, workspaceHint: sessionWorkspace,
replyTo: messageId,
}); });
if (result.ok && messageId) { if (result.ok && messageId) {
await this.#showSessions( await this.#showSessions(
{ chatId, key }, { chatId, key, replyTo: messageId },
sessionWorkspace, sessionWorkspace,
sessionPage, sessionPage,
{ updateMessageId: messageId }, { updateMessageId: messageId },
@ -1978,48 +1983,50 @@ export class FeishuHarnessBridge {
} }
async #handleMenuPick(menu, number, { chatId, key, event }) { async #handleMenuPick(menu, number, { chatId, key, event }) {
const replyTo = event?.message?.message_id ?? null;
const reply = (text) => this.#send(chatId, text, { replyTo });
if (menu.kind === 'menu') { if (menu.kind === 'menu') {
// Number fallback for the total menu: // Number fallback for the total menu:
// 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助 // 1=工作区列表 2=新会话 3=会话列表 4=状态 5=修复 6=帮助
const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help']; const actions = ['workspaces', 'new', 'sessions', 'status', 'repair', 'help'];
const action = actions[number - 1]; const action = actions[number - 1];
if (!action) { if (!action) {
await this.#send(chatId, t('菜单没有这个编号,回复 /m 重新打开。')); await reply(t('菜单没有这个编号,回复 /m 重新打开。'));
return; return;
} }
if (action === 'repair') { if (action === 'repair') {
await this.#handleRepairCommand(event, '/repair'); await this.#handleRepairCommand(event, '/repair');
return; return;
} }
await this.#handleCardAction(action, { chatId, key }); await this.#handleCardAction(action, { chatId, key, messageId: replyTo });
return; return;
} }
if (menu.kind === 'sessions') { if (menu.kind === 'sessions') {
const session = menu.sessions[number - 1]; const session = menu.sessions[number - 1];
if (!session?.sessionId) { if (!session?.sessionId) {
await this.#send(chatId, t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length })); await reply(t('本页只有 {count} 个会话,回复 /sessionlist 重新查看。', { count: menu.sessions.length }));
return; return;
} }
// The number label sits on the session (bind) button of the row. // The number label sits on the session (bind) button of the row.
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key }); await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key, messageId: replyTo });
return; return;
} }
if (menu.kind === 'workspaces') { if (menu.kind === 'workspaces') {
const workspace = menu.paths[number - 1]; const workspace = menu.paths[number - 1];
if (!workspace) { if (!workspace) {
await this.#send(chatId, t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length })); await reply(t('只有 {count} 个工作区,回复 /workspacelist 重新查看。', { count: menu.paths.length }));
return; return;
} }
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key }); await this.#handleCardAction(`workspace:${workspace}`, { chatId, key, messageId: replyTo });
return; return;
} }
if (menu.kind === 'watches') { if (menu.kind === 'watches') {
const entry = menu.entries[number - 1]; const entry = menu.entries[number - 1];
if (!entry?.sessionId) { if (!entry?.sessionId) {
await this.#send(chatId, t('关注列表只有 {count} 个会话。', { count: menu.entries.length })); await reply(t('关注列表只有 {count} 个会话。', { count: menu.entries.length }));
return; return;
} }
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key }); await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key, messageId: replyTo });
} }
} }
@ -2075,7 +2082,7 @@ export class FeishuHarnessBridge {
}, },
); );
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'session list' }); await this.#sendFailure(chatId, error, { logLabel: 'session list', replyTo });
} }
} }
@ -2092,35 +2099,37 @@ export class FeishuHarnessBridge {
{ key, updateMessageId, replyTo }, { key, updateMessageId, replyTo },
); );
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' }); await this.#sendFailure(chatId, error, { logLabel: 'workspace list', replyTo });
} }
} }
async #bindSession(key, chatId, sessionId, { updateMessageId = null } = {}) { async #bindSession(key, chatId, sessionId, { updateMessageId = null, replyTo = null } = {}) {
try { try {
const bound = await this.#harness.bindWorkspaceSession(key, sessionId); const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题'); const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || t('暂无标题');
await this.#send(chatId, [ await this.#send(chatId, [
t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }), t('已绑定会话「{title}」\nID:{id}', { title, id: bound?.sessionId ?? sessionId }),
t('发送 /history 查看最近对话。'), t('发送 /history 查看最近对话。'),
].join('\n')); ].join('\n'), { replyTo });
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { await this.#sendFailure(chatId, error, {
logLabel: 'session binding', logLabel: 'session binding',
replyTo,
userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }), userMessage: t('绑定失败:{message}', { message: safeErrorText(error) }),
}); });
} }
} }
async #switchWorkspace(key, chatId, workspace, { updateMessageId = null } = {}) { async #switchWorkspace(key, chatId, workspace, { updateMessageId = null, replyTo = null } = {}) {
try { try {
const current = await this.#harness.switchWorkspace(workspace); const current = await this.#harness.switchWorkspace(workspace);
await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current })); await this.#send(chatId, t('工作区已切换为:{workspace}', { workspace: current }), { replyTo });
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { await this.#sendFailure(chatId, error, {
logLabel: 'workspace switch', logLabel: 'workspace switch',
replyTo,
userMessage: t('切换失败:{message}', { message: safeErrorText(error) }), userMessage: t('切换失败:{message}', { message: safeErrorText(error) }),
}); });
} }
@ -2344,7 +2353,7 @@ export class FeishuHarnessBridge {
catalog._currentId = settings.agentPreset; catalog._currentId = settings.agentPreset;
await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId }); await this.#sendCard(chatId, presetCard(catalog), { key, updateMessageId });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset card' }); await this.#sendFailure(chatId, error, { logLabel: 'preset card', replyTo: updateMessageId });
} }
} }
@ -2369,7 +2378,7 @@ export class FeishuHarnessBridge {
} }
await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId }); await this.#sendCard(chatId, modelCard(catalog), { key, updateMessageId });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'model card' }); await this.#sendFailure(chatId, error, { logLabel: 'model card', replyTo: updateMessageId });
} }
} }
@ -2397,7 +2406,7 @@ export class FeishuHarnessBridge {
} }
await this.#send(chatId, lines.join('\n'), { replyTo }); await this.#send(chatId, lines.join('\n'), { replyTo });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'status text' }); await this.#sendFailure(chatId, error, { logLabel: 'status text', replyTo });
} }
} }
@ -2449,7 +2458,7 @@ export class FeishuHarnessBridge {
await this.#sendCard(chatId, statusCard(info), { key, updateMessageId }); await this.#sendCard(chatId, statusCard(info), { key, updateMessageId });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'status card' }); await this.#sendFailure(chatId, error, { logLabel: 'status card', replyTo: updateMessageId });
} }
} }
@ -2467,21 +2476,21 @@ export class FeishuHarnessBridge {
/** /**
* Run the /compact command and show the result. * Run the /compact command and show the result.
*/ */
async #handleCompact(key, chatId) { async #handleCompact(key, chatId, replyTo = null) {
try { try {
const result = await runCompactCommand( const result = await runCompactCommand(
'/compact', this.#harness, this.#state, key, { signal: this.#signal }, '/compact', this.#harness, this.#state, key, { signal: this.#signal },
); );
await this.#send(chatId, result?.message || t('上下文压缩失败。')); await this.#send(chatId, result?.message || t('上下文压缩失败。'), { replyTo });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'compact' }); await this.#sendFailure(chatId, error, { logLabel: 'compact', replyTo });
} }
} }
/** /**
* Stop the running task in the bound session (mirrors `/stop`). * Stop the running task in the bound session (mirrors `/stop`).
*/ */
async #handleStop(key, chatId) { async #handleStop(key, chatId, replyTo = null) {
try { try {
const result = await runControlCommand( const result = await runControlCommand(
'/stop', this.#harness, this.#state, key, { '/stop', this.#harness, this.#state, key, {
@ -2495,9 +2504,9 @@ export class FeishuHarnessBridge {
this.#approvals.closeRoute(key), this.#approvals.closeRoute(key),
]); ]);
} }
await this.#send(chatId, result?.message || t('/stop 执行完成。')); await this.#send(chatId, result?.message || t('/stop 执行完成。'), { replyTo });
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'stop' }); await this.#sendFailure(chatId, error, { logLabel: 'stop', replyTo });
} }
} }
@ -2522,26 +2531,26 @@ export class FeishuHarnessBridge {
control: { owner: this, key }, control: { owner: this, key },
}, },
); );
await this.#send(chatId, result?.message || t('已提交补充指令。')); await this.#send(chatId, result?.message || t('已提交补充指令。'), { replyTo: entry.messageId ?? null });
} }
/** /**
* Reset the preset to follow the Host default. * Reset the preset to follow the Host default.
*/ */
async #handlePresetDefault(key, chatId, { updateMessageId = null } = {}) { async #handlePresetDefault(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
try { try {
const result = await runPresetCommand( const result = await runPresetCommand(
'/preset --default', this.#harness, this.#state, key, { signal: this.#signal }, '/preset --default', this.#harness, this.#state, key, { signal: this.#signal },
); );
for (const reply of result?.messages ?? [result?.message]) { for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply); if (reply) await this.#send(chatId, reply, { replyTo });
} }
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset reset' }); await this.#sendFailure(chatId, error, { logLabel: 'preset reset', replyTo: updateMessageId });
return; return;
} }
try { try {
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message); this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset reset:', error.message);
} }
@ -2550,21 +2559,21 @@ export class FeishuHarnessBridge {
/** /**
* Handle preset selection from the preset dropdown. * Handle preset selection from the preset dropdown.
*/ */
async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null } = {}) { async #handlePresetSelect(key, chatId, presetId, { updateMessageId = null, replyTo = null } = {}) {
try { try {
const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId; const selector = /^\d+$/u.test(presetId) ? `id:${presetId}` : presetId;
const result = await runPresetCommand( const result = await runPresetCommand(
`/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal }, `/preset ${selector}`, this.#harness, this.#state, key, { signal: this.#signal },
); );
for (const reply of result?.messages ?? [result?.message]) { for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply); if (reply) await this.#send(chatId, reply, { replyTo });
} }
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'preset selection' }); await this.#sendFailure(chatId, error, { logLabel: 'preset selection', replyTo: updateMessageId });
return; return;
} }
try { try {
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message); this.#logger.warn?.('[dsh-feishu] menu refresh failed after preset select:', error.message);
} }
@ -2579,7 +2588,7 @@ export class FeishuHarnessBridge {
* catalog validation, busy checks, pending-interaction checks and the * catalog validation, busy checks, pending-interaction checks and the
* session binding lock stay in one place. * session binding lock stay in one place.
*/ */
async #handleModelSelect(key, chatId, modelId, { updateMessageId = null } = {}) { async #handleModelSelect(key, chatId, modelId, { updateMessageId = null, replyTo = null } = {}) {
try { try {
const result = await runModelCommand( const result = await runModelCommand(
`/model ${modelId}`, this.#harness, this.#state, key, { `/model ${modelId}`, this.#harness, this.#state, key, {
@ -2589,14 +2598,14 @@ export class FeishuHarnessBridge {
}, },
); );
for (const reply of result?.messages ?? [result?.message]) { for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(chatId, reply); if (reply) await this.#send(chatId, reply, { replyTo });
} }
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'model selection' }); await this.#sendFailure(chatId, error, { logLabel: 'model selection', replyTo: updateMessageId });
return; return;
} }
try { try {
await this.#sendMenuCard(key, chatId, { updateMessageId }); await this.#sendMenuCard(key, chatId, { updateMessageId, replyTo });
} catch (error) { } catch (error) {
this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message); this.#logger.warn?.('[dsh-feishu] menu refresh failed after model select:', error.message);
} }
@ -2867,6 +2876,13 @@ export class FeishuHarnessBridge {
chatId, chatId,
lastSeq, lastSeq,
...(watchStartedAt !== null ? { watchStartedAt } : {}), ...(watchStartedAt !== null ? { watchStartedAt } : {}),
// Remember where the watch was created so completion pushes can be
// delivered as replies inside the same Feishu topic.
...(replyTo
? { replyToMessageId: replyTo }
: existingEntry?.replyToMessageId
? { replyToMessageId: existingEntry.replyToMessageId }
: {}),
}); });
} catch (error) { } catch (error) {
await reply(t('关注失败:{message}', { message: safeErrorText(error) })); await reply(t('关注失败:{message}', { message: safeErrorText(error) }));
@ -2992,7 +3008,7 @@ export class FeishuHarnessBridge {
await this.#sendCard( await this.#sendCard(
entry.chatId, entry.chatId,
completionCard(sessionId, entry.title, reason), completionCard(sessionId, entry.title, reason),
{ key }, { key, replyTo: entry.replyToMessageId ?? null },
); );
const current = this.#state.watchEntry?.(key, sessionId); const current = this.#state.watchEntry?.(key, sessionId);
if (!current if (!current

View file

@ -1412,6 +1412,124 @@ test('command replies in a topic group are threaded to the triggering message',
); );
}); });
test('bind confirmations from card actions are threaded to the card message', async (t) => {
const created = [];
const replied = [];
const seen = new Set();
const sessions = new Map([['group:oc_topic:thread:omt_cmd', 'session-cmd']]);
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: {
create: async (request) => {
created.push({
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: `om_created_${created.length}` } };
},
reply: async (request) => {
const sentMessageId = `om_replied_${replied.length + 1}`;
replied.push({
id: sentMessageId,
to: request.path.message_id,
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: sentMessageId } };
},
} } },
},
harness: {
ensureRunning: async () => true,
bindWorkspaceSession: async (key, sessionId) => ({ sessionId, title: 'Test Session' }),
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
},
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
await bridge.accept(event('om_cmd', '/new', {
chat_type: 'group',
chat_id: 'oc_topic',
thread_id: 'omt_cmd',
}));
await eventually(() => replied.length >= 1, 'the menu card was not created');
const menuCardMessageId = replied.filter((item) => item.type === 'interactive').at(-1).id;
await bridge.onCardAction(cardActionEvent(menuCardMessageId, 'use:session-1', 'ou_user'));
await eventually(
() => replied.some(({ to, text }) => to === menuCardMessageId && text?.includes('已绑定会话')),
'the bind confirmation must be threaded to the card message',
);
assert.equal(
created.some(({ text }) => text?.includes('已绑定会话')),
false,
'the bind confirmation must not be sent as a fresh chat message outside the topic',
);
});
test('watch completion pushes are threaded to the message that created the watch', async (t) => {
const { state } = await watchStoreFixture();
const harness = watchHarness({
sessionsByWorkspace: { 'C:/work': [{ sessionId: 'race-session', title: 'Race Session' }] },
});
const completionReplies = [];
const completionCreates = [];
const client = {
im: { v1: { message: {
create: async (request) => {
completionCreates.push(request.data.msg_type);
return { code: 0, data: { message_id: `om_created_${completionCreates.length}` } };
},
reply: async (request) => {
completionReplies.push({ to: request.path.message_id, type: request.data.msg_type });
return { code: 0, data: { message_id: `om_replied_${completionReplies.length}` } };
},
} } },
};
const bridge = new FeishuHarnessBridge({
client,
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await bridge.accept(event('watch-anchor', '/watch 1', { senderOpenId: 'ou_owner' }));
const entry = state.watchEntry('p2p:ou_owner', 'race-session');
assert.equal(entry?.replyToMessageId, 'watch-anchor', 'the watch entry must remember the anchor message');
harness._listeners[0].onSessionEvent({
sessionId: 'race-session',
event: { type: 'turn/end', seq: 10, time: entry.watchStartedAt, data: { reason: { kind: 'completed' } } },
});
await eventually(
() => completionReplies.some(({ type }) => type === 'interactive'),
'the completion card was not delivered',
);
assert.equal(
completionReplies.at(-1).to,
'watch-anchor',
'the completion push must be threaded to the /watch command message',
);
assert.equal(
completionCreates.length,
0,
'the completion push must not be sent as a fresh message outside the topic',
);
});
test('pending Harness questions are isolated by Feishu conversation', async () => { test('pending Harness questions are isolated by Feishu conversation', async () => {
const fixture = stateFixture([ const fixture = stateFixture([
['p2p:ou_a', 'session-a'], ['p2p:ou_a', 'session-a'],