diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index b670590..6ecd1df 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -23,9 +23,17 @@ export interface FeishuRuntime { /** Raw `im.message.receive_v1` event payload. */ export interface MessageReceiveEvent { + /** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */ + readonly event_id?: string; + readonly event_type?: string; readonly header?: { readonly event_id?: string; readonly event_type?: string }; readonly message: { readonly message_id: string; + readonly root_id?: string; + readonly parent_id?: string; + readonly thread_id?: string; + readonly create_time?: string; + readonly update_time?: string; readonly chat_id: string; readonly chat_type: string; readonly message_type: string; diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts index 5ecde7a..54b7ec1 100644 --- a/hub/src/feishu/fileDeliveryTool.ts +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -2,10 +2,13 @@ import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@ import { z } from "zod"; import { sendFile, type FeishuRuntime } from "./client.js"; import { resolveDeliverableFile } from "./fileDelivery.js"; +import { readFeishuContext } from "./read.js"; export interface FileDeliveryToolOptions { readonly rt: FeishuRuntime; readonly chatId: string; + readonly projectId: string; + readonly runId: string; readonly workspaceDir: string; readonly onDelivered?: (path: string) => void; } @@ -18,7 +21,8 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M instructions: "Use send_file when the user asks to receive, resend, download, or attach a file. " + "Do not claim a file was sent unless send_file returns success. " + - "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.", + "If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path. " + + "Use feishu_read_context when the trigger context contains a reply, thread, or message anchor and more Feishu context is needed.", tools: [ tool( "send_file", @@ -51,6 +55,28 @@ export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): M }, { alwaysLoad: true }, ), + tool( + "feishu_read_context", + "Read Feishu context for the current project's bound chat. Use anchor ids from the trigger context; this tool never reads arbitrary chats.", + { + anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of Feishu anchor to read."), + id: z.string().describe("The message/thread/status anchor id to read."), + }, + async (args) => { + const result = await readFeishuContext( + { chat_id: options.chatId, anchor: args.anchor, id: args.id }, + { + runId: options.runId, + projectId: options.projectId, + boundChatId: options.chatId, + workspaceDir: options.workspaceDir, + }, + options.rt, + ); + return { content: [{ type: "text", text: result }] }; + }, + { alwaysLoad: true }, + ), ], }); } diff --git a/hub/src/feishu/read.ts b/hub/src/feishu/read.ts index d035aa4..a4a732e 100644 --- a/hub/src/feishu/read.ts +++ b/hub/src/feishu/read.ts @@ -24,11 +24,14 @@ interface LarkMessage { message_id?: string; msg_type?: string; content?: string; + body?: { content?: string }; create_time?: string; sender?: { id?: string; sender_type?: string }; chat_id?: string; + parent_id?: string; parent_message_id?: string; root_id?: string; + thread_id?: string; } interface ImV1Message { @@ -84,14 +87,17 @@ export async function readFeishuContext( /** Project a lark message down to the fields the model actually needs. */ function compact(m: LarkMessage): Record { + const parentId = m.parent_id ?? m.parent_message_id; return { message_id: m.message_id, msg_type: m.msg_type, - content: m.content, + content: m.body?.content ?? m.content, create_time: m.create_time, + chat_id: m.chat_id, sender_id: m.sender?.id, - parent_message_id: m.parent_message_id, + parent_id: parentId, + parent_message_id: parentId, root_id: m.root_id, + thread_id: m.thread_id, }; } - diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 01a13a9..0c323d0 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -19,6 +19,7 @@ import { createPermissionAuthorizer, type AuthorizationDecision, type Permission import { writeAudit } from "../audit.js"; import { PatchableTextStream } from "./textStream.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; +import { readFeishuContext } from "./read.js"; interface TriggerDeps { readonly prisma: PrismaClient; @@ -45,7 +46,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { // would spawn a second AgentRun — the lock would refuse it, but a FAILED // run row + a busy reply would leak. Record the event_id up front; if it // already exists, this is a redelivery → stop. - const eventId = event.header?.event_id; + const eventId = feishuEventId(event); if (eventId !== undefined && eventId !== "") { const existing = await deps.prisma.feishuEventReceipt.findUnique({ where: { eventId }, @@ -58,7 +59,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { await deps.prisma.feishuEventReceipt.create({ data: { eventId, - eventType: event.header?.event_type ?? "im.message.receive_v1", + eventType: feishuEventType(event), messageId: msg.message_id, }, }); @@ -227,6 +228,14 @@ export function makeTriggerHandler(deps: TriggerDeps) { // is enforced inside runAgent when it builds the SDK tools record. `tools: // undefined` means the full registered set (unrestricted role). const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt); + const feishuTriggerContext = await buildFeishuTriggerContext({ + msg, + chatId, + projectId, + workspaceDir: project.workspaceDir, + rt, + }); + const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); // ADR-0017: provider+model-bound session. Reuse if one exists for this // (project, provider, model) triple; otherwise create. @@ -260,14 +269,24 @@ export function makeTriggerHandler(deps: TriggerDeps) { requestedByUserId: roleDecision.actorUserId ?? null, entrypoint: "FEISHU", status: "ACTIVE", - prompt: agentPrompt, + prompt: promptForAgent, model, provider: providerId, - metadata: { roleId }, + metadata: agentRunMetadata(roleId, agentPrompt, feishuTriggerContext), }, select: { id: true }, }); - await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: agentPrompt.slice(0, 200) } }); + await writeAudit(deps.prisma, { + runId: run.id, + projectId, + action: "run.created", + metadata: { + roleId, + model, + prompt: agentPrompt.slice(0, 200), + ...(feishuTriggerContext === null ? {} : { feishuTriggerContext }), + }, + }); // ADR-0002: acquire the project lock for this run. try { @@ -303,6 +322,8 @@ export function makeTriggerHandler(deps: TriggerDeps) { const fileDeliveryMcpServer = createFileDeliveryMcpServer({ rt, chatId, + projectId, + runId: run.id, workspaceDir: project.workspaceDir, onDelivered: (path) => { deliveredFiles.push(path); @@ -310,7 +331,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { }); runAgent({ - prompt: agentPrompt, + prompt: promptForAgent, model, project: projectCtx, systemPrompt, @@ -417,6 +438,84 @@ function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma return base; } +function feishuEventId(event: MessageReceiveEvent): string | undefined { + return nonEmpty(event.event_id) ?? nonEmpty(event.header?.event_id); +} + +function feishuEventType(event: MessageReceiveEvent): string { + return nonEmpty(event.event_type) ?? nonEmpty(event.header?.event_type) ?? "im.message.receive_v1"; +} + +function nonEmpty(value: string | undefined): string | undefined { + return value === undefined || value === "" ? undefined : value; +} + +interface BuildFeishuTriggerContextArgs { + readonly msg: MessageReceiveEvent["message"]; + readonly chatId: string; + readonly projectId: string; + readonly workspaceDir: string; + readonly rt: FeishuRuntime; +} + +async function buildFeishuTriggerContext(args: BuildFeishuTriggerContextArgs): Promise { + const replyToMessageId = nonEmpty(args.msg.parent_id); + const rootId = nonEmpty(args.msg.root_id); + const threadId = nonEmpty(args.msg.thread_id); + if (replyToMessageId === undefined && rootId === undefined && threadId === undefined) { + return null; + } + + const context: Record = { + trigger_message_id: args.msg.message_id, + }; + if (replyToMessageId !== undefined) context["reply_to_message_id"] = replyToMessageId; + if (rootId !== undefined) context["root_id"] = rootId; + if (threadId !== undefined) context["thread_id"] = threadId; + + if (replyToMessageId !== undefined) { + const rawReplyContext = await readFeishuContext( + { chat_id: args.chatId, anchor: "reply", id: replyToMessageId }, + { + runId: "pending", + projectId: args.projectId, + boundChatId: args.chatId, + workspaceDir: args.workspaceDir, + }, + args.rt, + ); + context["replied_to_message"] = parseJsonForMetadata(rawReplyContext); + } + + return context as Prisma.InputJsonObject; +} + +function parseJsonForMetadata(text: string): Prisma.InputJsonValue { + try { + return JSON.parse(text) as Prisma.InputJsonValue; + } catch { + return { error: "invalid json", raw: text }; + } +} + +function withFeishuTriggerContext(prompt: string, context: Prisma.InputJsonObject | null): string { + if (context === null) return prompt; + return `Feishu trigger context:\n${JSON.stringify(context, null, 2)}\n\nUser request:\n${prompt}`; +} + +function agentRunMetadata( + roleId: string, + rawPrompt: string, + feishuTriggerContext: Prisma.InputJsonObject | null, +): Prisma.InputJsonObject { + const metadata: Record = { roleId }; + if (feishuTriggerContext !== null) { + metadata["rawPrompt"] = rawPrompt; + metadata["feishuTriggerContext"] = feishuTriggerContext; + } + return metadata as Prisma.InputJsonObject; +} + function auditDecision(decision: AuthorizationDecision): Record { return { allowed: decision.allowed, diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index 3a18816..7079d10 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -63,6 +63,21 @@ export interface MockFeishuRuntime extends FeishuRuntime { readonly sentCards: unknown[]; readonly sentPatches: unknown[]; readonly reactions: Array<{ readonly messageId: string; readonly emoji: string }>; + readonly readableMessages: Map; + readonly threadMessages: Map; +} + +export interface MockFeishuMessage { + readonly message_id?: string; + readonly root_id?: string; + readonly parent_id?: string; + readonly thread_id?: string; + readonly msg_type?: string; + readonly create_time?: string; + readonly chat_id?: string; + readonly sender?: { readonly id?: string; readonly sender_type?: string }; + readonly body?: { readonly content?: string }; + readonly content?: string; } export function mockFeishuRuntime(): MockFeishuRuntime { @@ -70,6 +85,8 @@ export function mockFeishuRuntime(): MockFeishuRuntime { const sentCards: unknown[] = []; const sentPatches: unknown[] = []; const reactions: Array<{ messageId: string; emoji: string }> = []; + const readableMessages = new Map(); + const threadMessages = new Map(); // Mock the client — sendText/sendCard are in client.ts, not on the runtime // object directly. We patch by providing a runtime whose client is a stub; // the actual send functions cast through shape, so a minimal stub works. @@ -114,6 +131,20 @@ export function mockFeishuRuntime(): MockFeishuRuntime { if (text !== null) sentTexts.push(text); return {}; }, + get: async (p: unknown) => { + const payload = p as { path?: { message_id?: string } }; + const messageId = payload.path?.message_id ?? ""; + const message = readableMessages.get(messageId); + return { data: { items: message === undefined ? [] : [message] } }; + }, + list: async (p: unknown) => { + const payload = p as { params?: { container_id?: string } }; + const containerId = payload.params?.container_id ?? ""; + const items = + threadMessages.get(containerId) ?? + [...readableMessages.values()].filter((message) => message.parent_id === containerId); + return { data: { items } }; + }, }, }, }, @@ -123,6 +154,8 @@ export function mockFeishuRuntime(): MockFeishuRuntime { sentCards, sentPatches, reactions, + readableMessages, + threadMessages, }; return rt; } diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 23b1d09..fe6f6ee 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -336,6 +336,86 @@ describe("trigger full lifecycle (integration)", () => { expect(receipts[0]?.eventId).toBe("evt-dedup-1"); }); + it("dedups flattened websocket events by top-level event_id", async () => { + await seedProject("proj-13b", "chat-13b"); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + const event: MessageReceiveEvent = { + ...makeEvent("chat-13b", "@_user_1 写教案"), + event_id: "evt-flat-1", + event_type: "im.message.receive_v1", + }; + + await trigger(event, rt); + await vi.waitFor(async () => { + expect(await prisma.agentRun.findMany()).toHaveLength(1); + }); + + await trigger( + { + ...event, + message: { ...event.message, message_id: "m_flat_redelivery" }, + }, + rt, + ); + + const runs = await prisma.agentRun.findMany(); + expect(runs).toHaveLength(1); + const receipts = await prisma.feishuEventReceipt.findMany(); + expect(receipts).toHaveLength(1); + expect(receipts[0]?.eventId).toBe("evt-flat-1"); + }); + + it("seeds replied-to message context when @bot is used in a Feishu reply", async () => { + await seedProject("proj-13c", "chat-13c"); + rt.readableMessages.set("m-parent", { + message_id: "m-parent", + root_id: "m-root", + thread_id: "thread-1", + msg_type: "text", + chat_id: "chat-13c", + sender: { id: "ou_teacher_2", sender_type: "user" }, + body: { content: JSON.stringify({ text: "上一条需求: 把第二题改成探究题。" }) }, + }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); + const baseEvent = makeEvent("chat-13c", "@_user_1 继续这个改法"); + const event: MessageReceiveEvent = { + ...baseEvent, + message: { + ...baseEvent.message, + message_id: "m-child", + root_id: "m-root", + parent_id: "m-parent", + thread_id: "thread-1", + }, + }; + + await trigger(event, rt); + + await vi.waitFor(() => { + expect(runAgentCalls).toHaveLength(1); + }); + const prompt = runAgentCalls[0]?.prompt ?? ""; + expect(prompt).toContain("Feishu trigger context"); + expect(prompt).toContain("reply_to_message_id"); + expect(prompt).toContain("m-parent"); + expect(prompt).toContain("thread-1"); + expect(prompt).toContain("上一条需求"); + expect(prompt).toContain("User request:\n继续这个改法"); + + const run = await prisma.agentRun.findFirst(); + expect(run?.prompt).toBe(prompt); + expect(run?.metadata).toMatchObject({ + roleId: "draft", + rawPrompt: "继续这个改法", + feishuTriggerContext: { + trigger_message_id: "m-child", + reply_to_message_id: "m-parent", + root_id: "m-root", + thread_id: "thread-1", + }, + }); + }); + it("writes audit entries across the run lifecycle", async () => { await seedProject("proj-14", "chat-14"); const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); diff --git a/hub/test/unit/feishu-read.test.ts b/hub/test/unit/feishu-read.test.ts new file mode 100644 index 0000000..43fc302 --- /dev/null +++ b/hub/test/unit/feishu-read.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { readFeishuContext } from "../../src/feishu/read.js"; +import type { FeishuRuntime } from "../../src/feishu/client.js"; + +describe("readFeishuContext", () => { + it("compacts Feishu message.get replies with parent_id, thread_id, and body.content", async () => { + const rt = { + client: { + im: { + v1: { + message: { + get: async () => ({ + data: { + items: [ + { + message_id: "m-child", + root_id: "m-root", + parent_id: "m-parent", + thread_id: "thread-1", + msg_type: "text", + body: { content: JSON.stringify({ text: "parent text" }) }, + }, + ], + }, + }), + list: async () => ({ data: { items: [] } }), + }, + }, + }, + }, + logger: { warn() {}, info() {}, error() {}, debug() {} }, + } as unknown as FeishuRuntime; + + const raw = await readFeishuContext( + { chat_id: "chat-1", anchor: "reply", id: "m-child" }, + { runId: "run-1", projectId: "proj-1", boundChatId: "chat-1", workspaceDir: "/tmp/proj-1" }, + rt, + ); + + expect(JSON.parse(raw)).toMatchObject({ + message_id: "m-child", + content: JSON.stringify({ text: "parent text" }), + parent_id: "m-parent", + parent_message_id: "m-parent", + root_id: "m-root", + thread_id: "thread-1", + }); + }); +});