fix(feishu): reply inside thread/topic instead of creating standalone topics (#2148)
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 创建新话题。
This commit is contained in:
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || ""
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user