diff --git a/hub/src/agent/roleTools.ts b/hub/src/agent/roleTools.ts index a157698..5fe72fe 100644 --- a/hub/src/agent/roleTools.ts +++ b/hub/src/agent/roleTools.ts @@ -1,7 +1,12 @@ export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const; export const CPH_HUB_MCP_SERVER_NAME = "cph_hub"; -export const CPH_HUB_MCP_TOOL_IDS = ["send_file", "feishu_read_context", "request_approval"] as const; +export const CPH_HUB_MCP_TOOL_IDS = [ + "send_file", + "feishu_read_context", + "feishu_download_resource", + "request_approval", +] as const; export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number]; @@ -31,9 +36,11 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map([ const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map([ ["send_file", "send_file"], ["feishu_read_context", "feishu_read_context"], + ["feishu_download_resource", "feishu_download_resource"], ["request_approval", "request_approval"], ["mcp__cph_hub__send_file", "send_file"], ["mcp__cph_hub__feishu_read_context", "feishu_read_context"], + ["mcp__cph_hub__feishu_download_resource", "feishu_download_resource"], ["mcp__cph_hub__request_approval", "request_approval"], ]); diff --git a/hub/src/feishu/card/builder.ts b/hub/src/feishu/card/builder.ts index 9319b1e..e5bbf3f 100644 --- a/hub/src/feishu/card/builder.ts +++ b/hub/src/feishu/card/builder.ts @@ -41,6 +41,7 @@ const TOOL_ICONS: Record = { send_file: "send-filled", request_approval: "thumb-up-filled", feishu_read_context: "search-filled", + feishu_download_resource: "download-filled", }; function toolIcon(toolName: string): string { diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index e355d4d..4e3c884 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -6,7 +6,7 @@ * reactions, file download/upload, and the card action callback. */ import * as lark from "@larksuiteoapi/node-sdk"; -import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { readFile, mkdir } from "node:fs/promises"; import { dirname } from "node:path"; import type { FastifyBaseLogger } from "fastify"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; @@ -487,20 +487,20 @@ export async function downloadMessageFile( messageId: string, fileKey: string, savePath: string, + resourceType: "image" | "file", ): Promise { await mkdir(dirname(savePath), { recursive: true }); try { - const res = await withRetry(async () => { - return rt.client.request({ - method: "GET", - url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`, - params: { type: "file" }, - responseType: "arraybuffer", - }) as Promise<{ data: Buffer }>; + await withRetry(async () => { + const resource = await rt.client.im.v1.messageResource.get({ + params: { type: resourceType }, + path: { message_id: messageId, file_key: fileKey }, + }); + await resource.writeFile(savePath); }); - await writeFile(savePath, res.data); } catch (e) { - rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed"); + rt.logger.error({ err: e, messageId, fileKey, resourceType, savePath }, "downloadMessageFile failed"); + throw e; } } diff --git a/hub/src/feishu/download.ts b/hub/src/feishu/download.ts new file mode 100644 index 0000000..71c889f --- /dev/null +++ b/hub/src/feishu/download.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import type { ToolContext } from "../agent/tools.js"; +import { downloadMessageFile, type FeishuRuntime } from "./client.js"; + +export interface FeishuMessageResourceArgs { + readonly messageId: string; + readonly fileKey: string; + readonly resourceType: "image" | "file"; +} + +export interface DownloadedFeishuMessageResource extends FeishuMessageResourceArgs { + readonly path: string; +} + +type DownloadContext = Pick; + +interface MessageLookupResult { + readonly data?: { + readonly message?: { readonly chat_id?: string | undefined } | undefined; + readonly items?: ReadonlyArray<{ readonly chat_id?: string | undefined }> | undefined; + } | undefined; +} + +/** Download one resource after proving its message belongs to the run's bound chat. */ +export async function downloadFeishuMessageResource( + args: FeishuMessageResourceArgs, + context: DownloadContext, + rt: FeishuRuntime, +): Promise { + const response = await rt.client.im.v1.message.get({ + path: { message_id: args.messageId }, + }) as MessageLookupResult; + const message = response.data?.message ?? response.data?.items?.[0]; + if (message === undefined) { + throw new Error(`Feishu message not found: ${args.messageId}`); + } + if (message.chat_id !== context.boundChatId) { + throw new Error( + `refused Feishu resource download: message ${args.messageId} is not in the current project's bound chat`, + ); + } + + const extension = args.resourceType === "image" ? ".png" : ".bin"; + const savePath = join( + context.workspaceDir, + ".cph", + "inbox", + `feishu-${args.resourceType}-${randomUUID()}${extension}`, + ); + await downloadMessageFile(rt, args.messageId, args.fileKey, savePath, args.resourceType); + return { ...args, path: savePath }; +} diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 2467525..5b309eb 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -2,6 +2,7 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance, type Sdk import { z } from "zod"; import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; +import { downloadFeishuMessageResource } from "./download.js"; import { readFeishuContext } from "./read.js"; import type { ApprovalManager } from "./approval.js"; import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.js"; @@ -85,6 +86,33 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M ); } + if (enabledTools.has("feishu_download_resource")) { + tools.push( + tool( + "feishu_download_resource", + "Download an image or file resource from a Feishu message in the current project's bound chat into the project workspace. Use message_id and file_key returned by feishu_read_context.", + { + message_id: z.string().describe("The Feishu message id containing the resource."), + file_key: z.string().describe("The image_key or file_key from that message's content."), + resource_type: z.enum(["image", "file"]).describe("Use image for image_key and file for file_key."), + }, + async (args) => { + const downloaded = await downloadFeishuMessageResource( + { + messageId: args.message_id, + fileKey: args.file_key, + resourceType: args.resource_type, + }, + { boundChatId: options.chatId, workspaceDir: options.workspaceDir }, + options.rt, + ); + return { content: [{ type: "text", text: JSON.stringify(downloaded) }] }; + }, + { alwaysLoad: true }, + ), + ); + } + if (enabledTools.has("request_approval")) { tools.push( tool( @@ -159,6 +187,11 @@ function mcpInstructions(enabledTools: ReadonlySet): string { if (enabledTools.has("feishu_read_context")) { instructions.push("Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed."); } + if (enabledTools.has("feishu_download_resource")) { + instructions.push( + "When feishu_read_context returns an image_key or file_key whose pixels or bytes are needed, call feishu_download_resource and then read the returned local path.", + ); + } if (enabledTools.has("request_approval")) { instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing."); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 8c9b91f..2048561 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -768,13 +768,14 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { select: { workspaceDir: true }, }); if (project === null) return; - await downloadPostMessageFiles({ + const downloadedResources = await downloadPostMessageFiles({ rt, msg, workspaceDir: project.workspaceDir, imageKeys: parsed.imageKeys, fileKeys: parsed.fileKeys, }); + cleanPrompt = appendDownloadedResourcePaths(cleanPrompt, downloadedResources); } } else if (msg.message_type === "file" || msg.message_type === "image") { // Download the file/image to the workspace, build a prompt around it. @@ -783,16 +784,23 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler { select: { workspaceDir: true }, }); if (project === null) return; + let content: z.infer; try { - const content = FileMessageContentSchema.parse(JSON.parse(msg.content)); - const key = content.file_key ?? content.image_key ?? ""; - if (key !== "") { - const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; - const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`; - await downloadMessageFile(rt, msg.message_id, key, savePath); - cleanPrompt = `用户发来一个文件: ${savePath}`; - } - } catch { return; } + content = FileMessageContentSchema.parse(JSON.parse(msg.content)); + } catch (error) { + rt.logger.warn( + { err: error, messageId: msg.message_id, messageType: msg.message_type }, + "feishu trigger: invalid file message content", + ); + return; + } + const key = content.file_key ?? content.image_key ?? ""; + if (key !== "") { + const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`; + const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`; + await downloadMessageFile(rt, msg.message_id, key, savePath, msg.message_type); + cleanPrompt = `用户发来一个文件: ${savePath}`; + } } if (cleanPrompt === null) return; const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor }; @@ -1282,22 +1290,43 @@ function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +interface DownloadedMessageResource { + readonly resourceType: "image" | "file"; + readonly path: string; +} + async function downloadPostMessageFiles(args: { readonly rt: FeishuRuntime; readonly msg: MessageReceiveEvent["message"]; readonly workspaceDir: string; readonly imageKeys: readonly string[]; readonly fileKeys: readonly string[]; -}): Promise { +}): Promise { const timestamp = Date.now(); + const downloadedResources: DownloadedMessageResource[] = []; for (const [index, imageKey] of args.imageKeys.entries()) { const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`; - await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath); + await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath, "image"); + downloadedResources.push({ resourceType: "image", path: savePath }); } for (const [index, fileKey] of args.fileKeys.entries()) { const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`; - await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath); + await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath, "file"); + downloadedResources.push({ resourceType: "file", path: savePath }); } + return downloadedResources; +} + +function appendDownloadedResourcePaths( + prompt: string, + resources: readonly DownloadedMessageResource[], +): string { + if (resources.length === 0) return prompt; + const paths = resources.map((resource) => { + const label = resource.resourceType === "image" ? "图片" : "文件"; + return `- ${label}: ${resource.path}`; + }); + return `${prompt}\n\n消息附件已下载到项目工作区,可直接读取以下本地文件:\n${paths.join("\n")}`; } /** diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 6afbc90..a5306ed 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest"; @@ -153,6 +153,61 @@ describe("trigger full lifecycle (integration)", () => { expect(run.costSource).toBe("provider_reported"); }); + it("includes downloaded post image paths in the agent prompt", async () => { + const workspaceDir = await tempWorkspaceRoot(); + await seedProject("proj-post-image", "chat-post-image"); + await prisma.project.update({ + where: { id: "proj-post-image" }, + data: { workspaceDir }, + }); + let downloadedPath: string | undefined; + const messageResourceGet = vi.fn(async () => ({ + writeFile: async (filePath: string) => { + downloadedPath = filePath; + await writeFile(filePath, "image bytes"); + }, + })); + const imV1 = (rt.client as unknown as { + im: { v1: { messageResource?: { get: typeof messageResourceGet } } }; + }).im.v1; + imV1.messageResource = { get: messageResourceGet }; + const baseEvent = makeEvent("chat-post-image", "@_user_1 看看这张图"); + const event: MessageReceiveEvent = { + ...baseEvent, + message: { + ...baseEvent.message, + message_type: "post", + content: JSON.stringify({ + zh_cn: { + content: [[ + { tag: "text", text: "@_user_1 看看这张图" }, + { tag: "img", image_key: "img-key-1" }, + ]], + }, + }), + }, + }; + const trigger = makeTriggerHandler({ + prisma, + settings, + logger: silentLogger, + runAgent, + messageBatcherOptions: { maxMessages: 1 }, + }); + + await trigger(event, rt); + + await vi.waitFor(() => { + expect(runAgentCalls).toHaveLength(1); + }); + expect(downloadedPath).toBeDefined(); + expect(runAgentCalls[0]?.prompt).toContain(downloadedPath); + expect(messageResourceGet).toHaveBeenCalledWith({ + params: { type: "image" }, + path: { message_id: event.message.message_id, file_key: "img-key-1" }, + }); + }); + it("sends an onboarding card when an unbound chat mentions the bot", async () => { await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER"); const trigger = makeTriggerHandler({ diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts index fa075ce..8faee93 100644 --- a/hub/test/unit/feishu-client.test.ts +++ b/hub/test/unit/feishu-client.test.ts @@ -1,11 +1,48 @@ import { describe, expect, it, vi } from "vitest"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveSenderName, sendFile, sendLongText, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js"; +import { + downloadMessageFile, + resolveSenderName, + sendFile, + sendLongText, + sendTextMessage, + type FeishuRuntime, +} from "../../src/feishu/client.js"; import { SenderNameCache } from "../../src/feishu/senderCache.js"; describe("Feishu client helpers", () => { + it("downloads an image message resource through the SDK stream helper", async () => { + const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-")); + try { + const destination = join(dir, "image.png"); + const resourceWriteFile = vi.fn(async (filePath: string) => { + await writeFile(filePath, "image bytes"); + }); + const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile })); + const rawRequest = vi.fn(async () => Buffer.from("image bytes")); + const rt = mockRuntime({ + fileCreate: vi.fn(), + messageCreate: vi.fn(), + messageResourceGet, + request: rawRequest, + }); + + await downloadMessageFile(rt, "message-1", "img-key-1", destination, "image"); + + expect(messageResourceGet).toHaveBeenCalledWith({ + params: { type: "image" }, + path: { message_id: "message-1", file_key: "img-key-1" }, + }); + expect(resourceWriteFile).toHaveBeenCalledWith(destination); + await expect(readFile(destination, "utf8")).resolves.toBe("image bytes"); + expect(rawRequest).not.toHaveBeenCalled(); + } finally { + await rm(dir, { 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 { @@ -89,6 +126,7 @@ function mockRuntime(options: { 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 { @@ -104,6 +142,7 @@ function mockRuntime(options: { 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" } })), diff --git a/hub/test/unit/feishu-download.test.ts b/hub/test/unit/feishu-download.test.ts new file mode 100644 index 0000000..a822bb7 --- /dev/null +++ b/hub/test/unit/feishu-download.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +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"; + +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", + ]); + }); + + it("downloads a bound-chat image into the project inbox", async () => { + const workspaceDir = await mkdtemp(join(tmpdir(), "hub-feishu-download-")); + try { + const messageGet = vi.fn(async () => ({ data: { items: [{ chat_id: "chat-1" }] } })); + const resourceWriteFile = vi.fn(async (filePath: string) => { + await writeFile(filePath, "image bytes"); + }); + const messageResourceGet = vi.fn(async () => ({ writeFile: resourceWriteFile })); + const rt = mockRuntime(messageGet, messageResourceGet); + + const result = await downloadFeishuMessageResource( + { messageId: "message-1", fileKey: "img-key-1", resourceType: "image" }, + { boundChatId: "chat-1", workspaceDir }, + rt, + ); + + expect(result).toMatchObject({ resourceType: "image" }); + expect(result.path).toMatch(new RegExp(`^${escapeRegExp(join(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(workspaceDir, { 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", 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, "\\$&"); +}