import { describe, expect, it, vi } from "vitest"; import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; import { downloadMessageFile, FeishuFileDeliveryError, resolveSenderName, sendFile, sendFileData, sendLongText, sendTextMessage, type FeishuRuntime, } from "../../src/feishu/client.js"; import { SenderNameCache } from "../../src/feishu/senderCache.js"; const itOnLinux = process.platform === "linux" ? it : it.skip; describe("Feishu client helpers", () => { itOnLinux("downloads an image message resource through the SDK stream helper", async () => { const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); try { const workspace = join(dir, "workspace"); await mkdir(workspace); const destination = join(workspace, ".cph", "inbox", "image.png"); const getReadableStream = vi.fn(() => Readable.from([Buffer.from("image bytes")])); const messageResourceGet = vi.fn(async () => ({ getReadableStream })); const rawRequest = vi.fn(async () => Buffer.from("image bytes")); const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet, request: rawRequest, }); await expect( downloadMessageFile(rt, "message-1", "img-key-1", dir, workspace, ".cph/inbox/image.png", "image"), ).resolves.toBe(join(await realpath(workspace), ".cph", "inbox", "image.png")); expect(messageResourceGet).toHaveBeenCalledWith({ params: { type: "image" }, path: { message_id: "message-1", file_key: "img-key-1" }, }); expect(getReadableStream).toHaveBeenCalledTimes(1); await expect(readFile(destination, "utf8")).resolves.toBe("image bytes"); expect(rawRequest).not.toHaveBeenCalled(); } finally { await rm(dir, { recursive: true, force: true }); } }); itOnLinux("refuses an inbound download through a symlinked workspace directory", async () => { const root = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); try { const workspace = join(root, "workspace"); const outside = join(root, "outside"); await Promise.all([mkdir(workspace), mkdir(outside)]); await symlink(outside, join(workspace, ".cph")); const messageResourceGet = vi.fn(async () => ({ getReadableStream: () => Readable.from([Buffer.from("must-not-escape")]), })); const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), messageResourceGet }); await expect( downloadMessageFile(rt, "message-1", "file-key-1", root, workspace, ".cph/inbox/file.bin", "file"), ).rejects.toThrow(/workspace|symlink|directory/i); await expect(access(join(outside, "inbox", "file.bin"))).rejects.toMatchObject({ code: "ENOENT" }); } finally { await rm(root, { recursive: true, force: true }); } }); it("accepts top-level file_key from the Lark SDK file upload response", async () => { const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); try { const file = join(dir, "student.pdf"); await writeFile(file, "pdf bytes"); const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" })); const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } })); const rt = mockRuntime({ fileCreate, messageCreate }); await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1"); expect(messageCreate).toHaveBeenCalledWith({ params: { receive_id_type: "chat_id" }, data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) }, }); } finally { await rm(dir, { recursive: true, force: true }); } }); it("rejects malformed file upload responses instead of hiding the failure", async () => { const rt = mockRuntime({ fileCreate: vi.fn(async () => ({ data: {} })), messageCreate: vi.fn(), }); await expect(sendFileData(rt, "chat-1", Buffer.from("bytes"), "student.pdf")) .rejects.toEqual(expect.objectContaining({ name: "FeishuFileDeliveryError", message: "Feishu file upload response is missing file_key", } satisfies Partial)); }); it("accepts top-level message_id from message create responses", async () => { const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(async () => ({ message_id: "message-1" })), }); await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1"); }); it("replies to a message without enabling Feishu topic mode", async () => { const messageCreate = vi.fn(); const messageReply = vi.fn(async () => ({ data: { message_id: "reply-1" } })); const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate, messageReply }); await expect(sendTextMessage(rt, "chat-1", "hello", { replyToMessageId: "m-trigger" })).resolves.toBe("reply-1"); expect(messageCreate).not.toHaveBeenCalled(); expect(messageReply).toHaveBeenCalledWith({ path: { message_id: "m-trigger" }, data: { msg_type: "text", content: JSON.stringify({ text: "hello" }), }, }); }); it("sends long text in chunks and returns the last successful message id", async () => { let messageNumber = 0; const messageCreate = vi.fn(async () => ({ data: { message_id: `message-${++messageNumber}` } })); const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate }); await expect(sendLongText(rt, "chat-1", "x".repeat(16_005))).resolves.toBe("message-3"); expect(messageCreate).toHaveBeenCalledTimes(3); const sentTexts = messageCreate.mock.calls.map(([payload]) => { const content = (payload as { data: { content: string } }).data.content; return (JSON.parse(content) as { text: string }).text; }); expect(sentTexts.map((text) => text.length)).toEqual([8000, 8000, 5]); }); it("resolves sender names through the basic contact API and caches hits", async () => { const contactBasicBatch = vi.fn(async () => ({ data: { users: [{ name: "Jiarong" }] } })); const request = vi.fn(); const rt = mockRuntime({ fileCreate: vi.fn(), messageCreate: vi.fn(), contactBasicBatch, request }); const cache = new SenderNameCache(); await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong"); await expect(resolveSenderName(rt, "ou_user_1", cache)).resolves.toBe("Jiarong"); expect(contactBasicBatch).toHaveBeenCalledTimes(1); expect(contactBasicBatch).toHaveBeenCalledWith({ params: { user_id_type: "open_id" }, data: { user_ids: ["ou_user_1"] }, }); expect(request).not.toHaveBeenCalled(); }); }); function mockRuntime(options: { readonly fileCreate: (payload: unknown) => Promise; readonly messageCreate: (payload: unknown) => Promise; readonly messageReply?: (payload: unknown) => Promise; readonly contactBasicBatch?: (payload: unknown) => Promise; readonly messageResourceGet?: (payload: unknown) => Promise; readonly request?: (payload: unknown) => Promise; }): FeishuRuntime { return { client: { request: options.request, contact: { v3: { user: { basicBatch: options.contactBasicBatch, }, }, }, im: { v1: { file: { create: options.fileCreate }, messageResource: { get: options.messageResourceGet }, message: { create: options.messageCreate, reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })), }, }, }, } as unknown as FeishuRuntime["client"], logger: { info() {}, warn() {}, error() {}, debug() {}, fatal() {}, child() { return this; }, level: "silent", } as unknown as FeishuRuntime["logger"], }; }