From 1bc5ba6e29db8f01fc77ed9022f0e0c7c8daf8ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 19:21:01 +0100 Subject: [PATCH] fix(feishu): prefer video file_key for inbound media --- CHANGELOG.md | 1 + extensions/feishu/src/bot.test.ts | 90 +++++++++++++++++++++++++++---- extensions/feishu/src/bot.ts | 2 +- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37627ad1bcf..94b71c39417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai - Dev tooling: prevent `CLAUDE.md` symlink target regressions by excluding CLAUDE symlink sentinels from `oxfmt` and marking them `-text` in `.gitattributes`, so formatter/EOL normalization cannot reintroduce trailing-newline targets. Thanks @vincentkoc. - Cron: honor `cron.maxConcurrentRuns` in the timer loop so due jobs can execute up to the configured parallelism instead of always running serially. (#11595) Thanks @Takhoffman. - Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. +- Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) - Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. - Providers/OpenRouter: inject `cache_control` on system prompts for OpenRouter Anthropic models to improve prompt-cache reuse. (#17473) Thanks @rrenamed. - Installer/Smoke tests: remove legacy `OPENCLAW_USE_GUM` overrides from docker install-smoke runs so tests exercise installer auto TTY detection behavior directly. diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 8f2c306b9c8..40f03a4f993 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -4,17 +4,25 @@ import type { FeishuMessageEvent } from "./bot.js"; import { handleFeishuMessage } from "./bot.js"; import { setFeishuRuntime } from "./runtime.js"; -const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted( - () => ({ - mockCreateFeishuReplyDispatcher: vi.fn(() => ({ - dispatcher: vi.fn(), - replyOptions: {}, - markDispatchIdle: vi.fn(), - })), - mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), - mockGetMessageFeishu: vi.fn().mockResolvedValue(null), +const { + mockCreateFeishuReplyDispatcher, + mockSendMessageFeishu, + mockGetMessageFeishu, + mockDownloadMessageResourceFeishu, +} = vi.hoisted(() => ({ + mockCreateFeishuReplyDispatcher: vi.fn(() => ({ + dispatcher: vi.fn(), + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), + mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), + mockGetMessageFeishu: vi.fn().mockResolvedValue(null), + mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({ + buffer: Buffer.from("video"), + contentType: "video/mp4", + fileName: "clip.mp4", }), -); +})); vi.mock("./reply-dispatcher.js", () => ({ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher, @@ -25,6 +33,10 @@ vi.mock("./send.js", () => ({ getMessageFeishu: mockGetMessageFeishu, })); +vi.mock("./media.js", () => ({ + downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu, +})); + function createRuntimeEnv(): RuntimeEnv { return { log: vi.fn(), @@ -53,6 +65,10 @@ describe("handleFeishuMessage command authorization", () => { const mockReadAllowFromStore = vi.fn().mockResolvedValue([]); const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }); const mockBuildPairingReply = vi.fn(() => "Pairing response"); + const mockSaveMediaBuffer = vi.fn().mockResolvedValue({ + path: "/tmp/inbound-clip.mp4", + contentType: "video/mp4", + }); beforeEach(() => { vi.clearAllMocks(); @@ -79,12 +95,18 @@ describe("handleFeishuMessage command authorization", () => { shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized, resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers, }, + media: { + saveMediaBuffer: mockSaveMediaBuffer, + }, pairing: { readAllowFromStore: mockReadAllowFromStore, upsertPairingRequest: mockUpsertPairingRequest, buildPairingReply: mockBuildPairingReply, }, }, + media: { + detectMime: vi.fn(async () => "application/octet-stream"), + }, } as unknown as PluginRuntime); }); @@ -312,4 +334,52 @@ describe("handleFeishuMessage command authorization", () => { }), ); }); + + it("uses video file_key (not thumbnail image_key) for inbound video download", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + dmPolicy: "open", + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-sender", + }, + }, + message: { + message_id: "msg-video-inbound", + chat_id: "oc-dm", + chat_type: "p2p", + message_type: "video", + content: JSON.stringify({ + file_key: "file_video_payload", + image_key: "img_thumb_payload", + file_name: "clip.mp4", + }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: "msg-video-inbound", + fileKey: "file_video_payload", + type: "file", + }), + ); + expect(mockSaveMediaBuffer).toHaveBeenCalledWith( + expect.any(Buffer), + "video/mp4", + "inbound", + expect.any(Number), + "clip.mp4", + ); + }); }); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 1bddac1fe42..91d390ac04d 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -412,7 +412,7 @@ async function resolveFeishuMediaList(params: { // For message media, always use messageResource API // The image.get API is only for images uploaded via im/v1/images, not for message attachments - const fileKey = mediaKeys.imageKey || mediaKeys.fileKey; + const fileKey = mediaKeys.fileKey || mediaKeys.imageKey; if (!fileKey) { return []; }