import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; import { describe, expect, it, vi } from "vitest"; import { claudeSdkToolConfigForRole, cphHubMcpToolsForRole } from "../../src/agent/roleTools.js"; import type { FeishuRuntime } from "../../src/feishu/client.js"; import { downloadFeishuMessageResource } from "../../src/feishu/download.js"; const itOnLinux = process.platform === "linux" ? it : it.skip; describe("Feishu message resource download", () => { it("exposes the download tool to default and explicitly configured roles", () => { expect(cphHubMcpToolsForRole(undefined)).toContain("feishu_download_resource"); expect(cphHubMcpToolsForRole(["feishu_download_resource"])).toEqual(["feishu_download_resource"]); expect(claudeSdkToolConfigForRole(["feishu_download_resource"]).allowedTools).toEqual([ "mcp__cph_hub__feishu_download_resource", ]); }); itOnLinux("downloads a bound-chat image into the project inbox", async () => { const workspaceRoot = await mkdtemp(join(tmpdir(), "hub-feishu-download-")); const workspaceDir = join(workspaceRoot, "project"); await mkdir(workspaceDir); try { const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); const messageResourceGet = vi.fn(async () => ({ getReadableStream: () => Readable.from([Buffer.from("image bytes")]), })); const rt = mockRuntime(messageGet, messageResourceGet); const result = await downloadFeishuMessageResource( { messageId: "message-1", fileKey: "img-key-1", resourceType: "image" }, { boundChatId: "chat-1", workspaceRoot, workspaceDir }, rt, ); expect(result).toMatchObject({ resourceType: "image" }); expect(result.path).toMatch( new RegExp(`^${escapeRegExp(join(await realpath(workspaceDir), ".cph", "inbox"))}`), ); expect(result.path).toMatch(/\.png$/); await expect(readFile(result.path, "utf8")).resolves.toBe("image bytes"); expect(messageResourceGet).toHaveBeenCalledWith({ params: { type: "image" }, path: { message_id: "message-1", file_key: "img-key-1" }, }); } finally { await rm(workspaceRoot, { recursive: true, force: true }); } }); it("rejects resources from a message outside the bound chat", async () => { const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-other" }] } })); const messageResourceGet = vi.fn(); const rt = mockRuntime(messageGet, messageResourceGet); await expect(downloadFeishuMessageResource( { messageId: "message-other", fileKey: "img-key-other", resourceType: "image" }, { boundChatId: "chat-1", workspaceRoot: "/tmp", workspaceDir: "/tmp/project-1" }, rt, )).rejects.toThrow("current project's bound chat"); expect(messageResourceGet).not.toHaveBeenCalled(); }); }); function mockRuntime( messageGet: (payload: unknown) => Promise, messageResourceGet: (payload: unknown) => Promise, ): FeishuRuntime { return { client: { im: { v1: { message: { get: messageGet }, messageResource: { get: messageResourceGet }, }, }, } as unknown as FeishuRuntime["client"], logger: { info() {}, warn() {}, error() {}, debug() {}, fatal() {}, child() { return this; }, level: "silent", } as unknown as FeishuRuntime["logger"], }; } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }