fix(hub): send Feishu files explicitly

This commit is contained in:
2026-07-08 11:48:25 +08:00
parent c73b41e1da
commit 6178664696
8 changed files with 524 additions and 72 deletions
+91 -65
View File
@@ -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.