diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index f30bc20a973ac..d6cc1d4151a8b 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2604,6 +2604,134 @@ describe("handleFeishuMessage command authorization", () => { undefined, ); }); + + it("downloads embedded image and media tags from quoted post messages into the agent context", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + const quotedRawContent = JSON.stringify({ + title: "Rich quote", + content: [ + [ + { tag: "text", text: "see attachments " }, + { tag: "img", image_key: "img_quoted_payload" }, + { + tag: "media", + file_key: "file_quoted_media_payload", + file_name: "quoted-clip.mp4", + }, + ], + ], + }); + mockGetMessageFeishu.mockResolvedValueOnce({ + messageId: "om_parent_post", + chatId: "oc-group", + chatType: "group", + senderId: "ou-post-sender", + senderType: "user", + content: "Rich quote\n\nsee attachments ![image][media]", + rawContent: quotedRawContent, + contentType: "post", + }); + mockDownloadMessageResourceFeishu + .mockResolvedValueOnce({ + buffer: Buffer.from("quoted-image"), + contentType: "image/png", + fileName: "quoted-image.png", + }) + .mockResolvedValueOnce({ + buffer: Buffer.from("quoted-media"), + contentType: "video/mp4", + fileName: "quoted-clip.mp4", + }); + mockSaveMediaBuffer + .mockResolvedValueOnce({ + id: "quoted-image.png", + path: "/tmp/quoted-image.png", + size: Buffer.byteLength("quoted-image"), + contentType: "image/png", + }) + .mockResolvedValueOnce({ + id: "quoted-clip.mp4", + path: "/tmp/quoted-clip.mp4", + size: Buffer.byteLength("quoted-media"), + contentType: "video/mp4", + }); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groupPolicy: "open", + requireMention: false, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-replier", + }, + }, + message: { + message_id: "om_reply_to_post", + parent_id: "om_parent_post", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "please inspect these attachments" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockDownloadMessageResourceFeishu).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + messageId: "om_parent_post", + fileKey: "img_quoted_payload", + type: "image", + }), + ); + expect(mockDownloadMessageResourceFeishu).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + messageId: "om_parent_post", + fileKey: "file_quoted_media_payload", + type: "file", + originalFilename: "quoted-clip.mp4", + }), + ); + expect(mockSaveMediaBuffer).toHaveBeenNthCalledWith( + 1, + expect.any(Buffer), + "image/png", + "inbound", + expect.any(Number), + "quoted-image.png", + ); + expect(mockSaveMediaBuffer).toHaveBeenNthCalledWith( + 2, + expect.any(Buffer), + "video/mp4", + "inbound", + expect.any(Number), + "quoted-clip.mp4", + ); + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + SupplementalContext: expect.objectContaining({ + quote: expect.objectContaining({ + body: "Rich quote\n\nsee attachments ![image][media]", + id: "om_parent_post", + }), + }), + MediaPath: "/tmp/quoted-image.png", + MediaPaths: ["/tmp/quoted-image.png", "/tmp/quoted-clip.mp4"], + MediaTypes: ["image/png", "video/mp4"], + }), + undefined, + ); + }); + it("includes message_id in BodyForAgent on its own line", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); @@ -3361,7 +3489,7 @@ describe("handleFeishuMessage command authorization", () => { expect(dispatcherOptions.rootId).toBe("om_root_topic"); }); - it("replies to the triggering message in normal groups even when root_id is present (#32980)", async () => { + it("sends top-level replies in normal groups even when root_id quote metadata is present", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); const cfg: ClawdbotConfig = { @@ -3396,12 +3524,12 @@ describe("handleFeishuMessage command authorization", () => { rootId?: string; skipReplyToInMessages?: boolean; }>(mockCreateFeishuReplyDispatcher, 0, 0); - expect(dispatcherOptions.replyToMessageId).toBe("om_quote_reply"); + expect(dispatcherOptions.replyToMessageId).toBeUndefined(); expect(dispatcherOptions.skipReplyToInMessages).toBe(false); expect(dispatcherOptions.rootId).toBe("om_original_msg"); }); - it("replies to the triggering quoted command message in normal groups", async () => { + it("sends quoted command responses as top-level messages in normal groups", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); const cfg: ClawdbotConfig = { @@ -3440,12 +3568,53 @@ describe("handleFeishuMessage command authorization", () => { skipReplyToInMessages?: boolean; replyInThread?: boolean; }>(mockCreateFeishuReplyDispatcher, 0, 0); - expect(dispatcherOptions.replyToMessageId).toBe("om_current_quoted_command"); + expect(dispatcherOptions.replyToMessageId).toBeUndefined(); expect(dispatcherOptions.skipReplyToInMessages).toBe(false); expect(dispatcherOptions.replyInThread).toBe(false); expect(dispatcherOptions.rootId).toBe("om_quoted_root"); }); + it("keeps synthetic card action reply targets in normal groups", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { sender_id: { open_id: "ou-card-user" } }, + message: { + message_id: "card-action-token", + reply_target_message_id: "om_card_message", + synthetic_card_action: true, + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "/approve plugin:test allow-once" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + const dispatcherOptions = mockCallArg<{ + replyToMessageId?: string; + replyInThread?: boolean; + threadReply?: boolean; + }>(mockCreateFeishuReplyDispatcher, 0, 0); + expect(dispatcherOptions.replyToMessageId).toBe("om_card_message"); + expect(dispatcherOptions.replyInThread).toBe(false); + expect(dispatcherOptions.threadReply).toBe(false); + }); + it("replies to topic root in topic-mode group with root_id", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); @@ -3597,7 +3766,7 @@ describe("handleFeishuMessage command authorization", () => { ); }); - it("does not force thread replies when inbound message contains thread_id but thread replies are disabled", async () => { + it("preserves Feishu thread replies when inbound message contains thread_id", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); const cfg: ClawdbotConfig = { @@ -3636,8 +3805,8 @@ describe("handleFeishuMessage command authorization", () => { }>(mockCreateFeishuReplyDispatcher, 0, 0); expect(dispatcherOptions.replyToMessageId).toBe("msg-thread-reply"); expect(dispatcherOptions.skipReplyToInMessages).toBe(false); - expect(dispatcherOptions.replyInThread).toBe(false); - expect(dispatcherOptions.threadReply).toBe(false); + expect(dispatcherOptions.replyInThread).toBe(true); + expect(dispatcherOptions.threadReply).toBe(true); }); it("bootstraps topic thread context only for a new thread session", async () => { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 5fb57a8aaad26..d99001c65db1f 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1417,27 +1417,28 @@ export async function handleFeishuMessage(params: { // Determine reply target based on group session mode: // - Topic-mode groups (group_topic / group_topic_sender): reply to the topic // root so the bot stays in the same thread. - // - Groups with explicit replyInThread config: reply to the root so the bot - // stays in the thread the user expects. - // - Normal groups: reply to the triggering message itself. Quoted-message - // metadata (reply_target_message_id/root_id/parent_id) is inbound context, - // not the outbound reply anchor; otherwise commands sent with a quote can - // be routed back into the quoted message's thread. + // - Real Feishu topic events and explicit replyInThread config: reply via + // message.reply so the bot stays in the thread the user expects. + // - Normal groups: send top-level messages. Quote metadata + // (reply_target_message_id/root_id/parent_id) is inbound context only. const isTopicSession = isGroup && (groupSession?.groupSessionScope === "group_topic" || groupSession?.groupSessionScope === "group_topic_sender"); + const isInboundFeishuThread = + isGroup && (Boolean(ctx.threadId?.trim()) || ctx.chatType === "topic_group"); const configReplyInThread = isGroup && (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled"; - const shouldReplyInFeishuThread = isTopicSession || configReplyInThread; + const shouldReplyInFeishuThread = + isTopicSession || isInboundFeishuThread || configReplyInThread; const topicReplyTargetMessageId = - ctx.rootId ?? ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId); + ctx.rootId ?? + ctx.replyTargetMessageId ?? + (ctx.suppressReplyTarget ? undefined : ctx.messageId); const normalGroupReplyTargetMessageId = ctx.syntheticCardAction ? ctx.replyTargetMessageId - : ctx.suppressReplyTarget - ? undefined - : ctx.messageId; + : undefined; const replyTargetMessageId = directThreadReply ? directThreadReplyTargetMessageId : shouldReplyInFeishuThread @@ -1446,9 +1447,14 @@ export async function handleFeishuMessage(params: { ? normalGroupReplyTargetMessageId : defaultReplyTargetMessageId; const skipReplyToInMessages = !isGroup && !directThreadReply; + const dispatchReplyInThread = directThreadReply + ? true + : shouldReplyInFeishuThread + ? isInboundFeishuThread || replyInThread + : false; const threadReply = isGroup ? shouldReplyInFeishuThread - ? (groupSession?.threadReply ?? false) + ? isInboundFeishuThread || (groupSession?.threadReply ?? false) : false : directThreadReply; const lastRouteThreadId = @@ -1574,11 +1580,7 @@ export async function handleFeishuMessage(params: { allowReasoningPreview, replyToMessageId: replyTargetMessageId, skipReplyToInMessages, - replyInThread: directThreadReply - ? true - : shouldReplyInFeishuThread - ? replyInThread - : false, + replyInThread: dispatchReplyInThread, rootId: ctx.rootId, threadReply, accountId: account.accountId, @@ -1754,11 +1756,7 @@ export async function handleFeishuMessage(params: { allowReasoningPreview, replyToMessageId: replyTargetMessageId, skipReplyToInMessages, - replyInThread: directThreadReply - ? true - : shouldReplyInFeishuThread - ? replyInThread - : false, + replyInThread: dispatchReplyInThread, rootId: ctx.rootId, threadReply, accountId: account.accountId, diff --git a/extensions/feishu/src/send.reply-fallback.test.ts b/extensions/feishu/src/send.reply-fallback.test.ts index 79af3934302ba..c9466b9a5d223 100644 --- a/extensions/feishu/src/send.reply-fallback.test.ts +++ b/extensions/feishu/src/send.reply-fallback.test.ts @@ -253,6 +253,7 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { content: '{"zh_cn":{"content":[[{"tag":"md","text":"hello"}]]}}', msg_type: "post", reply_in_thread: true, + uuid: expect.any(String), }, }); expect(createMock).toHaveBeenCalledWith({ @@ -261,6 +262,7 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => { content: '{"zh_cn":{"content":[[{"tag":"md","text":"hello"}]]}}', receive_id: "oc_group_1", msg_type: "post", + uuid: expect.any(String), }, }); });