Files
curriculum-project-hub/hub/src/feishu/trigger.ts
T
hongjr03 0fe6cabc68 feat(hub): session transcript 连续性 + slash commands (/new /resume /reset)
修复每次 @bot 都是失忆状态的问题。
- transcript.ts: JSONL 文件存 session 对话消息(workspace/.cph/sessions/
  <id>.jsonl),append-only;和 Claude Code 一致;ADR-0003 说的'不存全
  历史'是群聊历史,session 对话是 Hub 自己产生的,不冲突
- runner.ts: RunRequest 加 transcriptPath;run 开始时读历史拼进初始
  messages(system prompt 只在首条);run 结束时 append 本轮新消息
  (loadedCount 区分历史 vs 新增,不重复写);best-effort(写失败不丢
  run 结果)
- trigger.ts: slash commands 在 lock 检查之前(/new /resume /reset
  不创建 run 不需要锁);transcriptPath 传给 runner
  /new = 归档当前 session,下次 @bot 自动建新的
  /resume = 恢复最近归档的 session
  /reset = 同 /new
  未知 /cmd 透传给 agent 当普通 prompt
- unit: transcript.test.ts 读写回环/append 累积/损坏行恢复/路径(6)
- integration: trigger.test.ts 加 /new /resume /reset /unknown(4)
修了 slash command 被 lock 挡住的问题(提到 lock 之前)。
vitest run 45/45 通过;tsc rc=0。
2026-07-07 17:50:05 +08:00

278 lines
10 KiB
TypeScript

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