forked from bai/curriculum-project-hub
fix(hub): send Feishu files explicitly
This commit is contained in:
+91
-65
@@ -6,17 +6,18 @@
|
||||
* 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, transcript continuity, ADR-0003-authorized reads.
|
||||
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
|
||||
*/
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, sendFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.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, canTriggerRole } from "../permission.js";
|
||||
import { transcriptPath } from "../agent/transcript.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
import { PatchableTextStream } from "./textStream.js";
|
||||
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
|
||||
interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -172,7 +173,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// 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 { roleId: parsedRole } = extractRole(cleanPrompt, deps.models);
|
||||
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, deps.models);
|
||||
const roleId = parsedRole ?? "draft";
|
||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||
@@ -192,31 +193,32 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// 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 = role?.systemPrompt;
|
||||
const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt);
|
||||
|
||||
// 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 },
|
||||
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 },
|
||||
select: { id: true, metadata: true },
|
||||
})
|
||||
: await deps.prisma.agentSession.create({
|
||||
data: {
|
||||
projectId,
|
||||
provider: providerId,
|
||||
model,
|
||||
title: cleanPrompt.slice(0, 40) || null,
|
||||
title: agentPrompt.slice(0, 40) || null,
|
||||
metadata: {},
|
||||
},
|
||||
select: { id: true },
|
||||
select: { id: true, metadata: true },
|
||||
});
|
||||
const sessionMetadata = readSessionMetadata(session.metadata);
|
||||
|
||||
const run = await deps.prisma.agentRun.create({
|
||||
data: {
|
||||
@@ -225,14 +227,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
||||
entrypoint: "FEISHU",
|
||||
status: "ACTIVE",
|
||||
prompt: cleanPrompt,
|
||||
prompt: agentPrompt,
|
||||
model,
|
||||
provider: providerId,
|
||||
metadata: { roleId },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: cleanPrompt.slice(0, 200) } });
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: agentPrompt.slice(0, 200) } });
|
||||
|
||||
// ADR-0002: acquire the project lock for this run.
|
||||
try {
|
||||
@@ -259,55 +261,52 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
|
||||
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
||||
// anything until the first text-delta arrives (avoids empty placeholder).
|
||||
let currentMsgId: string | null = null;
|
||||
let accText = "";
|
||||
let lastPatch = 0;
|
||||
const PATCH_INTERVAL = 400;
|
||||
const MAX_MSG_LEN = 4000;
|
||||
|
||||
const flushText = async () => {
|
||||
if (currentMsgId === null) {
|
||||
// First chunk — create the message immediately (no throttle on init).
|
||||
currentMsgId = await sendTextMessage(rt, chatId, accText);
|
||||
lastPatch = Date.now();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (now - lastPatch < PATCH_INTERVAL) return;
|
||||
lastPatch = now;
|
||||
if (accText.length > MAX_MSG_LEN) {
|
||||
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
|
||||
accText = accText.slice(MAX_MSG_LEN);
|
||||
currentMsgId = await sendTextMessage(rt, chatId, accText);
|
||||
} else {
|
||||
await patchTextMessage(rt, currentMsgId, accText);
|
||||
}
|
||||
};
|
||||
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,
|
||||
workspaceDir: project.workspaceDir,
|
||||
onDelivered: (path) => {
|
||||
deliveredFiles.push(path);
|
||||
},
|
||||
});
|
||||
|
||||
runAgent({
|
||||
prompt: cleanPrompt,
|
||||
prompt: agentPrompt,
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
onStream: (event) => {
|
||||
if (event.type === "text-delta") {
|
||||
accText += event.text;
|
||||
void flushText();
|
||||
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.
|
||||
if (currentMsgId !== null && accText.length > 0) {
|
||||
await patchTextMessage(rt, currentMsgId, accText);
|
||||
} else if (currentMsgId === null && result.text.length > 0) {
|
||||
// No text-delta was received (e.g. model returned all at once).
|
||||
// Send the full text now.
|
||||
await sendTextMessage(rt, chatId, result.text);
|
||||
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 },
|
||||
@@ -319,23 +318,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
|
||||
|
||||
// Send any new files in build/ as file messages.
|
||||
const { readdir, stat } = await import("node:fs/promises");
|
||||
const { join } = await import("node:path");
|
||||
const buildDir = join(project.workspaceDir, "build");
|
||||
try {
|
||||
const entries = await readdir(buildDir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(buildDir, entry);
|
||||
const s = await stat(fullPath);
|
||||
if (s.isFile() && s.mtimeMs > Date.now() - 5 * 60 * 1000) {
|
||||
// File created in the last 5 minutes — likely from this run.
|
||||
await sendFile(rt, chatId, fullPath, entry);
|
||||
}
|
||||
}
|
||||
} catch { /* no build/ dir — fine */ }
|
||||
await writeAudit(deps.prisma, {
|
||||
runId: run.id,
|
||||
projectId,
|
||||
action: "run.finished",
|
||||
metadata: { status: result.status, deliveredFiles },
|
||||
});
|
||||
})
|
||||
.catch(async (e) => {
|
||||
try {
|
||||
@@ -347,9 +335,6 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
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) } });
|
||||
if (currentMsgId !== null && accText.length === 0) {
|
||||
await patchTextMessage(rt, currentMsgId, `⚠️ 处理出错: ${String(e)}`);
|
||||
}
|
||||
})
|
||||
.finally(async () => {
|
||||
await releaseLock(deps.prisma, run.id);
|
||||
@@ -357,6 +342,47 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
};
|
||||
}
|
||||
|
||||
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 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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the prompt text from a text message, stripping @mentions.
|
||||
|
||||
Reference in New Issue
Block a user