forked from EduCraft/curriculum-project-hub
fix: let agents download Feishu message resources
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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<unknown>;
|
||||
readonly messageReply?: (payload: unknown) => Promise<unknown>;
|
||||
readonly contactBasicBatch?: (payload: unknown) => Promise<unknown>;
|
||||
readonly messageResourceGet?: (payload: unknown) => Promise<unknown>;
|
||||
readonly request?: (payload: unknown) => Promise<unknown>;
|
||||
}): 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" } })),
|
||||
|
||||
@@ -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