Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 81 additions & 10 deletions integrations/feishu-bridge/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});
}
}
Comment on lines +153 to +169
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

We can simplify the logic for storing the incoming message ID by using patchChat directly. Since patchChat handles merging with the existing state (or initializing an empty object if none exists), we don't need to check if the chat exists or duplicate the updatedAt timestamp and other fields.

  if (identity.messageId) {
    await threadStore.patchChat(identity.chatId, {
      replyToMessageId: identity.messageId,
      updatedAt: new Date().toISOString()
    });
  }


if (identity.messageType && identity.messageType !== "text") {
await sendText(identity.chatId, "Only text messages are supported in this first bridge.");
return;
Expand Down Expand Up @@ -208,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;
Expand All @@ -220,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;
Comment on lines +249 to +251
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

State Overwrite Bug in ensureThread

While effectiveModel correctly retrieves the per-chat model from existing?.model, the subsequent call to threadStore.setChat(chatId, state) at line 274 (which is outside this diff hunk) completely overwrites the chat state with only threadId, lastSeq, activeTurnId, and updatedAt.

This causes two major issues:

  1. It wipes out the model property from the store as soon as a new thread is created, causing subsequent turns to fall back to the global default model.
  2. It also wipes out replyToMessageId, breaking the threading/reply functionality.

Recommended Fix:
Update ensureThread (line 274) and resumeThread (line 508) to use patchChat instead of setChat to preserve existing properties like model and replyToMessageId:

const state = await threadStore.patchChat(chatId, {
  threadId: thread.id,
  lastSeq: 0,
  activeTurnId: null,
  updatedAt: new Date().toISOString()
});
return state;


const thread = await runtimeJson("/v1/threads", {
method: "POST",
body: {
model: config.model,
model: effectiveModel,
workspace: config.workspace,
mode: config.mode,
allow_shell: config.allowShell,
Expand Down Expand Up @@ -251,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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve per-chat model when computing turn model

runPrompt() derives effectiveModel from state?.model, but when a chat has no thread yet ensureThread() returns a freshly built state object without model and also overwrites stored chat data via setChat(). In that path, /model <name> set just before the first prompt (or before /new) is ignored for the turn submission (model falls back to config.model) and the override is dropped for later turns, so the new feature does not reliably work.

Useful? React with 👍 / 👎.

const detail = await runtimeJson(`/v1/threads/${encodeURIComponent(state.threadId)}`);
const activeBlock = activeTurnBlock(detail, state);
if (activeBlock) {
Expand All @@ -273,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,
Expand Down Expand Up @@ -530,22 +564,59 @@ async function decideApproval(chatId, action) {
await sendText(chatId, `Approval ${approvalId}: ${decision}${remember ? " and remember" : ""}`);
}

async function setChatModel(chatId, modelName) {
// /model <name> — 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.
// / 优先使用 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
});
Comment on lines +610 to +613
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fall back to create API when reply send fails

After this change, sendText() always uses message.reply whenever replyToMessageId is present, but any reply-specific API error now aborts delivery because there is no fallback to message.create. This can drop bot responses in chats/messages where reply semantics are unsupported even though normal send is still possible; preserving a fallback keeps behavior compatible with the previous implementation.

Useful? React with 👍 / 👎.

} else {
await createMessage({
params: { receive_id_type: "chat_id" },
data: { ...body, receive_id: chatId }
});
}
Comment on lines +609 to +619
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If replyMessage fails (e.g., due to an expired/deleted message ID or permission changes), the entire sendText call will throw an error and fail to deliver the bot's response. Adding a try-catch block to fall back to createMessage ensures that the message is still delivered to the chat even if threading fails.

    let sent = false;
    if (replyMessage) {
      try {
        await replyMessage({ 
          path: { message_id: replyToMessageId },
          data: body
        });
        sent = true;
      } catch (error) {
        console.error(`Failed to reply to message ${replyToMessageId}, falling back to direct message:`, error);
      }
    }
    if (!sent) {
      await createMessage({
        params: { receive_id_type: "chat_id" },
        data: { ...body, receive_id: chatId }
      });
    }

}
}
Comment on lines 604 to 621
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No fallback when replyMessage API call throws

When replyMessage is resolved (i.e., the SDK exposes the reply method and replyToMessageId is set), any API-level error — expired message ID, revoked permission, deleted message — throws and propagates up with no fallback to createMessage. The previous code always used createMessage which can't fail for this reason. A try/catch around the replyMessage call that falls back to createMessage would preserve the original reliability.

Fix in Codex Fix in Claude Code Fix in Cursor


Expand Down
13 changes: 12 additions & 1 deletion integrations/feishu-bridge/src/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 || ""
};
}

Expand Down Expand Up @@ -141,6 +147,11 @@ export function commandAction(command) {
return { kind: "interrupt" };
case "compact":
return { kind: "compact" };
case "model":
// /model <model_name> — 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":
Expand Down