forked from bai/curriculum-project-hub
fix: let agents download Feishu message resources
This commit is contained in:
@@ -41,6 +41,7 @@ const TOOL_ICONS: Record<string, string> = {
|
||||
send_file: "send-filled",
|
||||
request_approval: "thumb-up-filled",
|
||||
feishu_read_context: "search-filled",
|
||||
feishu_download_resource: "download-filled",
|
||||
};
|
||||
|
||||
function toolIcon(toolName: string): string {
|
||||
|
||||
+10
-10
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ToolContext, "boundChatId" | "workspaceDir">;
|
||||
|
||||
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<DownloadedFeishuMessageResource> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<CphHubMcpToolId>): 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.");
|
||||
}
|
||||
|
||||
+42
-13
@@ -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<typeof FileMessageContentSchema>;
|
||||
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<void> {
|
||||
}): Promise<readonly DownloadedMessageResource[]> {
|
||||
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")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user