diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 535ebd06..669da19c 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -145,6 +145,29 @@ async function handleIncomingMessage(event) { const identity = incomingIdentity(event); if (!identity.chatId) return; + // Store the incoming message ID so sendText() can reply inside the same + // Feishu thread/topic — without this, every bot message creates a new + // standalone topic in thread-enabled groups. + // / 缓存入站消息 ID,让 sendText 能通过 reply API 在同一话题内回复。 + // / 否则每条 bot 消息都会在话题群中创建独立的新话题(见 #1710)。 + if (identity.messageId) { + const existing = await threadStore.getChat(identity.chatId); + if (existing) { + await threadStore.patchChat(identity.chatId, { + replyToMessageId: identity.messageId, + updatedAt: new Date().toISOString() + }); + } else { + await threadStore.setChat(identity.chatId, { + replyToMessageId: identity.messageId, + threadId: null, + lastSeq: 0, + activeTurnId: null, + updatedAt: new Date().toISOString() + }); + } + } + if (identity.messageType && identity.messageType !== "text") { await sendText(identity.chatId, "Only text messages are supported in this first bridge."); return; @@ -531,21 +554,40 @@ async function decideApproval(chatId, action) { } async function sendText(chatId, text) { + // Try reply API first — keeps bot responses inside the same Feishu + // thread/topic instead of spawning new standalone topics. + // / 优先使用 reply API,确保 bot 回复留在话题群的同一条话题内。 + const state = await threadStore.getChat(chatId); + const replyToMessageId = state?.replyToMessageId || null; + + const replyMessage = + replyToMessageId + ? client.im?.v1?.message?.reply?.bind(client.im.v1.message) || + client.im?.message?.reply?.bind(client.im.message) + : null; const createMessage = client.im?.v1?.message?.create?.bind(client.im.v1.message) || client.im?.message?.create?.bind(client.im.message); if (!createMessage) { throw new Error("Lark SDK client does not expose im message create API"); } + for (const chunk of splitMessage(text, config.maxReplyChars)) { - await createMessage({ - params: { receive_id_type: "chat_id" }, - data: { - receive_id: chatId, - msg_type: "text", - content: JSON.stringify({ text: chunk }) - } - }); + const body = { + msg_type: "text", + content: JSON.stringify({ text: chunk }) + }; + if (replyMessage) { + await replyMessage({ + path: { message_id: replyToMessageId }, + data: body + }); + } else { + await createMessage({ + params: { receive_id_type: "chat_id" }, + data: { ...body, receive_id: chatId } + }); + } } } diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index a16daf93..b6dae5f2 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -67,7 +67,13 @@ export function incomingIdentity(event) { messageType: message.message_type || "", openId: sender.open_id || "", unionId: sender.union_id || "", - userId: sender.user_id || "" + userId: sender.user_id || "", + // Thread/topic group context: these fields let the bridge reply + // inside the same topic instead of spawning a new standalone topic. + // / 话题群上下文:用于在同一话题内回复,而非新建独立话题。 + parentId: message.parent_id || "", + rootId: message.root_id || "", + threadId: message.thread_id || "" }; }