forked from bai/curriculum-project-hub
ad65d93b2e
- Create MessageBatcher: debounce 600ms, adaptive delay for long chunks, max 8 messages/4000 chars before immediate flush, per (chatId, sender) key - Integrate into trigger: text messages enqueued, slash commands and file/image bypass batching, locked projects respond immediately - Add unit tests (8) for batcher lifecycle and edge cases
672 lines
26 KiB
TypeScript
672 lines
26 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, patchTextMessage, reactToMessage, addReaction, removeReaction, downloadMessageFile, 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";
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
const authorizer = deps.authorizer ?? createPermissionAuthorizer(deps.prisma);
|
|
const runAgent = deps.runAgent ?? defaultRunAgent;
|
|
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): Promise<void> {
|
|
const { msg, rt, chatId, projectId, senderOpenId, actor } = context;
|
|
|
|
// ADR-0002: if locked by another run, reply busy.
|
|
const existing = await currentLockRunId(deps.prisma, projectId);
|
|
if (existing !== null) {
|
|
await reactToMessage(rt, msg.message_id, "OnIt");
|
|
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. 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) },
|
|
});
|
|
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
|
return;
|
|
}
|
|
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),
|
|
...(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: {} });
|
|
await reactToMessage(rt, msg.message_id, "OnIt");
|
|
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
|
return;
|
|
}
|
|
|
|
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) => sendTextMessage(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,
|
|
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");
|
|
}
|
|
});
|
|
}
|
|
|
|
return async (event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void> => {
|
|
const msg = event.message;
|
|
const sender = event.sender;
|
|
void sender; // sender→user mapping is OPEN (principal sub-typology)
|
|
|
|
// 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 text, file, or image messages in group chats.
|
|
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image") 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) },
|
|
});
|
|
await sendText(rt, chatId, "无权限触发。");
|
|
return;
|
|
}
|
|
|
|
let cleanPrompt: string | null = null;
|
|
if (msg.message_type === "text") {
|
|
cleanPrompt = extractPrompt(msg);
|
|
} 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 run immediately through the normal lock.
|
|
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() },
|
|
});
|
|
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 reactToMessage(rt, msg.message_id, "OnIt");
|
|
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
|
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);
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
/**
|
|
* 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) };
|
|
}
|