forked from EduCraft/curriculum-project-hub
fix: let agents download Feishu message resources
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
export const DEFAULT_CLAUDE_BUILT_IN_TOOLS = ["Read", "Write", "Bash", "Glob", "Grep"] as const;
|
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_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];
|
export type CphHubMcpToolId = (typeof CPH_HUB_MCP_TOOL_IDS)[number];
|
||||||
|
|
||||||
@@ -31,9 +36,11 @@ const ROLE_TOOL_TO_CLAUDE_BUILT_INS = new Map<string, readonly string[]>([
|
|||||||
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
const ROLE_TOOL_TO_CPH_HUB_MCP_TOOL = new Map<string, CphHubMcpToolId>([
|
||||||
["send_file", "send_file"],
|
["send_file", "send_file"],
|
||||||
["feishu_read_context", "feishu_read_context"],
|
["feishu_read_context", "feishu_read_context"],
|
||||||
|
["feishu_download_resource", "feishu_download_resource"],
|
||||||
["request_approval", "request_approval"],
|
["request_approval", "request_approval"],
|
||||||
["mcp__cph_hub__send_file", "send_file"],
|
["mcp__cph_hub__send_file", "send_file"],
|
||||||
["mcp__cph_hub__feishu_read_context", "feishu_read_context"],
|
["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"],
|
["mcp__cph_hub__request_approval", "request_approval"],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const TOOL_ICONS: Record<string, string> = {
|
|||||||
send_file: "send-filled",
|
send_file: "send-filled",
|
||||||
request_approval: "thumb-up-filled",
|
request_approval: "thumb-up-filled",
|
||||||
feishu_read_context: "search-filled",
|
feishu_read_context: "search-filled",
|
||||||
|
feishu_download_resource: "download-filled",
|
||||||
};
|
};
|
||||||
|
|
||||||
function toolIcon(toolName: string): string {
|
function toolIcon(toolName: string): string {
|
||||||
|
|||||||
+10
-10
@@ -6,7 +6,7 @@
|
|||||||
* reactions, file download/upload, and the card action callback.
|
* reactions, file download/upload, and the card action callback.
|
||||||
*/
|
*/
|
||||||
import * as lark from "@larksuiteoapi/node-sdk";
|
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 { dirname } from "node:path";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js";
|
||||||
@@ -487,20 +487,20 @@ export async function downloadMessageFile(
|
|||||||
messageId: string,
|
messageId: string,
|
||||||
fileKey: string,
|
fileKey: string,
|
||||||
savePath: string,
|
savePath: string,
|
||||||
|
resourceType: "image" | "file",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await mkdir(dirname(savePath), { recursive: true });
|
await mkdir(dirname(savePath), { recursive: true });
|
||||||
try {
|
try {
|
||||||
const res = await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
return rt.client.request({
|
const resource = await rt.client.im.v1.messageResource.get({
|
||||||
method: "GET",
|
params: { type: resourceType },
|
||||||
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
|
path: { message_id: messageId, file_key: fileKey },
|
||||||
params: { type: "file" },
|
});
|
||||||
responseType: "arraybuffer",
|
await resource.writeFile(savePath);
|
||||||
}) as Promise<{ data: Buffer }>;
|
|
||||||
});
|
});
|
||||||
await writeFile(savePath, res.data);
|
|
||||||
} catch (e) {
|
} 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 { z } from "zod";
|
||||||
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
import { sendApprovalCard, sendFile, type FeishuRuntime, type SendMessageOptions } from "./client.js";
|
||||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||||
|
import { downloadFeishuMessageResource } from "./download.js";
|
||||||
import { readFeishuContext } from "./read.js";
|
import { readFeishuContext } from "./read.js";
|
||||||
import type { ApprovalManager } from "./approval.js";
|
import type { ApprovalManager } from "./approval.js";
|
||||||
import { CPH_HUB_MCP_TOOL_IDS, type CphHubMcpToolId } from "../agent/roleTools.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")) {
|
if (enabledTools.has("request_approval")) {
|
||||||
tools.push(
|
tools.push(
|
||||||
tool(
|
tool(
|
||||||
@@ -159,6 +187,11 @@ function mcpInstructions(enabledTools: ReadonlySet<CphHubMcpToolId>): string {
|
|||||||
if (enabledTools.has("feishu_read_context")) {
|
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.");
|
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")) {
|
if (enabledTools.has("request_approval")) {
|
||||||
instructions.push("Use request_approval when explicit human approval or confirmation is required before continuing.");
|
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 },
|
select: { workspaceDir: true },
|
||||||
});
|
});
|
||||||
if (project === null) return;
|
if (project === null) return;
|
||||||
await downloadPostMessageFiles({
|
const downloadedResources = await downloadPostMessageFiles({
|
||||||
rt,
|
rt,
|
||||||
msg,
|
msg,
|
||||||
workspaceDir: project.workspaceDir,
|
workspaceDir: project.workspaceDir,
|
||||||
imageKeys: parsed.imageKeys,
|
imageKeys: parsed.imageKeys,
|
||||||
fileKeys: parsed.fileKeys,
|
fileKeys: parsed.fileKeys,
|
||||||
});
|
});
|
||||||
|
cleanPrompt = appendDownloadedResourcePaths(cleanPrompt, downloadedResources);
|
||||||
}
|
}
|
||||||
} else if (msg.message_type === "file" || msg.message_type === "image") {
|
} else if (msg.message_type === "file" || msg.message_type === "image") {
|
||||||
// Download the file/image to the workspace, build a prompt around it.
|
// 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 },
|
select: { workspaceDir: true },
|
||||||
});
|
});
|
||||||
if (project === null) return;
|
if (project === null) return;
|
||||||
|
let content: z.infer<typeof FileMessageContentSchema>;
|
||||||
try {
|
try {
|
||||||
const content = FileMessageContentSchema.parse(JSON.parse(msg.content));
|
content = FileMessageContentSchema.parse(JSON.parse(msg.content));
|
||||||
const key = content.file_key ?? content.image_key ?? "";
|
} catch (error) {
|
||||||
if (key !== "") {
|
rt.logger.warn(
|
||||||
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
{ err: error, messageId: msg.message_id, messageType: msg.message_type },
|
||||||
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`;
|
"feishu trigger: invalid file message content",
|
||||||
await downloadMessageFile(rt, msg.message_id, key, savePath);
|
);
|
||||||
cleanPrompt = `用户发来一个文件: ${savePath}`;
|
return;
|
||||||
}
|
}
|
||||||
} catch { 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;
|
if (cleanPrompt === null) return;
|
||||||
const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor };
|
const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor };
|
||||||
@@ -1282,22 +1290,43 @@ function escapeRegExp(value: string): string {
|
|||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DownloadedMessageResource {
|
||||||
|
readonly resourceType: "image" | "file";
|
||||||
|
readonly path: string;
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadPostMessageFiles(args: {
|
async function downloadPostMessageFiles(args: {
|
||||||
readonly rt: FeishuRuntime;
|
readonly rt: FeishuRuntime;
|
||||||
readonly msg: MessageReceiveEvent["message"];
|
readonly msg: MessageReceiveEvent["message"];
|
||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
readonly imageKeys: readonly string[];
|
readonly imageKeys: readonly string[];
|
||||||
readonly fileKeys: readonly string[];
|
readonly fileKeys: readonly string[];
|
||||||
}): Promise<void> {
|
}): Promise<readonly DownloadedMessageResource[]> {
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
const downloadedResources: DownloadedMessageResource[] = [];
|
||||||
for (const [index, imageKey] of args.imageKeys.entries()) {
|
for (const [index, imageKey] of args.imageKeys.entries()) {
|
||||||
const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`;
|
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()) {
|
for (const [index, fileKey] of args.fileKeys.entries()) {
|
||||||
const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`;
|
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")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
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");
|
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 () => {
|
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
|
||||||
await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER");
|
await seedOnboardingUser("u-onboard-card", "ou_onboard_card", "MEMBER");
|
||||||
const trigger = makeTriggerHandler({
|
const trigger = makeTriggerHandler({
|
||||||
|
|||||||
@@ -1,11 +1,48 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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";
|
import { SenderNameCache } from "../../src/feishu/senderCache.js";
|
||||||
|
|
||||||
describe("Feishu client helpers", () => {
|
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 () => {
|
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
|
||||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +126,7 @@ function mockRuntime(options: {
|
|||||||
readonly messageCreate: (payload: unknown) => Promise<unknown>;
|
readonly messageCreate: (payload: unknown) => Promise<unknown>;
|
||||||
readonly messageReply?: (payload: unknown) => Promise<unknown>;
|
readonly messageReply?: (payload: unknown) => Promise<unknown>;
|
||||||
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
|
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
|
||||||
|
readonly messageResourceGet?: (payload: unknown) => Promise<unknown>;
|
||||||
readonly request?: (payload: unknown) => Promise<unknown>;
|
readonly request?: (payload: unknown) => Promise<unknown>;
|
||||||
}): FeishuRuntime {
|
}): FeishuRuntime {
|
||||||
return {
|
return {
|
||||||
@@ -104,6 +142,7 @@ function mockRuntime(options: {
|
|||||||
im: {
|
im: {
|
||||||
v1: {
|
v1: {
|
||||||
file: { create: options.fileCreate },
|
file: { create: options.fileCreate },
|
||||||
|
messageResource: { get: options.messageResourceGet },
|
||||||
message: {
|
message: {
|
||||||
create: options.messageCreate,
|
create: options.messageCreate,
|
||||||
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
|
reply: options.messageReply ?? vi.fn(async () => ({ data: { message_id: "reply-1" } })),
|
||||||
|
|||||||
@@ -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<unknown>,
|
||||||
|
messageResourceGet: (payload: unknown) => Promise<unknown>,
|
||||||
|
): 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, "\\$&");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user