From c0b82b6ec0253d847ba02a5a529b13ba40666d5d Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Tue, 26 May 2026 09:45:46 +0800 Subject: [PATCH 1/2] fix(feishu): reply inside thread/topic instead of creating standalone topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running in a Feishu thread-enabled group (话题群), every bot response — status messages, approval prompts, streaming progress, turn results — was sent via the Lark SDK's `create` API which spawns a new standalone topic. The user sees a cluttered group with orphan topics for each intermediate bot message. Root cause: `sendText()` only called `client.im.message.create()` with a bare `chat_id`, never passing any reply context. The Feishu `reply` API was completely unused. Fix (two changes, one site each): 1. **lib.mjs — incomingIdentity()**: expose `parentId`, `rootId`, `threadId` from the raw Feishu message event so callers can determine thread context. (Not consumed directly yet, but available for future use.) 2. **index.mjs**: - `handleIncomingMessage()`: store the latest incoming `messageId` as `replyToMessageId` in the per-chat thread store. - `sendText()`: look up `replyToMessageId` from the thread store; when present, call `client.im.message.reply()` instead of `create()`. This keeps ALL bot responses nested under the original user message inside the same topic. No config changes needed. New chats automatically start using the reply path; existing chats without a `replyToMessageId` in the store fall back to the old `create` behaviour. / 修复飞书话题群中 bot 消息新建独立话题的问题。所有回复改为使用 reply API / 在原话题内嵌套回复,而非通过 create API 创建新话题。 --- integrations/feishu-bridge/src/index.mjs | 58 ++++++++++++++++++++---- integrations/feishu-bridge/src/lib.mjs | 8 +++- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 535ebd061..669da19c1 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 a16daf936..b6dae5f29 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 || "" }; } From 82499e1c20895eee6cb1f9527c9f3112ecafbf1d Mon Sep 17 00:00:00 2001 From: yuanchenglu Date: Tue, 26 May 2026 09:52:53 +0800 Subject: [PATCH 2/2] feat(feishu): add /model command for per-chat model switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge only supported a single global model (DEEPSEEK_MODEL env / default_text_model in config.toml). Users who wanted a different model for a particular Feishu group had to restart the bridge with a different env var — impractical and disruptive. This commit adds per-chat model switching so users in a group chat can run "/model " to switch the model for all future threads and turns in that chat, without affecting other chats. Changes: - **lib.mjs — commandAction()**: handle "model" command → { kind: "set_model", modelName }. - **index.mjs**: - setChatModel(chatId, modelName): store/clear per-chat model in the thread store. "/model default" resets to the bridge-level default. - ensureThread(): read per-chat model from store when creating a new runtime thread; fall back to config.model. - runPrompt(): read per-chat model for each turn submission (independent of the thread-creation model), so a /model change takes effect on the very next message. Usage: /model deepseek-v4-flash — switch this chat to Flash /model deepseek-v4-pro — switch this chat to Pro /model default — reset to bridge default Resolution priority (per-chat): global default < per-chat /model. / 新增 /model 命令,支持按飞书群设置独立模型。 / 群内输入 /model 切换,/model default 恢复全局默认。 --- integrations/feishu-bridge/src/index.mjs | 33 ++++++++++++++++++++++-- integrations/feishu-bridge/src/lib.mjs | 5 ++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/integrations/feishu-bridge/src/index.mjs b/integrations/feishu-bridge/src/index.mjs index 669da19c1..0a485b5c9 100644 --- a/integrations/feishu-bridge/src/index.mjs +++ b/integrations/feishu-bridge/src/index.mjs @@ -231,6 +231,9 @@ async function handleCommand(chatId, command) { case "approval": await decideApproval(chatId, action); return; + case "set_model": + await setChatModel(chatId, action.modelName); + return; case "prompt": await runPrompt(chatId, action.prompt); return; @@ -243,10 +246,14 @@ async function ensureThread(chatId, { forceNew = false } = {}) { const existing = await threadStore.getChat(chatId); if (existing?.threadId && !forceNew) return existing; + // Use per-chat model if set, fall back to bridge-level default. + // / 优先使用 per-chat 模型(/model 命令设置),否则用桥接级别的默认模型。 + const effectiveModel = existing?.model || config.model; + const thread = await runtimeJson("/v1/threads", { method: "POST", body: { - model: config.model, + model: effectiveModel, workspace: config.workspace, mode: config.mode, allow_shell: config.allowShell, @@ -274,6 +281,10 @@ async function runPrompt(chatId, prompt) { return; } const state = await ensureThread(chatId); + // Use per-chat model for this turn (may differ from the thread's + // creation model if the user ran /model after the thread was created). + // / 使用 per-chat 模型执行本轮对话(如果用户在创建线程后切换过模型)。 + const effectiveModel = state?.model || config.model; const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`); const activeBlock = activeTurnBlock(detail, state); if (activeBlock) { @@ -296,7 +307,7 @@ async function runPrompt(chatId, prompt) { body: { prompt, input_summary: prompt.slice(0, 200), - model: config.model, + model: effectiveModel, mode: config.mode, allow_shell: config.allowShell, trust_mode: config.trustMode, @@ -553,6 +564,24 @@ async function decideApproval(chatId, action) { await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`); } +async function setChatModel(chatId, modelName) { + // /model — set per-chat model; "default" or empty resets to bridge default. + // / /model "default" 或空参数 — 恢复桥接级别的默认模型。 + if (!modelName || modelName === "default") { + await threadStore.patchChat(chatId, { + model: null, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Reset per-chat model. Using bridge default: ${config.model}`); + return; + } + await threadStore.patchChat(chatId, { + model: modelName, + updatedAt: new Date().toISOString() + }); + await sendText(chatId, `Per-chat model set to: ${modelName}`); +} + async function sendText(chatId, text) { // Try reply API first — keeps bot responses inside the same Feishu // thread/topic instead of spawning new standalone topics. diff --git a/integrations/feishu-bridge/src/lib.mjs b/integrations/feishu-bridge/src/lib.mjs index b6dae5f29..2408fe811 100644 --- a/integrations/feishu-bridge/src/lib.mjs +++ b/integrations/feishu-bridge/src/lib.mjs @@ -147,6 +147,11 @@ export function commandAction(command) { return { kind: "interrupt" }; case "compact": return { kind: "compact" }; + case "model": + // /model — switch per-chat default model. + // Stored in thread store and used for future threads/turns. + // Pass "default" to reset to the bridge-level default. + return { kind: "set_model", modelName: command.args }; case "allow": return { kind: "approval", decision: "allow", ...parseApprovalDecisionArgs(command.args) }; case "deny":