Merge pull request #101 from evanfang0054/feat/feishu-topic-session-isolation

feat(feishu): isolate Harness sessions per topic in topic groups
This commit is contained in:
xiemanR 2026-08-31 20:08:45 +08:00 • committed by GitHub
commit 7a16718922
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 605 additions and 257 deletions

File diff suppressed because one or more lines are too long

View file

@ -686,7 +686,7 @@ export class FeishuHarnessBridge {
? pending.queue ? pending.queue
: null, : null,
isQuestionPending: () => this.#pendingInteractions.has(key), isQuestionPending: () => this.#pendingInteractions.has(key),
send: (text) => this.#send(event.message.chat_id, text), send: (text) => this.#send(event.message.chat_id, text, { replyTo: event.message.message_id }),
}); });
if (approvalReply) { if (approvalReply) {
const processing = approvalReply.process(async () => { const processing = approvalReply.process(async () => {
@ -851,7 +851,7 @@ export class FeishuHarnessBridge {
if (error?.code === 'turn-stopped') { if (error?.code === 'turn-stopped') {
await this.#removeProcessingReaction(messageId, processingReaction); await this.#removeProcessingReaction(messageId, processingReaction);
if (error?.batchInputMessage) { if (error?.batchInputMessage) {
await this.#send(event.message.chat_id, error.batchInputMessage).catch(() => undefined); await this.#send(event.message.chat_id, error.batchInputMessage, { replyTo: event.message.message_id }).catch(() => undefined);
} }
return; return;
} }
@ -940,7 +940,7 @@ export class FeishuHarnessBridge {
]); ]);
} }
for (const reply of result?.messages ?? [result?.message]) { for (const reply of result?.messages ?? [result?.message]) {
if (reply) await this.#send(event.message.chat_id, reply); if (reply) await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
} }
this.#status.lastError = null; this.#status.lastError = null;
} }
@ -964,7 +964,7 @@ export class FeishuHarnessBridge {
// accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。 // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
const commandText = !hasImages && !hasFiles && text ? text.trim() : null; const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
if (!text && !hasImages && !hasFiles) { if (!text && !hasImages && !hasFiles) {
await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。')); await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
return; return;
} }
@ -973,11 +973,11 @@ export class FeishuHarnessBridge {
return; return;
} }
if (commandText === '/help') { if (commandText === '/help') {
await this.#send(event.message.chat_id, menuHelpText()); await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
return; return;
} }
if (MENU_COMMAND.test(commandText)) { if (MENU_COMMAND.test(commandText)) {
await this.#sendMenuCard(key, event.message.chat_id); await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
return; return;
} }
if (commandText === '/new') { if (commandText === '/new') {
@ -985,53 +985,54 @@ export class FeishuHarnessBridge {
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'), t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。'),
{ replyTo: event.message.message_id },
); );
return; return;
} }
await this.#state.clearSession(key); await this.#state.clearSession(key);
await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。')); await this.#send(event.message.chat_id, t('已开启全新 Harness 会话。'), { replyTo: event.message.message_id });
await this.#sendMenuCard(key, event.message.chat_id); await this.#sendMenuCard(key, event.message.chat_id, { replyTo: event.message.message_id });
return; return;
} }
if (commandText === '/status') { if (commandText === '/status') {
await this.#showStatusText(key, event.message.chat_id); await this.#showStatusText(key, event.message.chat_id, event.message.message_id);
return; return;
} }
if (commandText === '/compact') { if (commandText === '/compact') {
const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal }); const compactCommand = await runCompactCommand(commandText, this.#harness, this.#state, key, { signal: this.#signal });
if (compactCommand) { if (compactCommand) {
await this.#send(event.message.chat_id, compactCommand.message); await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
} }
return; return;
} }
if (SESSION_LIST_PREFIX.test(commandText)) { if (SESSION_LIST_PREFIX.test(commandText)) {
const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null; const selector = commandText.replace(/^\/(?:sessionlist|sessions)/i, '').trim() || null;
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0); await this.#showSessions({ chatId: event.message.chat_id, key, replyTo: event.message.message_id }, selector, 0);
return; return;
} }
if (WORKSPACE_LIST_COMMAND.test(commandText)) { if (WORKSPACE_LIST_COMMAND.test(commandText)) {
await this.#showWorkspaces({ chatId: event.message.chat_id, key }); await this.#showWorkspaces({ chatId: event.message.chat_id, key, replyTo: event.message.message_id });
return; return;
} }
if (WATCH_COMMAND.test(commandText)) { if (WATCH_COMMAND.test(commandText)) {
const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null; const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
await this.#runWatch(key, event.message.chat_id, target); await this.#runWatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
return; return;
} }
if (UNWATCH_COMMAND.test(commandText)) { if (UNWATCH_COMMAND.test(commandText)) {
const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null; const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
await this.#runUnwatch(key, event.message.chat_id, target); await this.#runUnwatch(key, event.message.chat_id, target, { replyTo: event.message.message_id });
return; return;
} }
if (WATCHLIST_COMMAND.test(commandText)) { if (WATCHLIST_COMMAND.test(commandText)) {
await this.#showWatchList(key, event.message.chat_id); await this.#showWatchList(key, event.message.chat_id, { replyTo: event.message.message_id });
return; return;
} }
if (ARCHIVED_COMMAND.test(commandText)) { if (ARCHIVED_COMMAND.test(commandText)) {
const match = ARCHIVED_COMMAND.exec(commandText); const match = ARCHIVED_COMMAND.exec(commandText);
const value = match[1]?.toLowerCase(); const value = match[1]?.toLowerCase();
if (value !== 'on' && value !== 'off') { if (value !== 'on' && value !== 'off') {
await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)')); await this.#send(event.message.chat_id, t('用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)'), { replyTo: event.message.message_id });
return; return;
} }
if (typeof this.#state?.setIncludeArchivedSessions === 'function') { if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
@ -1040,6 +1041,7 @@ export class FeishuHarnessBridge {
await this.#send( await this.#send(
event.message.chat_id, event.message.chat_id,
value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'), value === 'on' ? t('已开启:会话列表包含归档会话。') : t('已关闭:会话列表隐藏归档会话。'),
{ replyTo: event.message.message_id },
); );
return; return;
} }
@ -1059,7 +1061,7 @@ export class FeishuHarnessBridge {
: await runWorkspaceCommand(text, this.#harness, key); : await runWorkspaceCommand(text, this.#harness, key);
if (workspaceCommand) { if (workspaceCommand) {
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) { for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
await this.#send(event.message.chat_id, reply); await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
} }
return; return;
} }
@ -1073,7 +1075,7 @@ export class FeishuHarnessBridge {
{ signal: this.#signal }, { signal: this.#signal },
); );
if (compactCommand) { if (compactCommand) {
await this.#send(event.message.chat_id, compactCommand.message); await this.#send(event.message.chat_id, compactCommand.message, { replyTo: event.message.message_id });
return; return;
} }
@ -2030,7 +2032,7 @@ export class FeishuHarnessBridge {
} }
async #showSessions( async #showSessions(
{ chatId, key }, { chatId, key, replyTo = null },
selector, selector,
page = 0, page = 0,
{ updateMessageId = null } = {}, { updateMessageId = null } = {},
@ -2039,14 +2041,14 @@ export class FeishuHarnessBridge {
const signal = this.#cardDataSignal(); const signal = this.#cardDataSignal();
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal }); const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness, { signal });
if (resolved.error) { if (resolved.error) {
await this.#send(chatId, resolved.error); await this.#send(chatId, resolved.error, { replyTo });
return; return;
} }
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal }); const listed = await this.#harness.listWorkspaceSessions(resolved.workspace, { signal });
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []); const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
const workspace = listed?.workspace ?? resolved.workspace; const workspace = listed?.workspace ?? resolved.workspace;
if (sessions.length === 0) { if (sessions.length === 0) {
await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace })); await this.#send(chatId, t('工作区:{workspace}\n该工作区暂无会话。', { workspace }), { replyTo });
return; return;
} }
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE); const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
@ -2065,6 +2067,7 @@ export class FeishuHarnessBridge {
{ {
key, key,
updateMessageId, updateMessageId,
replyTo,
// Keep the canonical selector result for later page callbacks. The // Keep the canonical selector result for later page callbacks. The
// list response's workspace is display data and is not authoritative. // list response's workspace is display data and is not authoritative.
sessionWorkspace: resolved.workspace, sessionWorkspace: resolved.workspace,
@ -2076,7 +2079,7 @@ export class FeishuHarnessBridge {
} }
} }
async #showWorkspaces({ chatId, key }, { updateMessageId = null } = {}) { async #showWorkspaces({ chatId, key, replyTo = null }, { updateMessageId = null } = {}) {
try { try {
const { current, paths } = await workspacePathSnapshot( const { current, paths } = await workspacePathSnapshot(
this.#harness, this.#harness,
@ -2086,7 +2089,7 @@ export class FeishuHarnessBridge {
await this.#sendCard( await this.#sendCard(
chatId, chatId,
workspaceListCard(paths, current), workspaceListCard(paths, current),
{ key, updateMessageId }, { key, updateMessageId, replyTo },
); );
} catch (error) { } catch (error) {
await this.#sendFailure(chatId, error, { logLabel: 'workspace list' }); await this.#sendFailure(chatId, error, { logLabel: 'workspace list' });
@ -2144,6 +2147,7 @@ export class FeishuHarnessBridge {
async #sendCard(chatId, cardJson, options = {}) { async #sendCard(chatId, cardJson, options = {}) {
const updateMessageId = nonEmptyString(options.updateMessageId); const updateMessageId = nonEmptyString(options.updateMessageId);
const replyTo = nonEmptyString(options.replyTo);
if (updateMessageId) { if (updateMessageId) {
try { try {
@ -2161,6 +2165,28 @@ export class FeishuHarnessBridge {
} }
} }
// A brand-new card triggered by an inbound message is delivered as a
// threaded reply so it lands inside the same Feishu topic. Falls back to
// a plain chat message when the referenced message is gone.
const content = cardJson;
if (replyTo) {
try {
const response = await this.#client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: 'interactive', content },
});
if (response?.code && response.code !== 0) {
throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
}
const repliedMessageId = nonEmptyString(response?.data?.message_id);
if (repliedMessageId) {
this.#rememberCardRoute(repliedMessageId, chatId, options);
return repliedMessageId;
}
} catch (error) {
this.#logger.warn?.('[dsh-feishu] threaded card reply failed; sending a plain card:', error?.message ?? String(error));
}
}
const response = await this.#client.im.v1.message.create({ const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' }, params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson }, data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
@ -2173,7 +2199,7 @@ export class FeishuHarnessBridge {
return messageId; return messageId;
} }
async #sendMenuCard(key, chatId, { updateMessageId = null } = {}) { async #sendMenuCard(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
let currentSessionId = null; let currentSessionId = null;
let directSessionTitle = null; let directSessionTitle = null;
try { try {
@ -2268,7 +2294,7 @@ export class FeishuHarnessBridge {
currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null, currentSession: currentSessionId ? { id: currentSessionId, title: currentSessionTitle } : null,
sessions, archiveVisible, presetCatalog, modelCatalog, sessions, archiveVisible, presetCatalog, modelCatalog,
}), }),
{ key, updateMessageId }, { key, updateMessageId, replyTo },
); );
} }
@ -2350,7 +2376,7 @@ export class FeishuHarnessBridge {
/** /**
* Gather system status and show the status card. * Gather system status and show the status card.
*/ */
async #showStatusText(key, chatId) { async #showStatusText(key, chatId, replyTo = null) {
try { try {
await this.#harness.ensureRunning({ signal: this.#signal }); await this.#harness.ensureRunning({ signal: this.#signal });
const lines = [t('连接正常')]; const lines = [t('连接正常')];
@ -2369,7 +2395,7 @@ export class FeishuHarnessBridge {
: (settings.agentPreset || t('跟随默认')), : (settings.agentPreset || t('跟随默认')),
})); }));
} }
await this.#send(chatId, lines.join('\n')); 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' });
} }
@ -2787,11 +2813,12 @@ export class FeishuHarnessBridge {
notify = true, notify = true,
validatedTarget = null, validatedTarget = null,
workspaceHint = null, workspaceHint = null,
replyTo = null,
} = {}) { } = {}) {
const watchRequestedAt = Date.now(); const watchRequestedAt = Date.now();
const reply = async (message) => { const reply = async (message) => {
if (!notify) return; if (!notify) return;
await this.#send(chatId, message).catch((error) => { await this.#send(chatId, message, { replyTo }).catch((error) => {
this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message); this.#logger.warn?.('[dsh-feishu] watch notification failed:', error.message);
}); });
}; };
@ -2854,10 +2881,10 @@ export class FeishuHarnessBridge {
return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) }; return { ok: true, changed: !existingEntry, entry: this.#state.watchEntry?.(key, resolved.sessionId) };
} }
async #runUnwatch(key, chatId, target, { notify = true } = {}) { async #runUnwatch(key, chatId, target, { notify = true, replyTo = null } = {}) {
const reply = async (message) => { const reply = async (message) => {
if (!notify) return; if (!notify) return;
await this.#send(chatId, message).catch((error) => { await this.#send(chatId, message, { replyTo }).catch((error) => {
this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message); this.#logger.warn?.('[dsh-feishu] unwatch notification failed:', error.message);
}); });
}; };
@ -2883,7 +2910,7 @@ export class FeishuHarnessBridge {
return { ok: true, changed: true, entry }; return { ok: true, changed: true, entry };
} }
async #showWatchList(key, chatId, { updateMessageId = null } = {}) { async #showWatchList(key, chatId, { updateMessageId = null, replyTo = null } = {}) {
const entries = this.#state.watchEntries?.(key) ?? []; const entries = this.#state.watchEntries?.(key) ?? [];
// 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。 // 收集可选会话(用于「添加关注」多选下拉);失败则传空数组 → 只渲染移除/列表。
let availableSessions = []; let availableSessions = [];
@ -2913,6 +2940,7 @@ export class FeishuHarnessBridge {
{ {
key, key,
updateMessageId, updateMessageId,
replyTo,
sessionWorkspace: currentWorkspace, sessionWorkspace: currentWorkspace,
}, },
); );
@ -3068,17 +3096,18 @@ export class FeishuHarnessBridge {
actor: senderOpenId(event), actor: senderOpenId(event),
chatId: event.message.chat_id, chatId: event.message.chat_id,
requiresMention: event.message.chat_type !== 'p2p', requiresMention: event.message.chat_type !== 'p2p',
replyToMessageId: event.message.message_id,
}), }),
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution), onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
files, files,
}; };
} }
async #sendAnswerText(chatId, answer, { deliveryId, presentation }) { async #sendAnswerText(chatId, answer, { deliveryId, presentation, replyTo = null }) {
const providerMessageIds = []; const providerMessageIds = [];
for (const chunk of splitText(answer)) { for (const chunk of splitText(answer)) {
this.#signal?.throwIfAborted(); this.#signal?.throwIfAborted();
const messageId = await this.#send(chatId, chunk); const messageId = await this.#send(chatId, chunk, { replyTo });
if (messageId) providerMessageIds.push(messageId); if (messageId) providerMessageIds.push(messageId);
} }
return createDeliveryReceipt({ return createDeliveryReceipt({
@ -3182,6 +3211,7 @@ export class FeishuHarnessBridge {
{ {
deliveryId: messageId, deliveryId: messageId,
presentation: 'feishu-text', presentation: 'feishu-text',
replyTo: messageId,
}, },
); );
} catch (error) { } catch (error) {
@ -3252,6 +3282,7 @@ export class FeishuHarnessBridge {
{ {
deliveryId: messageId, deliveryId: messageId,
presentation: 'feishu-text-fallback', presentation: 'feishu-text-fallback',
replyTo: messageId,
}, },
); );
} catch (fallbackError) { } catch (fallbackError) {
@ -3302,6 +3333,7 @@ export class FeishuHarnessBridge {
{ {
deliveryId: messageId, deliveryId: messageId,
presentation: 'feishu-text-fallback', presentation: 'feishu-text-fallback',
replyTo: messageId,
}, },
); );
} catch (fallbackError) { } catch (fallbackError) {
@ -3368,11 +3400,11 @@ export class FeishuHarnessBridge {
const pending = this.#pendingInteractions.get(key); const pending = this.#pendingInteractions.get(key);
if (!pending || pending !== expected || pending.submitting) { if (!pending || pending !== expected || pending.submitting) {
if (this.#isResolvedQuestionReply(event, key)) { if (this.#isResolvedQuestionReply(event, key)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
return; return;
} }
if (claimed && (!pending || pending !== expected)) { if (claimed && (!pending || pending !== expected)) {
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id });
return; return;
} }
return this.#enqueueMessage(event, messageId, key, processingReaction, { return this.#enqueueMessage(event, messageId, key, processingReaction, {
@ -3430,7 +3462,7 @@ export class FeishuHarnessBridge {
if (error?.code === 'interaction-not-pending') { if (error?.code === 'interaction-not-pending') {
this.#rememberResolvedInteraction(key, pending); this.#rememberResolvedInteraction(key, pending);
this.#clearPendingInteraction(key, pending.interactionId); this.#clearPendingInteraction(key, pending.interactionId);
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
return; return;
} }
pending.submitting = false; pending.submitting = false;
@ -3448,12 +3480,13 @@ export class FeishuHarnessBridge {
actor, actor,
chatId, chatId,
requiresMention, requiresMention,
replyToMessageId,
}) { }) {
if (await this.#approvals.handleRequested(interaction, { if (await this.#approvals.handleRequested(interaction, {
key, key,
actor, actor,
requiresMention, requiresMention,
send: (text) => this.#send(chatId, text), send: (text) => this.#send(chatId, text, { replyTo: replyToMessageId }),
})) return; })) return;
// Approval requests return above; the existing question state machine stays unchanged. // Approval requests return above; the existing question state machine stays unchanged.
@ -3484,6 +3517,7 @@ export class FeishuHarnessBridge {
await this.#send( await this.#send(
chatId, chatId,
t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'), t('检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。'),
{ replyTo: replyToMessageId },
).catch(() => undefined); ).catch(() => undefined);
return; return;
} }
@ -3519,6 +3553,7 @@ export class FeishuHarnessBridge {
answers: [], answers: [],
index: 0, index: 0,
chatId, chatId,
replyToMessageId,
queue: null, queue: null,
claimedReplyMessageId: null, claimedReplyMessageId: null,
submitting: false, submitting: false,
@ -3553,6 +3588,9 @@ export class FeishuHarnessBridge {
pending.questions.length, pending.questions.length,
{ requiresMention: pending.requiresMention }, { requiresMention: pending.requiresMention },
), ),
// Reply to the message that started the turn so the question lands in
// the same Feishu thread/topic instead of the group's default area.
{ replyTo: pending.replyToMessageId },
); );
if (messageId) { if (messageId) {
pending.questionMessageIds.add(messageId); pending.questionMessageIds.add(messageId);
@ -3585,7 +3623,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;
await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT()).catch(() => undefined); await this.#send(event.message.chat_id, INTERACTION_RESOLVED_TEXT(), { replyTo: event.message.message_id }).catch(() => undefined);
} }
#takePendingInteraction(key, interactionId) { #takePendingInteraction(key, interactionId) {
@ -3651,13 +3689,33 @@ export class FeishuHarnessBridge {
else processingReaction.success(); else processingReaction.success();
} }
async #send(chatId, text) { async #send(chatId, text, { replyTo } = {}) {
const content = JSON.stringify({ text });
if (replyTo) {
try {
const response = await this.#client.im.v1.message.reply({
path: { message_id: replyTo },
data: { msg_type: 'text', content },
});
if (response?.code && response.code !== 0) {
throw new Error(`Feishu reply failed: ${response.msg || response.code}`);
}
return nonEmptyString(response?.data?.message_id);
} catch (error) {
// The referenced message may be gone (recalled/deleted); keep the
// delivery promise by falling back to a plain chat message.
this.#logger.warn?.(
'[dsh-feishu] threaded reply failed; falling back to a plain message:',
error?.message ?? String(error),
);
}
}
const response = await this.#client.im.v1.message.create({ const response = await this.#client.im.v1.message.create({
params: { receive_id_type: 'chat_id' }, params: { receive_id_type: 'chat_id' },
data: { data: {
receive_id: chatId, receive_id: chatId,
msg_type: 'text', msg_type: 'text',
content: JSON.stringify({ text }), content,
}, },
}); });
if (response?.code && response.code !== 0) { if (response?.code && response.code !== 0) {

View file

@ -16,6 +16,11 @@ export function conversationKey(event) {
} }
const chatId = event?.message?.chat_id; const chatId = event?.message?.chat_id;
if (!chatId) throw new Error('Feishu group event has no chat id'); if (!chatId) throw new Error('Feishu group event has no chat id');
// Topic groups: every message belongs to a thread, so key the session per
// thread to keep each topic's Harness conversation isolated. Regular group
// chats carry no thread_id and keep the single shared `group:<chat_id>` key.
const threadId = event?.message?.thread_id;
if (typeof threadId === 'string' && threadId.trim()) return `group:${chatId}:thread:${threadId}`;
return `group:${chatId}`; return `group:${chatId}`;
} }

View file

@ -1238,6 +1238,180 @@ test('a threaded Feishu reply answers a pending Harness question before the orig
assert.equal(status.messagesReplied, 1); assert.equal(status.messagesReplied, 1);
}); });
test('a Harness question is presented as a threaded reply inside a topic group', async () => {
const sent = [];
const replied = [];
const streamed = [];
const seen = new Set();
const sessions = new Map();
const status = {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: {
create: async (request) => {
sent.push({ text: JSON.parse(request.data.content).text });
return { code: 0, data: { message_id: `om_sent_${sent.length}` } };
},
reply: async (request) => {
replied.push({
to: request.path.message_id,
text: JSON.parse(request.data.content).text,
});
return { code: 0, data: { message_id: `om_replied_${replied.length}` } };
},
} } },
},
channel: {
stream: async (_chatId, input) => {
await input.markdown({
setContent: async (content) => streamed.push(content),
});
return { messageId: 'om_stream' };
},
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => false,
createSession: async () => 'session-topic',
ask: async (_sessionId, _text, options) => {
await options.onUpdate({ type: 'tool', name: 'ask_user_question' });
await options.onInteraction({
kind: 'question',
interactionId: 'question-rpc',
rpcId: 'question-rpc',
sessionId: 'session-topic',
payload: {
type: 'question/requested',
sessionId: 'session-topic',
questions: [{
id: 'environment',
header: '测试环境',
question: '请选择测试环境',
options: [{ label: '测试环境' }, { label: '生产环境' }],
}],
},
respond: async () => ({ accepted: true }),
});
return '已完成';
},
},
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,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_prompt', '请先调用 ask_user_question', {
chat_type: 'group',
chat_id: 'oc_topic_group',
thread_id: 'omt_prompt',
}));
await bridge.waitForIdle();
assert.deepEqual(
replied,
[{ to: 'om_prompt', text: replied[0]?.text }],
'the question must be delivered through the reply API targeting the triggering message',
);
assert.ok(replied[0]?.text.includes('请选择测试环境'));
assert.equal(
sent.some(({ text }) => text.includes('请选择测试环境')),
false,
'the question must not be sent as a plain chat message that lands outside the topic',
);
assert.equal(streamed.at(-1), '已完成');
});
test('command replies in a topic group are threaded to the triggering message', async () => {
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) => {
replied.push({
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: `om_replied_${replied.length}` } };
},
} } },
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
},
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: {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
},
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_cmd', '/new', {
chat_type: 'group',
chat_id: 'oc_topic',
thread_id: 'omt_cmd',
}));
await eventually(
() => replied.length >= 2,
'the /new replies were not presented as threaded replies',
);
assert.ok(
replied.some(({ to, text }) => to === 'om_cmd' && text?.includes('已开启全新')),
'the /new confirmation must be delivered through the reply API targeting the command message',
);
assert.ok(
replied.some(({ to, type }) => to === 'om_cmd' && type === 'interactive'),
'the menu card must also be threaded to the command message',
);
assert.equal(
created.some(({ text }) => text?.includes('已开启全新')),
false,
'the /new confirmation must not be sent as a plain chat 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'],

View file

@ -232,6 +232,38 @@ test('conversationKey isolates p2p users and groups', () => {
}), 'group:oc_group'); }), 'group:oc_group');
}); });
test('conversationKey isolates topic-group threads without affecting regular groups', () => {
// Topic groups: each message belongs to a thread, so every topic gets its own session.
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_topic_group', thread_id: 'om_thread_a' },
}), 'group:oc_topic_group:thread:om_thread_a');
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_other' } },
message: { chat_type: 'group', chat_id: 'oc_topic_group', thread_id: 'om_thread_b' },
}), 'group:oc_topic_group:thread:om_thread_b');
assert.notEqual(
conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_topic_group', thread_id: 'om_thread_a' },
}),
conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_topic_group', thread_id: 'om_thread_b' },
}),
);
// Regular group chats: no thread_id keeps the single shared group key.
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_group' },
}), 'group:oc_group');
// Blank thread_id values fall back to the shared group key.
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_group', thread_id: ' ' },
}), 'group:oc_group');
});
test('splitText preserves all text', () => { test('splitText preserves all text', () => {
const input = `${'a'.repeat(12)}\n${'b'.repeat(12)}`; const input = `${'a'.repeat(12)}\n${'b'.repeat(12)}`;
const chunks = splitText(input, 15); const chunks = splitText(input, 15);