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, + }); } }