From af172742a3f6f820ac3e199d6e319fcc0bae78ca Mon Sep 17 00:00:00 2001 From: 0xRain Date: Fri, 13 Feb 2026 02:05:09 +0800 Subject: [PATCH] fix(feishu): use msg_type 'media' for video/audio messages (#14648) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: e8044cb2085cc77ac2b9e819a09dc7e1c21bc8da Co-authored-by: 0xRaini <190923101+0xRaini@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- extensions/feishu/src/media.test.ts | 151 ++++++++++++++++++++++++++++ extensions/feishu/src/media.ts | 18 +++- 2 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 extensions/feishu/src/media.test.ts diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts new file mode 100644 index 000000000..433d193a1 --- /dev/null +++ b/extensions/feishu/src/media.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); +const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn()); +const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); + +const fileCreateMock = vi.hoisted(() => vi.fn()); +const messageCreateMock = vi.hoisted(() => vi.fn()); +const messageReplyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + +vi.mock("./accounts.js", () => ({ + resolveFeishuAccount: resolveFeishuAccountMock, +})); + +vi.mock("./targets.js", () => ({ + normalizeFeishuTarget: normalizeFeishuTargetMock, + resolveReceiveIdType: resolveReceiveIdTypeMock, +})); + +import { sendMediaFeishu } from "./media.js"; + +describe("sendMediaFeishu msg_type routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + + resolveFeishuAccountMock.mockReturnValue({ + configured: true, + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + }); + + normalizeFeishuTargetMock.mockReturnValue("ou_target"); + resolveReceiveIdTypeMock.mockReturnValue("open_id"); + + createFeishuClientMock.mockReturnValue({ + im: { + file: { + create: fileCreateMock, + }, + message: { + create: messageCreateMock, + reply: messageReplyMock, + }, + }, + }); + + fileCreateMock.mockResolvedValue({ + code: 0, + data: { file_key: "file_key_1" }, + }); + + messageCreateMock.mockResolvedValue({ + code: 0, + data: { message_id: "msg_1" }, + }); + + messageReplyMock.mockResolvedValue({ + code: 0, + data: { message_id: "reply_1" }, + }); + }); + + it("uses msg_type=media for mp4", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("video"), + fileName: "clip.mp4", + }); + + expect(fileCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ file_type: "mp4" }), + }), + ); + + expect(messageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ msg_type: "media" }), + }), + ); + }); + + it("uses msg_type=media for opus", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("audio"), + fileName: "voice.opus", + }); + + expect(fileCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ file_type: "opus" }), + }), + ); + + expect(messageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ msg_type: "media" }), + }), + ); + }); + + it("uses msg_type=file for documents", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("doc"), + fileName: "paper.pdf", + }); + + expect(fileCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ file_type: "pdf" }), + }), + ); + + expect(messageCreateMock).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ msg_type: "file" }), + }), + ); + }); + + it("uses msg_type=media when replying with mp4", async () => { + await sendMediaFeishu({ + cfg: {} as any, + to: "user:ou_target", + mediaBuffer: Buffer.from("video"), + fileName: "reply.mp4", + replyToMessageId: "om_parent", + }); + + expect(messageReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + path: { message_id: "om_parent" }, + data: expect.objectContaining({ msg_type: "media" }), + }), + ); + + expect(messageCreateMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index c9e74fddf..8f5eafce3 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -359,10 +359,13 @@ export async function sendFileFeishu(params: { cfg: ClawdbotConfig; to: string; fileKey: string; + /** Use "media" for audio/video files, "file" for documents */ + msgType?: "file" | "media"; replyToMessageId?: string; accountId?: string; }): Promise { const { cfg, to, fileKey, replyToMessageId, accountId } = params; + const msgType = params.msgType ?? "file"; const account = resolveFeishuAccount({ cfg, accountId }); if (!account.configured) { throw new Error(`Feishu account "${account.accountId}" not configured`); @@ -382,7 +385,7 @@ export async function sendFileFeishu(params: { path: { message_id: replyToMessageId }, data: { content, - msg_type: "file", + msg_type: msgType, }, }); @@ -401,7 +404,7 @@ export async function sendFileFeishu(params: { data: { receive_id: receiveId, content, - msg_type: "file", + msg_type: msgType, }, }); @@ -524,6 +527,15 @@ export async function sendMediaFeishu(params: { fileType, accountId, }); - return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId }); + // Feishu requires msg_type "media" for audio/video, "file" for documents + const isMedia = fileType === "mp4" || fileType === "opus"; + return sendFileFeishu({ + cfg, + to, + fileKey, + msgType: isMedia ? "media" : "file", + replyToMessageId, + accountId, + }); } }