/** * Feishu @bot trigger → AgentRun lifecycle. * * The core path (ADR-0001..0003, 0017): * 1. A message mentioning the bot arrives in a project group. * 2. Resolve the chat → project binding (ADR-0001). Unknown chat ⇒ ignored. * 3. Create an AgentRun + AgentSession (provider-bound, ADR-0017). * 4. Acquire the project lock for the run (ADR-0002). If locked, reply busy. * 5. Run the agent loop; the agent reads Feishu context on demand through the * ADR-0003-authorized tool (chat must match binding). * 6. On finish/fail/timeout: release the lock, post a status card. * * The agent provider/model come from the ModelRegistry (ADR-0017 role-based * routing, role-as-data). The trigger does not pick a model directly. */ import type { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; import type { AgentProvider } from "../agent/provider.js"; import type { ToolRegistry } from "../agent/tools.js"; import type { ModelRegistry } from "../agent/models.js"; import { runAgent, type ProjectContext } from "../agent/runner.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; import { canTriggerAgent } from "../permission.js"; import { transcriptPath } from "../agent/transcript.js"; interface TriggerDeps { readonly prisma: PrismaClient; readonly provider: AgentProvider; readonly tools: ToolRegistry; readonly models: ModelRegistry; readonly logger: FastifyBaseLogger; } /** * Build the message handler. Returns a function suitable for the lark * `im.message.receive_v1` event. The handler is async and long-running (the * agent run is the long leg); the lock guarantees one run per project at a time. */ export function makeTriggerHandler(deps: TriggerDeps) { return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise => { const msg = event.message; const sender = event.sender; void sender; // sender→user mapping is OPEN (principal sub-typology) // Only react to text messages in group chats. if (msg.message_type !== "text") return; const chatId = msg.chat_id; if (chatId === "") return; // ADR-0001: resolve chat → project. Unknown chat ⇒ not a project group. const binding = await deps.prisma.projectGroupBinding.findUnique({ where: { chatId }, select: { projectId: true }, }); if (binding === null) { deps.logger.debug({ chatId }, "feishu trigger: chat not bound to any project"); return; } const projectId = binding.projectId; // ADR-0004: triggerAgent requires edit on the project. principal=sender // open_id (sub-typology OPEN — pragmatic choice, see permission.ts). const senderOpenId = event.sender.sender_id.open_id ?? ""; if (senderOpenId !== "") { const perm = await canTriggerAgent(deps.prisma, projectId, senderOpenId); if (!perm.allowed) { deps.logger.info({ projectId, senderOpenId, reason: perm.reason }, "feishu trigger: permission denied"); await sendText(rt, chatId, "无权限触发。"); return; } } const prompt = extractPrompt(msg); if (prompt === null) return; // Slash commands: session management, not agent runs. These bypass the // lock — they don't create a run, so they can't conflict with one. if (prompt.startsWith("/")) { const cmd = prompt.split(/\s+/)[0]; switch (cmd) { case "/new": { await deps.prisma.agentSession.updateMany({ where: { projectId, archivedAt: null }, data: { archivedAt: new Date() }, }); await sendText(rt, chatId, "已开新会话,下次 @bot 将从头开始。"); return; } case "/resume": { const latest = await deps.prisma.agentSession.findFirst({ where: { projectId, archivedAt: { not: null } }, orderBy: { archivedAt: "desc" }, select: { id: true }, }); if (latest === null) { await sendText(rt, chatId, "没有可恢复的会话。"); return; } await deps.prisma.agentSession.update({ where: { id: latest.id }, data: { archivedAt: null }, }); await sendText(rt, chatId, "已恢复上一个会话。"); return; } case "/reset": { await deps.prisma.agentSession.updateMany({ where: { projectId, archivedAt: null }, data: { archivedAt: new Date() }, }); await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。"); return; } default: break; } } // ADR-0002: if locked by another run, reply busy. const existing = await currentLockRunId(deps.prisma, projectId); if (existing !== null) { await sendText(rt, chatId, "项目正在处理中,请稍候。"); return; } const project = await deps.prisma.project.findUnique({ where: { id: projectId }, select: { workspaceDir: true }, }); if (project === null) { deps.logger.error({ projectId }, "feishu trigger: project missing"); return; } // ADR-0017: role-as-data; model resolved by registry. "draft" is the // fallback role id; real wiring lets the trigger carry a role hint (slash // command, card selector). The registry degrades unknown roles gracefully. const model = deps.models.resolve(undefined, "draft"); const providerId = deps.provider.id; // ADR-0017: provider+model-bound session. Reuse if one exists for this // (project, provider, model) triple; otherwise create. const existingSession = await deps.prisma.agentSession.findFirst({ where: { projectId, provider: providerId, model, archivedAt: null }, select: { id: true }, }); const session = existingSession !== null ? await deps.prisma.agentSession.update({ where: { id: existingSession.id }, data: { provider: providerId, model, updatedAt: new Date() }, select: { id: true }, }) : await deps.prisma.agentSession.create({ data: { projectId, provider: providerId, model, title: prompt.slice(0, 40) || null, metadata: {}, }, select: { id: true }, }); const run = await deps.prisma.agentRun.create({ data: { projectId, sessionId: session.id, requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology) entrypoint: "FEISHU", status: "ACTIVE", prompt, model, provider: providerId, metadata: {}, }, select: { id: true }, }); // ADR-0002: acquire the project lock for this run. try { await acquireLock(deps.prisma, projectId, run.id, null); } catch { // Race: another run grabbed the lock between our check and create. await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: "FAILED", error: "lock race", finishedAt: new Date() }, }); await sendText(rt, chatId, "项目正在处理中,请稍候。"); return; } await sendText(rt, chatId, `已开始处理(model: ${model})。`); const projectCtx: ProjectContext = { projectId, boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat workspaceDir: project.workspaceDir, }; // Run the agent — fire-and-forget from the ws handler's perspective. runAgent(deps.provider, deps.tools, { prompt, model, project: projectCtx, transcriptPath: transcriptPath(project.workspaceDir, session.id) }) .then(async (result) => { await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED", inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens, error: result.error ?? null, finishedAt: new Date(), }, }); await sendCard(rt, chatId, buildRunStatusCard({ status: statusReply(result.status), model, models: deps.models.listModels(), runId: run.id, })); }) .catch(async (e) => { await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: "FAILED", error: String(e), finishedAt: new Date() }, }); await sendCard(rt, chatId, buildRunStatusCard({ status: "处理出错", model, models: deps.models.listModels(), runId: run.id, })); }) .finally(async () => { await releaseLock(deps.prisma, run.id); }); }; } function statusReply(status: "completed" | "length" | "failed"): string { switch (status) { case "completed": return "处理完成。"; case "length": return "处理达到步数上限,已停止。"; case "failed": return "处理出错。"; } } /** * Extract the prompt text from a text message, stripping @mentions. * Returns null if the message is empty after stripping or doesn't mention the bot. * Lark text content is JSON: `{"text":"@_user_1 do something"}`. */ export function extractPrompt(msg: MessageReceiveEvent["message"]): string | null { // Check for bot mention — mentions[].id.open_id should include the bot. // The dispatcher already routes only message events; we further require // that the bot is among the mentions (if no mentions, it's not @bot). const mentions = msg.mentions; if (mentions === undefined || mentions.length === 0) return null; try { const parsed = JSON.parse(msg.content) as { text?: string }; const text = parsed.text; if (typeof text !== "string") return null; // Strip @mentions (@_user_1 etc.) and trim; remainder is the prompt. const stripped = text.replace(/@_\w+\s*/g, "").trim(); return stripped === "" ? null : stripped; } catch { return null; } }