forked from bai/curriculum-project-hub
313e890b6c
群消息事件 → 创建 AgentRun → 获锁 → 调 runAgent → 释放锁 + 状态回写: - feishu/client.ts: lark WSClient 长连接接收 im.message.receive_v1; sendText 经 client.im.v1 发送(SDK 运行时动态 API,类型弱化务实收口) - feishu/trigger.ts: @bot 识别(chat 绑定解析 ADR-0001)→ 创建 run+ session(provider-bound ADR-0017)→ acquireLock(ADR-0002,竞态回退 busy)→ runAgent(fire-and-forget)→ finally releaseLock;非项目群/无 @bot/非文本 全部忽略 - lock.ts: ADR-0002 WellFormed 落代码——currentLockRunId 读锁时校验 持锁 run 非终止,终止 run 持锁即 release-path bug(抛而非静默) - models.ts: resolve 提到 ModelRegistry 接口(之前漏在实现上) - server.ts: Fastify + lark ws 启动,/api/healthz prompt 提取 smoke 通过(@bot+prompt→prompt;纯@bot→null;无mention→null; 非文本→null)。修了 mention 正则 bug(/@_\w+_/ 的回溯错误)。tsc rc=0。
212 lines
7.7 KiB
TypeScript
212 lines
7.7 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, 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";
|
|
|
|
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-0002: if locked by another run, reply busy.
|
|
const existing = await currentLockRunId(deps.prisma, projectId);
|
|
if (existing !== null) {
|
|
await sendText(rt, chatId, "项目正在处理中,请稍候。");
|
|
return;
|
|
}
|
|
|
|
const prompt = extractPrompt(msg);
|
|
if (prompt === null) 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 })
|
|
.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 sendText(rt, chatId, statusReply(result.status));
|
|
})
|
|
.catch(async (e) => {
|
|
await deps.prisma.agentRun.update({
|
|
where: { id: run.id },
|
|
data: { status: "FAILED", error: String(e), finishedAt: new Date() },
|
|
});
|
|
await sendText(rt, chatId, "处理出错,已释放项目锁。");
|
|
})
|
|
.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"}`.
|
|
*/
|
|
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;
|
|
}
|
|
}
|