Skip to content

Commit 1bd3555

Browse files
LiaoyuanNingclaude
authored andcommitted
fix(feishu): reply inside thread for P2P direct messages
When a user sends a message inside a Feishu thread (话题) in a P2P chat, the bot's reply was incorrectly sent outside the thread as a standalone message. This was caused by PR #33789 which fixed group reply targeting (#32980) but inadvertently broke P2P thread replies by gating all thread-aware logic behind isGroup checks. The fix distinguishes between two P2P scenarios based on the Feishu API contract (thread_id present = thread message, root_id only = quote reply): - P2P thread messages (thread_id present): reply to root_id with reply_in_thread=true so the response stays in the thread. - P2P plain replies (root_id only, no thread_id): reply to the triggering message as before. Group chat behavior is completely unchanged — the new condition (directThreadMessage) requires isDirect=true, which is always false for groups. Changes: - replyInThread: true for P2P thread messages (enables reply_in_thread API flag) - replyTargetMessageId: use root_id for P2P thread messages - threadReply: true for P2P thread messages (drives effectiveReplyInThread in dispatcher) - skipReplyToInMessages: false for P2P thread messages (so send layer uses im.message.reply instead of im.message.create) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 84f5d7d commit 1bd3555

2 files changed

Lines changed: 97 additions & 10 deletions

File tree

extensions/feishu/src/bot.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,6 +1706,79 @@ describe("handleFeishuMessage command authorization", () => {
17061706
);
17071707
});
17081708

1709+
it("replies to topic root in p2p thread message (thread_id present)", async () => {
1710+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1711+
1712+
const cfg: ClawdbotConfig = {
1713+
channels: {
1714+
feishu: {
1715+
dmPolicy: "open",
1716+
},
1717+
},
1718+
} as ClawdbotConfig;
1719+
1720+
const event: FeishuMessageEvent = {
1721+
sender: { sender_id: { open_id: "ou-dm-thread-user" } },
1722+
message: {
1723+
message_id: "om_dm_thread_reply",
1724+
root_id: "om_dm_thread_root",
1725+
thread_id: "omt_dm_thread",
1726+
chat_id: "oc-dm",
1727+
chat_type: "p2p",
1728+
message_type: "text",
1729+
content: JSON.stringify({ text: "hello in p2p thread" }),
1730+
},
1731+
};
1732+
1733+
await dispatchMessage({ cfg, event });
1734+
1735+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1736+
expect.objectContaining({
1737+
replyToMessageId: "om_dm_thread_root",
1738+
skipReplyToInMessages: false,
1739+
rootId: "om_dm_thread_root",
1740+
replyInThread: true,
1741+
threadReply: true,
1742+
}),
1743+
);
1744+
});
1745+
1746+
it("replies to triggering message in p2p plain reply (root_id only, no thread_id)", async () => {
1747+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1748+
1749+
const cfg: ClawdbotConfig = {
1750+
channels: {
1751+
feishu: {
1752+
dmPolicy: "open",
1753+
},
1754+
},
1755+
} as ClawdbotConfig;
1756+
1757+
const event: FeishuMessageEvent = {
1758+
sender: { sender_id: { open_id: "ou-dm-reply-user" } },
1759+
message: {
1760+
message_id: "om_dm_plain_reply",
1761+
root_id: "om_dm_original_msg",
1762+
chat_id: "oc-dm",
1763+
chat_type: "p2p",
1764+
message_type: "text",
1765+
content: JSON.stringify({ text: "just a quote reply in p2p" }),
1766+
},
1767+
};
1768+
1769+
await dispatchMessage({ cfg, event });
1770+
1771+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1772+
expect.objectContaining({
1773+
replyToMessageId: "om_dm_plain_reply",
1774+
skipReplyToInMessages: true,
1775+
rootId: "om_dm_original_msg",
1776+
replyInThread: false,
1777+
threadReply: false,
1778+
}),
1779+
);
1780+
});
1781+
17091782
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
17101783
mockShouldComputeCommandAuthorized.mockReturnValue(false);
17111784

extensions/feishu/src/bot.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,11 @@ export async function handleFeishuMessage(params: {
11561156
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
11571157
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
11581158
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1159-
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
1159+
// P2P thread messages (thread_id present) need reply_in_thread so the
1160+
// response stays inside the Feishu thread container, not just as an inline
1161+
// reply to the root message.
1162+
const directThreadMessage = isDirect && Boolean(ctx.threadId);
1163+
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadMessage;
11601164

11611165
if (isGroup && groupSession) {
11621166
log(
@@ -1338,11 +1342,19 @@ export async function handleFeishuMessage(params: {
13381342
const messageCreateTimeMs = event.message.create_time
13391343
? parseInt(event.message.create_time, 10)
13401344
: undefined;
1341-
// Determine reply target based on group session mode:
1342-
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
1343-
// root so the bot stays in the same thread.
1344-
// - Groups with explicit replyInThread config: reply to the root so the bot
1345-
// stays in the thread the user expects.
1345+
// Determine reply target:
1346+
//
1347+
// P2P (direct) chats:
1348+
// - Thread messages (thread_id present): reply to the topic root so
1349+
// the bot's reply stays inside the thread.
1350+
// - Plain reply (root_id only, no thread_id): reply to the triggering
1351+
// message; root_id is just a quote reference, not a thread container.
1352+
//
1353+
// Group chats:
1354+
// - Topic-mode groups (group_topic / group_topic_sender): reply to the
1355+
// topic root so the bot stays in the same thread.
1356+
// - Groups with explicit replyInThread config: reply to the root so the
1357+
// bot stays in the thread the user expects.
13461358
// - Normal groups (auto-detected threadReply from root_id): reply to the
13471359
// triggering message itself. Using rootId here would silently push the
13481360
// reply into a topic thread invisible in the main chat view (#32980).
@@ -1354,8 +1366,10 @@ export async function handleFeishuMessage(params: {
13541366
isGroup &&
13551367
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
13561368
const replyTargetMessageId =
1357-
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
1358-
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
1369+
directThreadMessage || isTopicSession || configReplyInThread
1370+
? (ctx.rootId ?? ctx.messageId)
1371+
: ctx.messageId;
1372+
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadMessage;
13591373

13601374
if (broadcastAgents) {
13611375
// Cross-account dedup: in multi-account setups, Feishu delivers the same
@@ -1406,7 +1420,7 @@ export async function handleFeishuMessage(params: {
14061420
runtime: runtime as RuntimeEnv,
14071421
chatId: ctx.chatId,
14081422
replyToMessageId: replyTargetMessageId,
1409-
skipReplyToInMessages: !isGroup,
1423+
skipReplyToInMessages: !isGroup && !directThreadMessage,
14101424
replyInThread,
14111425
rootId: ctx.rootId,
14121426
threadReply,
@@ -1504,7 +1518,7 @@ export async function handleFeishuMessage(params: {
15041518
runtime: runtime as RuntimeEnv,
15051519
chatId: ctx.chatId,
15061520
replyToMessageId: replyTargetMessageId,
1507-
skipReplyToInMessages: !isGroup,
1521+
skipReplyToInMessages: !isGroup && !directThreadMessage,
15081522
replyInThread,
15091523
rootId: ctx.rootId,
15101524
threadReply,

0 commit comments

Comments
 (0)