Files
curriculum-project-hub/hub/src/feishu/trigger.ts
T
hongjr03 a0df8b6bcf fix: repair streaming output broken by markdown rendering changes
Two bugs were introduced when sendTextMessage was changed to auto-detect
message format (text/post/interactive):

1. Message type mismatch: sendTextMessage could create a text or post
   message, but patchTextMessage only works on interactive cards.
   Fix: add sendInteractiveCardMessage for streaming create (always
   interactive, always patchable).

2. flushTextToSink state corruption: the text management after flush
   had a flawed appendedDuringFlush heuristic that could lose text or
   duplicate it. Fix: properly track sent vs unsent text by removing
   the sent prefix length, keeping only the last chunk for future patches.

Also fix finish() to flush pending text regardless of currentMessageId.
2026-07-08 17:48:55 +08:00

943 lines
35 KiB
TypeScript

/**
* Feishu @bot trigger → AgentRun lifecycle (streaming).
*
* Sends a "processing" card immediately, patches it with text deltas + tool
* status as the agent streams, then replaces it with a final status card on
* completion. Throttled to ~1 patch/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,
sendInteractiveCardMessage,
patchTextMessage,
reactToMessage,
addReaction,
removeReaction,
downloadMessageFile,
parsePostMessage,
resolveCard,
resolveSenderName,
type CardActionEvent,
type FeishuRuntime,
type MessageReceiveEvent,
} 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 { PatchableTextStream } from "./textStream.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";
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<RunResult>;
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<void>;
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
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 batchContexts = new Map<string, TriggerRunContext>();
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<StartAgentRunOutcome> {
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
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 `/<role>` 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}。`);
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,
});
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
// 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, metadata: true },
});
const session =
existingSession !== null
? await deps.prisma.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, model, updatedAt: new Date() },
select: { id: true, metadata: true },
})
: await deps.prisma.agentSession.create({
data: {
projectId,
provider: providerId,
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 === null ? {} : { 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<boolean> => {
if (processingReactionId === null) return false;
return removeReaction(rt, msg.message_id, processingReactionId);
};
// Stream agent response as text-as-card messages. Lazy-init: don't send
// anything until the first text-delta arrives (avoids empty placeholder).
const textStream = new PatchableTextStream({
create: (text) => sendInteractiveCardMessage(rt, chatId, text),
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
projectId,
runId: run.id,
workspaceDir: project.workspaceDir,
approvalManager,
onDelivered: (path) => {
deliveredFiles.push(path);
},
});
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,
onStream: (event) => {
if (event.type === "text-delta") {
textStream.append(event.text);
}
},
})
.then(async (result) => {
// Final flush: patch the last message with complete text, or send
// the full response if no streaming message was created yet.
const finalText =
result.text !== ""
? result.text
: result.status === "failed" && result.error !== undefined
? `处理失败: ${result.error}`
: result.text;
await textStream.finish(finalText);
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: 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");
}
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");
}
});
return "started";
}
async function enqueueLockedTrigger(context: TriggerRunContext, cleanPrompt: string): Promise<StartAgentRunOutcome> {
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, "队列已满,请稍后再试");
return "rejected";
}
await sendText(context.rt, context.chatId, `已加入队列(第${position}位),当前处理完成后将自动开始`);
return "queued";
}
async function drainTriggerQueue(projectId: string, rt: FeishuRuntime): Promise<void> {
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<void> => {
const action = approvalActionFromValue(event.action.value);
if (action === null) return;
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(action);
approvalManager.resolve(messageId, action, resolvedBy);
await resolveCard(rt, messageId, resolution.icon, resolution.label, resolution.template, resolvedByName);
};
const onMessage = async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
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, "无法识别发送者,拒绝触发。");
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, "无权限触发。");
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 cmd = cleanPrompt.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() },
});
const cleared = triggerQueue.clear(projectId);
if (cleared > 0) {
deps.logger.info({ projectId, cleared }, "feishu trigger: cleared queued triggers on reset");
}
await sendText(rt, chatId, "已重置,下次 @bot 将从头开始。");
return;
}
default:
break;
}
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<string, unknown>;
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 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;
const action = (value as Record<string, unknown>)["approval_action"];
return typeof action === "string" && action !== "" ? action : 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;
}
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,
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<string> {
return (await resolveSenderName(rt, openId, senderNameCache)) ?? openId;
}
async function senderAuditMetadata(rt: FeishuRuntime, openId: string): Promise<Prisma.InputJsonObject> {
const sender: Record<string, Prisma.InputJsonValue> = { 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<MessageReceiveEvent["message"]["mentions"]>,
): 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<void> {
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 `/<role>` command from a prompt (e.g. `/draft 帮我写教案`).
* Returns the role id and the remaining prompt with the command stripped.
* Control commands already handled upstream (`/new`, `/resume`, `/reset`) 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) };
}