diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 78ff14c816b1..ac17bca02193 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -525,6 +525,40 @@ describe("handleFeishuMessage ACP routing", () => { expect(message.text).toContain("runtime unavailable"); }); + it("surfaces configured ACP initialization failures inside P2P direct-message threads", async () => { + mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute()); + mockEnsureConfiguredBindingRouteReady.mockResolvedValue( + createConfiguredBindingReadiness(false, "runtime unavailable"), + ); + + await dispatchMessage({ + cfg: { + session: { mainKey: "main", scope: "per-sender" }, + channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } }, + }, + event: { + sender: { sender_id: { open_id: "ou_sender_1" } }, + message: { + message_id: "msg-thread-child", + root_id: "msg-thread-root", + thread_id: "omt-acp-dm-thread", + chat_id: "oc_dm", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello" }), + }, + }, + }); + + expect(mockSendMessageFeishu).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat:oc_dm", + replyToMessageId: "msg-thread-root", + replyInThread: true, + }), + ); + }); + it("routes Feishu topic messages through active bound conversations", async () => { mockResolveBoundConversation.mockReturnValue(createBoundConversation()); @@ -3359,6 +3393,79 @@ describe("handleFeishuMessage command authorization", () => { expect(dispatcherOptions.rootId).toBe("om_topic_sender_root"); }); + it("keeps P2P replies inside a direct-message thread when Feishu supplies thread_id", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-thread-dm" } }, + message: { + message_id: "om_dm_thread_child", + root_id: "om_dm_thread_root", + thread_id: "omt_dm_thread", + chat_id: "oc-dm-thread", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "hello inside a DM thread" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_dm_thread_root", + rootId: "om_dm_thread_root", + skipReplyToInMessages: false, + replyInThread: true, + threadReply: true, + }), + ); + }); + + it("keeps root_id-only P2P replies as quote replies outside thread mode", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-quote-dm" } }, + message: { + message_id: "om_dm_quote_reply", + root_id: "om_dm_quote_root", + chat_id: "oc-dm-quote", + chat_type: "p2p", + message_type: "text", + content: JSON.stringify({ text: "quoted DM reply" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith( + expect.objectContaining({ + replyToMessageId: "om_dm_quote_reply", + rootId: "om_dm_quote_root", + skipReplyToInMessages: true, + replyInThread: false, + threadReply: false, + }), + ); + }); + it("forces thread replies when inbound message contains thread_id", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 86f0ba0a0471..b1d1463225ca 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -802,7 +802,14 @@ export async function handleFeishuMessage(params: { const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`; const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId; const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null; - const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false; + const directThreadReply = !isGroup && Boolean(ctx.threadId?.trim()); + const defaultReplyTargetMessageId = + ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId); + const directThreadRootId = directThreadReply ? ctx.rootId?.trim() || undefined : undefined; + const directThreadReplyTargetMessageId = directThreadReply + ? (directThreadRootId ?? defaultReplyTargetMessageId) + : undefined; + const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadReply; const feishuAcpConversationSupported = !isGroup || groupSession?.groupSessionScope === "group_topic" || @@ -906,10 +913,13 @@ export async function handleFeishuMessage(params: { bindingResolution: configuredBinding, }); if (!ensured.ok) { - const replyTargetMessageId = + const acpTopicReply = isGroup && (groupSession?.groupSessionScope === "group_topic" || - groupSession?.groupSessionScope === "group_topic_sender") + groupSession?.groupSessionScope === "group_topic_sender"); + const replyTargetMessageId = directThreadReply + ? directThreadReplyTargetMessageId + : acpTopicReply ? (ctx.rootId ?? ctx.messageId) : ctx.messageId; await sendMessageFeishu({ @@ -917,7 +927,7 @@ export async function handleFeishuMessage(params: { to: `chat:${ctx.chatId}`, text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`, replyToMessageId: replyTargetMessageId, - replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false, + replyInThread, accountId: account.accountId, }).catch((err: unknown) => { log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`); @@ -1387,13 +1397,13 @@ export async function handleFeishuMessage(params: { const configReplyInThread = isGroup && (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; - const replyTargetMessageId = - isTopicSession || configReplyInThread - ? (ctx.rootId ?? - ctx.replyTargetMessageId ?? - (ctx.suppressReplyTarget ? undefined : ctx.messageId)) - : (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId)); - const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false; + const topicReplyTargetMessageId = ctx.rootId ?? defaultReplyTargetMessageId; + const replyTargetMessageId = directThreadReply + ? directThreadReplyTargetMessageId + : isTopicSession || configReplyInThread + ? topicReplyTargetMessageId + : defaultReplyTargetMessageId; + const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadReply; const lastRouteThreadId = isGroup && (isTopicSession || configReplyInThread || threadReply) ? replyTargetMessageId @@ -1518,7 +1528,7 @@ export async function handleFeishuMessage(params: { chatId: ctx.chatId, allowReasoningPreview, replyToMessageId: replyTargetMessageId, - skipReplyToInMessages: !isGroup, + skipReplyToInMessages: !isGroup && !directThreadReply, replyInThread, rootId: ctx.rootId, threadReply, @@ -1694,7 +1704,7 @@ export async function handleFeishuMessage(params: { chatId: ctx.chatId, allowReasoningPreview, replyToMessageId: replyTargetMessageId, - skipReplyToInMessages: !isGroup, + skipReplyToInMessages: !isGroup && !directThreadReply, replyInThread, rootId: ctx.rootId, threadReply,