/** * Sends a "processing" reaction immediately, then streams a single * interactive card through the full agent run lifecycle: thinking → tool * calls (with trace panel) → streaming answer text → final card. The card * shows a collapsible tool-use panel, a collapsible reasoning panel, and * the markdown answer text. Throttled to ~2.5 patches/sec to avoid * spamming the Feishu API. * * ADR-0001..0003, 0017: chat→project binding, permission gate, lock, * provider-bound session, SDK resume continuity, ADR-0003-authorized reads. */ import type { Prisma, PrismaClient } from "@prisma/client"; import { z } from "zod"; import type { FastifyBaseLogger } from "fastify"; import { sendText, sendTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, parsePostMessage, resolveCard, resolveSenderName, type CardActionEvent, type FeishuRuntime, type MessageReceiveEvent, type SendMessageOptions, } from "./client.js"; import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js"; import type { RuntimeSettings } from "../settings/runtime.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; import { createPermissionAuthorizer, type AuthorizationDecision, type PermissionAuthorizer } from "../permission.js"; import { writeAudit } from "../audit.js"; import { StreamingAgentCard } from "./card/streaming-card.js"; import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js"; import { readFeishuContext } from "./read.js"; import { MessageBatcher, messageBatchKey, type MessageBatcherOptions } from "./messageBatcher.js"; import { ApprovalManager } from "./approval.js"; import { SenderNameCache } from "./senderCache.js"; import { TriggerQueue, triggerQueue as defaultTriggerQueue, type QueuedTrigger } from "./triggerQueue.js"; import { createSlashCommandRegistry, formatSlashHelpTarget, parseSlashHelpSubcommand, parseSlashInvocation } from "./slashCommands.js"; export { ApprovalManager } from "./approval.js"; export type { ApprovalResult, PendingApproval } from "./approval.js"; export { TriggerQueue, triggerQueue } from "./triggerQueue.js"; export type { QueuedTrigger, TriggerQueueOptions } from "./triggerQueue.js"; export const senderNameCache = new SenderNameCache(); interface TriggerDeps { readonly prisma: PrismaClient; readonly settings: RuntimeSettings; readonly logger: FastifyBaseLogger; readonly runAgent?: (req: RunRequest) => Promise; readonly authorizer?: PermissionAuthorizer | undefined; readonly messageBatcherOptions?: MessageBatcherOptions | undefined; readonly triggerQueue?: TriggerQueue | undefined; } interface TriggerActor { readonly feishuOpenId: string; readonly chatId: string; } interface TriggerRunContext { readonly msg: MessageReceiveEvent["message"]; readonly rt: FeishuRuntime; readonly chatId: string; readonly projectId: string; readonly senderOpenId: string; readonly actor: TriggerActor; } type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped"; export interface TriggerHandler { (event: MessageReceiveEvent, rt: FeishuRuntime): Promise; readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise; readonly approvalManager: ApprovalManager; } /** * 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): TriggerHandler { const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma); const runAgent = deps.runAgent ?? defaultRunAgent; const approvalManager = new ApprovalManager(); const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue; const slashCommands = createSlashCommandRegistry({ prisma: deps.prisma, settings: deps.settings, logger: deps.logger, triggerQueue, }); const batchContexts = new Map(); // runId → AbortController for live runs. Registered when a run starts, // used by the interrupt card action to abort the SDK query, cleared in the // run's finally. Absence means the run already terminated (or never started). const activeRuns = new Map(); const messageBatcher = new MessageBatcher(async (mergedText, key) => { if (key === undefined) { deps.logger.warn("feishu trigger: message batch flushed without a key"); return; } const context = batchContexts.get(key); if (context === undefined) { deps.logger.warn({ key }, "feishu trigger: message batch flushed without trigger context"); return; } batchContexts.delete(key); try { await startAgentRun(context, mergedText); } catch (e) { deps.logger.error({ key, err: e instanceof Error ? e.message : String(e) }, "feishu trigger: batched run setup failed"); throw e; } }, deps.messageBatcherOptions); async function startAgentRun( context: TriggerRunContext, cleanPrompt: string, options: { readonly queueIfLocked?: boolean } = {}, ): Promise { const { msg, rt, chatId, projectId, senderOpenId, actor } = context; const sendOptions = sendOptionsForTriggerMessage(msg); const queueIfLocked = options.queueIfLocked ?? true; // ADR-0002: if locked by another run, enqueue the trigger for this project. const existing = await currentLockRunId(deps.prisma, projectId); if (existing !== null) { if (!queueIfLocked) return "locked"; return enqueueLockedTrigger(context, cleanPrompt); } 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 "skipped"; } // ADR-0017: role-as-data. Parse a leading `/` command; unknown // slashes are left as literal text (extractRole returns null). Falls back // to "draft" when no role is named — the registry degrades gracefully. const models = await deps.settings.modelRegistry({ projectId }); const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models); const roleId = parsedRole ?? "draft"; // ADR-0019: role trigger is the second gate after project agent.trigger. // Unconfigured roles remain open for back-compat; configured roles require // a matching active RoleTriggerGrant for any resolved principal. const roleDecision = await authorizer.can({ actor, action: "role.trigger", resource: { type: "PROJECT", id: projectId }, roleId, }); if (!roleDecision.allowed) { deps.logger.info({ projectId, roleId, senderOpenId, reason: roleDecision.reason }, "feishu trigger: role permission denied"); await writeAudit(deps.prisma, { projectId, ...(roleDecision.actorUserId !== undefined ? { actorUserId: roleDecision.actorUserId } : {}), action: "trigger.role_denied", metadata: { roleId, decision: auditDecision(roleDecision), sender: await senderAuditMetadata(rt, senderOpenId), }, }); await sendText(rt, chatId, `无权限使用角色 ${roleId}。`, sendOptions); return "skipped"; } const role = models.role(roleId); const model = models.resolve(undefined, roleId); const provider = await deps.settings.provider("openrouter", { projectId }); const runPolicy = await deps.settings.runPolicy({ projectId, roleId }); const providerId = provider.id; // Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist // 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, senderOpenId, }); const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext); // ADR-0017: provider+role+model-bound session. Reuse if one exists for // this (project, provider, role, model) tuple; otherwise create. Role is // part of the key because role prompts/tool surfaces can differ even when // the underlying model is the same. const existingSession = await deps.prisma.agentSession.findFirst({ where: { projectId, provider: providerId, roleId, model, archivedAt: null }, select: { id: true, metadata: true }, }); const session = existingSession !== null ? await deps.prisma.agentSession.update({ where: { id: existingSession.id }, data: { provider: providerId, roleId, model, updatedAt: new Date() }, select: { id: true, metadata: true }, }) : await deps.prisma.agentSession.create({ data: { projectId, provider: providerId, roleId, model, title: agentPrompt.slice(0, 40) || null, metadata: {}, }, select: { id: true, metadata: true }, }); const sessionMetadata = readSessionMetadata(session.metadata); const run = await deps.prisma.agentRun.create({ data: { projectId, sessionId: session.id, requestedByUserId: roleDecision.actorUserId ?? null, entrypoint: "FEISHU", status: "ACTIVE", prompt: promptForAgent, model, provider: providerId, 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), sender: await senderAuditMetadata(rt, senderOpenId), feishuTriggerContext, }, }); // ADR-0002: acquire the project lock for this run. try { await acquireLock(deps.prisma, projectId, run.id, null); } catch { await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: "FAILED", error: "lock race", finishedAt: new Date() }, }); await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.lock_race", metadata: { sender: await senderAuditMetadata(rt, senderOpenId) }, }); if (!queueIfLocked) return "locked"; return enqueueLockedTrigger(context, cleanPrompt); } const projectCtx: ProjectContext = { projectId, boundChatId: chatId, // ADR-0003: Feishu reads stay in this chat workspaceDir: project.workspaceDir, }; const processingReactionId = await addReaction(rt, msg.message_id, "Typing"); const removeProcessingReaction = async (): Promise => { if (processingReactionId === null) return false; return removeReaction(rt, msg.message_id, processingReactionId); }; // Streaming agent card: single interactive card through the full run // lifecycle (thinking → tool calls → streaming text → complete). // Shows tool-use trace panel + reasoning panel + answer text. const card = new StreamingAgentCard({ runId: run.id, rt, chatId, sendOptions, patchIntervalMs: undefined, maxMessageLength: undefined, }); const deliveredFiles: string[] = []; const fileDeliveryMcpServer = createFileDeliveryMcpServer({ rt, chatId, projectId, runId: run.id, workspaceDir: project.workspaceDir, sendOptions, approvalManager, onDelivered: (path) => { deliveredFiles.push(path); }, }); // Abort handle for this run; the interrupt card action calls abort() to // stop the SDK query. Cleared in the finally below. const abortController = new AbortController(); activeRuns.set(run.id, abortController); runAgent({ prompt: promptForAgent, model, project: projectCtx, systemPrompt, providerEnv: provider.sdkEnv, resumeSessionId: sessionMetadata.claudeSessionId, mcpServers: { cph_hub: fileDeliveryMcpServer }, maxTurns: runPolicy.maxTurns, runId: run.id, sessionId: session.id, prisma: deps.prisma, abortController, onStream: (event) => { switch (event.type) { case "text-delta": card.appendText(event.text); break; case "thinking-delta": card.appendReasoning(event.text); break; case "tool-start": card.onToolStart(event.toolName, event.toolUseId); break; case "tool-end": card.onToolEnd({ toolName: event.toolName, toolUseId: event.toolUseId, input: event.input, result: undefined, error: undefined, durationMs: event.durationMs, }); break; case "tool-result": card.onToolEnd({ toolName: event.toolName, toolUseId: event.toolUseId, input: undefined, result: event.result, error: event.isError ? event.result : undefined, durationMs: event.durationMs, }); break; case "finish": break; } }, }) .then(async (result) => { const interrupted = result.status === "interrupted"; const finalText = result.text !== "" ? result.text : result.status === "failed" && result.error !== undefined ? `\u5904\u7406\u5931\u8D25: ${result.error}` : result.text; await card.finish(finalText, { interrupted }); const metadataPatch = sessionMetadataPatch(result.sdkSessionId); if (metadataPatch !== null) { await deps.prisma.agentSession.update({ where: { id: session.id }, data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) }, }); } await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: interrupted ? "CANCELED" : 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 writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status, deliveredFiles }, }); await removeProcessingReaction(); }) .catch(async (e) => { const removedProcessingReaction = await removeProcessingReaction(); if (removedProcessingReaction) { await addReaction(rt, msg.message_id, "CrossMark"); } await card.fail(e instanceof Error ? e.message : String(e)); try { await deps.prisma.agentRun.update({ where: { id: run.id }, data: { status: "FAILED", error: String(e), finishedAt: new Date() }, }); } catch (updateErr) { deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED"); } await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } }); }) .finally(async () => { try { await releaseLock(deps.prisma, run.id); } catch (e) { deps.logger.warn({ runId: run.id, err: e instanceof Error ? e.message : String(e) }, "trigger: could not release lock"); } try { await drainTriggerQueue(projectId, rt); } catch (e) { deps.logger.warn({ projectId, err: e instanceof Error ? e.message : String(e) }, "trigger: could not drain trigger queue"); } activeRuns.delete(run.id); }); return "started"; } async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise { const position = triggerQueue.enqueue(context.projectId, { chatId: context.chatId, prompt: cleanPrompt, msg: context.msg, senderOpenId: context.senderOpenId, actor: context.actor, }); if (position === 0) { await reactToMessage(context.rt, context.msg.message_id, "OnIt"); await sendText(context.rt, context.chatId, "队列已满,请稍后再试", sendOptionsForTriggerMessage(context.msg)); return "rejected"; } await sendText( context.rt, context.chatId, `已加入队列(第${position}位),当前处理完成后将自动开始`, sendOptionsForTriggerMessage(context.msg), ); return "queued"; } async function drainTriggerQueue(projectId: string, rt: FeishuRuntime): Promise { while (triggerQueue.hasPending(projectId)) { const existing = await currentLockRunId(deps.prisma, projectId); if (existing !== null) return; const queued = triggerQueue.peek(projectId); if (queued === null) return; if (triggerQueue.isExpired(queued)) { triggerQueue.dequeue(projectId); deps.logger.info( { projectId, messageId: queued.msg.message_id, enqueuedAt: queued.enqueuedAt }, "feishu trigger: dropped expired queued trigger", ); continue; } const next = triggerQueue.dequeue(projectId); if (next === null) return; const outcome = await startAgentRun(contextFromQueuedTrigger(next, rt), next.prompt, { queueIfLocked: false }); if (outcome === "started") return; if (outcome === "locked") { requeueTrigger(next); return; } } } function contextFromQueuedTrigger(trigger: QueuedTrigger, rt: FeishuRuntime): TriggerRunContext { return { msg: trigger.msg, rt, chatId: trigger.chatId, projectId: trigger.projectId, senderOpenId: trigger.senderOpenId, actor: trigger.actor, }; } function requeueTrigger(trigger: QueuedTrigger): void { const position = triggerQueue.enqueue(trigger.projectId, { chatId: trigger.chatId, prompt: trigger.prompt, msg: trigger.msg, senderOpenId: trigger.senderOpenId, actor: trigger.actor, }); if (position === 0) { deps.logger.warn( { projectId: trigger.projectId, messageId: trigger.msg.message_id }, "feishu trigger: could not requeue locked queued trigger", ); } } const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise => { // Approval buttons (file-delivery / explicit approval cards). const approvalAction = approvalActionFromValue(event.action.value); if (approvalAction !== null) { const messageId = nonEmpty(event.context?.open_message_id); if (messageId === undefined || !approvalManager.hasPending(messageId)) return; const operatorOpenId = nonEmpty(event.operator.open_id); const resolvedBy = operatorOpenId ?? "unknown"; const resolvedByName = operatorOpenId === undefined ? "unknown" : await senderDisplayNameOrOpenId(rt, operatorOpenId); const resolution = approvalResolutionFor(approvalAction); approvalManager.resolve(messageId, approvalAction, resolvedBy); await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName); return; } // Interrupt button on a live run card. const runId = interruptRunFromValue(event.action.value); if (runId !== null) { await handleInterruptAction(event, rt, runId); } }; /** Abort a live run after authorizing agent.cancel on its project. */ async function handleInterruptAction(event: CardActionEvent, rt: FeishuRuntime, runId: string): Promise { const chatId = nonEmpty(event.context?.open_chat_id); if (chatId === undefined) { deps.logger.warn({ runId }, "feishu interrupt: card action missing chat id"); return; } const binding = await deps.prisma.projectGroupBinding.findUnique({ where: { chatId }, select: { projectId: true }, }); if (binding === null) { deps.logger.debug({ chatId }, "feishu interrupt: chat not bound to any project"); return; } const projectId = binding.projectId; const senderOpenId = nonEmpty(event.operator.open_id) ?? ""; if (senderOpenId === "") { deps.logger.warn({ projectId, chatId, runId }, "feishu interrupt: operator has no open_id, denying"); await sendText(rt, chatId, "无法识别操作者,拒绝中断。"); return; } const actor = { feishuOpenId: senderOpenId, chatId }; const decision = await authorizer.can({ actor, action: "agent.cancel", resource: { type: "PROJECT", id: projectId }, }); if (!decision.allowed) { deps.logger.info({ projectId, runId, senderOpenId, reason: decision.reason }, "feishu interrupt: permission denied"); await writeAudit(deps.prisma, { projectId, ...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}), runId, action: "run.interrupt_denied", metadata: { decision: auditDecision(decision), sender: await senderAuditMetadata(rt, senderOpenId) }, }); await sendText(rt, chatId, "无权限中断该运行。"); return; } const controller = activeRuns.get(runId); if (controller === undefined) { // Run already terminated (or unknown): nothing to abort. The card's // complete-state patch already removed the button, so a stray click is // best-effort ignored rather than spammed with a message. deps.logger.debug({ runId, projectId }, "feishu interrupt: no active run for runId"); return; } controller.abort(); await writeAudit(deps.prisma, { projectId, ...(decision.actorUserId !== undefined ? { actorUserId: decision.actorUserId } : {}), runId, action: "run.interrupted", metadata: { sender: await senderAuditMetadata(rt, senderOpenId) }, }); } const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise => { const msg = event.message; // Dedup: the lark ws client redelivers at-least-once. A duplicate event // 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 = feishuEventId(event); if (eventId !== undefined && eventId !== "") { const existing = await deps.prisma.feishuEventReceipt.findUnique({ where: { eventId }, select: { id: true }, }); if (existing !== null) { deps.logger.debug({ eventId }, "feishu trigger: duplicate event, skipping"); return; } await deps.prisma.feishuEventReceipt.create({ data: { eventId, eventType: feishuEventType(event), messageId: msg.message_id, }, }); } // Only react to supported inbound message types in group chats. if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") 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-0019: triggerAgent is authorized through actor → principal-set // resolution, then grant/settings composition. // A missing open_id is treated as an untrusted event: deny explicitly // rather than skipping the check (fail closed). const senderOpenId = event.sender.sender_id.open_id ?? ""; if (senderOpenId === "") { deps.logger.warn({ projectId, chatId }, "feishu trigger: sender has no open_id, denying"); await writeAudit(deps.prisma, { projectId, action: "trigger.denied", metadata: { reason: "missing open_id" } }); await sendText(rt, chatId, "无法识别发送者,拒绝触发。", sendOptionsForTriggerMessage(msg)); return; } const actor = { feishuOpenId: senderOpenId, chatId }; const triggerDecision = await authorizer.can({ actor, action: "agent.trigger", resource: { type: "PROJECT", id: projectId }, }); if (!triggerDecision.allowed) { deps.logger.info({ projectId, senderOpenId, reason: triggerDecision.reason }, "feishu trigger: permission denied"); await writeAudit(deps.prisma, { projectId, ...(triggerDecision.actorUserId !== undefined ? { actorUserId: triggerDecision.actorUserId } : {}), action: "trigger.denied", metadata: { decision: auditDecision(triggerDecision), sender: await senderAuditMetadata(rt, senderOpenId), }, }); await sendText(rt, chatId, "无权限触发。", sendOptionsForTriggerMessage(msg)); return; } let cleanPrompt: string | null = null; if (msg.message_type === "text") { cleanPrompt = extractPrompt(msg); } else if (msg.message_type === "post") { const parsed = parsePostMessage(msg.content); cleanPrompt = extractPostPrompt(msg, parsed.text); if (cleanPrompt === null) return; if (parsed.imageKeys.length > 0 || parsed.fileKeys.length > 0) { const project = await deps.prisma.project.findUnique({ where: { id: projectId }, select: { workspaceDir: true }, }); if (project === null) return; await downloadPostMessageFiles({ rt, msg, workspaceDir: project.workspaceDir, imageKeys: parsed.imageKeys, fileKeys: parsed.fileKeys, }); } } else if (msg.message_type === "file" || msg.message_type === "image") { // Download the file/image to the workspace, build a prompt around it. const project = await deps.prisma.project.findUnique({ where: { id: projectId }, select: { workspaceDir: true }, }); if (project === null) return; try { const content = FileMessageContentSchema.parse(JSON.parse(msg.content)); 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); cleanPrompt = `用户发来一个文件: ${savePath}`; } } catch { return; } } if (cleanPrompt === null) return; const runContext: TriggerRunContext = { msg, rt, chatId, projectId, senderOpenId, actor }; // Slash commands: session management, not agent runs. These bypass the // batcher. Session commands bypass the lock because they don't create runs; // role/unknown slash prompts still use the normal run path and queue if locked. if (cleanPrompt.startsWith("/")) { const invocation = parseSlashInvocation(cleanPrompt); const helpSubcommandTarget = invocation === null ? null : parseSlashHelpSubcommand(invocation); if (helpSubcommandTarget !== null) { // Help is generated on demand because role/tool configuration is runtime data. const models = await deps.settings.modelRegistry({ projectId }); await sendText(rt, chatId, formatSlashHelpTarget(helpSubcommandTarget, models, slashCommands), sendOptionsForTriggerMessage(msg)); return; } if (invocation !== null) { const slashCommand = slashCommands.get(invocation.name); if (slashCommand !== undefined) { await slashCommand.run({ invocation, projectId, chatId, rt, sendOptions: sendOptionsForTriggerMessage(msg) }); return; } } await startAgentRun(runContext, cleanPrompt); return; } if (msg.message_type === "text") { const existing = await currentLockRunId(deps.prisma, projectId); if (existing !== null) { await enqueueLockedTrigger(runContext, cleanPrompt); return; } const key = messageBatchKey(chatId, senderOpenId); if (!batchContexts.has(key)) { batchContexts.set(key, runContext); } await messageBatcher.enqueue(chatId, senderOpenId, cleanPrompt); return; } await startAgentRun(runContext, cleanPrompt); }; return Object.assign(onMessage, { onCardAction, approvalManager }); } interface SessionMetadata { readonly claudeSessionId?: string | undefined; } function readSessionMetadata(metadata: unknown): SessionMetadata { if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) { return {}; } const raw = metadata as Record; const claudeSessionId = typeof raw["claudeSessionId"] === "string" && raw["claudeSessionId"] !== "" ? raw["claudeSessionId"] : undefined; return { ...(claudeSessionId !== undefined ? { claudeSessionId } : {}), }; } function sessionMetadataPatch(claudeSessionId: string | undefined): SessionMetadata | null { const patch: SessionMetadata = { ...(claudeSessionId !== undefined ? { claudeSessionId } : {}), }; return Object.keys(patch).length > 0 ? patch : null; } function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma.InputJsonObject { const base = typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? { ...(metadata as Prisma.InputJsonObject) } : {}; if (patch.claudeSessionId !== undefined) base["claudeSessionId"] = patch.claudeSessionId; 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; } function sendOptionsForTriggerMessage(msg: MessageReceiveEvent["message"]): SendMessageOptions { return { replyToMessageId: msg.message_id }; } function approvalActionFromValue(value: unknown): string | null { if (typeof value === "string") { try { return approvalActionFromValue(JSON.parse(value)); } catch { return null; } } if (typeof value !== "object" || value === null || Array.isArray(value)) return null; if (!("approval_action" in value)) return null; const action = value.approval_action; return typeof action === "string" && action !== "" ? action : null; } function interruptRunFromValue(value: unknown): string | null { if (typeof value === "string") { try { return interruptRunFromValue(JSON.parse(value)); } catch { return null; } } if (typeof value !== "object" || value === null || Array.isArray(value)) return null; if (!("interrupt_run" in value)) return null; const runId = value.interrupt_run; return typeof runId === "string" && runId !== "" ? runId : null; } function approvalResolutionFor(action: string): { readonly icon: string; readonly label: string; readonly template: "green" | "red" } { const normalized = action.trim().toLowerCase(); switch (normalized) { case "approve": case "approved": return { icon: "✅", label: "Approved", template: "green" }; case "accept": case "accepted": return { icon: "✅", label: "Accepted", template: "green" }; case "confirm": case "confirmed": case "yes": case "ok": return { icon: "✅", label: "Confirmed", template: "green" }; case "reject": case "rejected": return { icon: "❌", label: "Rejected", template: "red" }; case "deny": case "denied": case "decline": case "declined": return { icon: "❌", label: "Declined", template: "red" }; case "cancel": case "canceled": case "cancelled": case "no": return { icon: "❌", label: "Cancelled", template: "red" }; default: return { icon: "✅", label: action, template: "green" }; } } interface BuildFeishuTriggerContextArgs { readonly msg: MessageReceiveEvent["message"]; readonly chatId: string; readonly projectId: string; readonly workspaceDir: string; readonly rt: FeishuRuntime; readonly senderOpenId: string; } 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); const context: Record = { trigger_message_id: args.msg.message_id, chat_id: args.chatId, sender: await senderAuditMetadata(args.rt, args.senderOpenId), }; 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): string { return `Feishu trigger context:\n${JSON.stringify(context, null, 2)}\n\nUser request from ${senderLabelFromContext(context)}:\n${prompt}`; } function agentRunMetadata( roleId: string, rawPrompt: string, feishuTriggerContext: Prisma.InputJsonObject, ): Prisma.InputJsonObject { const metadata: Record = { roleId, rawPrompt, feishuTriggerContext }; return metadata as Prisma.InputJsonObject; } function senderLabelFromContext(context: Prisma.InputJsonObject): string { const sender = context["sender"]; if (typeof sender !== "object" || sender === null || Array.isArray(sender)) return "unknown sender"; const senderObject = sender as Record; const name = senderObject["name"]; if (typeof name === "string" && name.trim() !== "") return name.trim(); const openId = senderObject["open_id"]; return typeof openId === "string" && openId.trim() !== "" ? openId.trim() : "unknown sender"; } function auditDecision(decision: AuthorizationDecision): Record { return { allowed: decision.allowed, reason: decision.reason, action: decision.action, requiredRole: decision.requiredRole, effectiveRole: decision.effectiveRole ?? null, principals: decision.principals.map((principal) => ({ type: principal.type, id: principal.id, })), matchedGrant: decision.matchedGrant === undefined ? null : { id: decision.matchedGrant.id, principal: decision.matchedGrant.principal, role: decision.matchedGrant.role, }, matchedRoleGrant: decision.matchedRoleGrant === undefined ? null : { id: decision.matchedRoleGrant.id, principal: decision.matchedRoleGrant.principal, }, }; } async function senderDisplayNameOrOpenId(rt: FeishuRuntime, openId: string): Promise { return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId; } async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise { const sender: Record = { open_id: openId }; const name = await resolveSenderName(rt, openId, senderNameCache); if (name !== null) { sender["name"] = name; } return sender as Prisma.InputJsonObject; } function withFileDeliveryInstructions(systemPrompt: string | undefined): string { const fileDeliveryPrompt = "When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " + "Do not say a file is attached or sent unless that tool returns success."; return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`; } /** Lark text-message content: `{"text":"@_user_1 do something"}`. */ const TextMessageContentSchema = z.object({ text: z.string() }); /** Lark file/image-message content: carries a file_key or image_key. */ const FileMessageContentSchema = z.object({ file_key: z.string().optional(), image_key: z.string().optional(), }); /** * 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 text = TextMessageContentSchema.parse(JSON.parse(msg.content)).text; // 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; } } function extractPostPrompt(msg: MessageReceiveEvent["message"], parsedText: string): string | null { const mentions = msg.mentions; if (mentions === undefined || mentions.length === 0) return null; const stripped = stripParsedPostMentions(parsedText, mentions).trim(); return stripped === "" ? null : stripped; } function stripParsedPostMentions( text: string, mentions: NonNullable, ): string { let stripped = text; for (const mention of mentions) { stripped = removeMentionMarker(stripped, mention.key); stripped = removeMentionMarker(stripped, `@${mention.name}`); } return stripped; } function removeMentionMarker(text: string, marker: string): string { if (marker === "") return text; return text.replace(new RegExp(`${escapeRegExp(marker)}\\s*`, "g"), ""); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } async function downloadPostMessageFiles(args: { readonly rt: FeishuRuntime; readonly msg: MessageReceiveEvent["message"]; readonly workspaceDir: string; readonly imageKeys: readonly string[]; readonly fileKeys: readonly string[]; }): Promise { const timestamp = Date.now(); for (const [index, imageKey] of args.imageKeys.entries()) { const savePath = `${args.workspaceDir}/.cph/inbox/post-image-${timestamp}-${index}.png`; await downloadMessageFile(args.rt, args.msg.message_id, imageKey, savePath); } for (const [index, fileKey] of args.fileKeys.entries()) { const savePath = `${args.workspaceDir}/.cph/inbox/post-file-${timestamp}-${index}.bin`; await downloadMessageFile(args.rt, args.msg.message_id, fileKey, savePath); } } /** * Parse a leading `/` command from a prompt (e.g. `/draft 帮我写教案`). * Returns the role id and the remaining prompt with the command stripped. * Control/help commands already handled upstream are ignored here — they never * reach this function. * * Unknown `/foo` that isn't a registered role: returns `null` role and the * original prompt unchanged (the slash is treated as literal text). Role * resolution still happens via the registry's fallback. */ export function extractRole( prompt: string, registry: { role(id: string): unknown }, ): { roleId: string | null; prompt: string } { const m = /^\/(\S+)\s*/.exec(prompt); if (m === null || m[1] === undefined) return { roleId: null, prompt }; const roleId = m[1]; if (registry.role(roleId) === undefined) { // Unknown slash command — leave the prompt as-is (no silent role switch). return { roleId: null, prompt }; } return { roleId, prompt: prompt.slice(m[0].length) }; }