fix(hub): 保留飞书回复上下文

This commit is contained in:
2026-07-08 16:03:52 +08:00
parent a025d23af8
commit a00e4f9871
7 changed files with 311 additions and 10 deletions
+8
View File
@@ -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;
+27 -1
View File
@@ -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 },
),
],
});
}
+9 -3
View File
@@ -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<string, unknown> {
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,
};
}
+105 -6
View File
@@ -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<Prisma.InputJsonObject | null> {
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<string, Prisma.InputJsonValue> = {
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<string, Prisma.InputJsonValue> = { roleId };
if (feishuTriggerContext !== null) {
metadata["rawPrompt"] = rawPrompt;
metadata["feishuTriggerContext"] = feishuTriggerContext;
}
return metadata as Prisma.InputJsonObject;
}
function auditDecision(decision: AuthorizationDecision): Record<string, unknown> {
return {
allowed: decision.allowed,