forked from bai/curriculum-project-hub
fix(hub): send Feishu files explicitly
This commit is contained in:
@@ -60,14 +60,14 @@ export interface CardActionEvent {
|
||||
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
||||
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
|
||||
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
|
||||
};
|
||||
try {
|
||||
const res = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
||||
});
|
||||
return res.data?.message_id ?? null;
|
||||
return messageIdFromResponse(res);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
@@ -133,8 +133,8 @@ export async function downloadMessageFile(
|
||||
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
|
||||
const client = rt.client as unknown as {
|
||||
im: { v1: {
|
||||
file: { create: (p: unknown) => Promise<{ data?: { file_key?: string } }> }
|
||||
message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> }
|
||||
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
||||
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
|
||||
} };
|
||||
};
|
||||
try {
|
||||
@@ -142,19 +142,44 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
|
||||
const ext = fileName.split(".").pop() ?? "";
|
||||
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
||||
const uploadRes = await client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } });
|
||||
const fileKey = uploadRes.data?.file_key;
|
||||
if (fileKey === undefined) return null;
|
||||
const fileKey = fileKeyFromResponse(uploadRes);
|
||||
if (fileKey === undefined) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
|
||||
return null;
|
||||
}
|
||||
const msgRes = await client.im.v1.message.create({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
|
||||
});
|
||||
return msgRes.data?.message_id ?? null;
|
||||
const messageId = messageIdFromResponse(msgRes);
|
||||
if (messageId === null) {
|
||||
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
|
||||
}
|
||||
return messageId;
|
||||
} catch (e) {
|
||||
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type MessageCreateResponse = {
|
||||
readonly message_id?: string | undefined;
|
||||
readonly data?: { readonly message_id?: string | undefined } | undefined;
|
||||
} | null;
|
||||
|
||||
type FileCreateResponse = {
|
||||
readonly file_key?: string | undefined;
|
||||
readonly data?: { readonly file_key?: string | undefined } | undefined;
|
||||
} | null;
|
||||
|
||||
function messageIdFromResponse(res: MessageCreateResponse): string | null {
|
||||
return res?.data?.message_id ?? res?.message_id ?? null;
|
||||
}
|
||||
|
||||
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
|
||||
return res?.file_key ?? res?.data?.file_key;
|
||||
}
|
||||
|
||||
// --- Lark client + ws listener ---
|
||||
|
||||
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export interface DeliverableFile {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
const DELIVERABLE_EXTENSIONS = new Set([
|
||||
".csv",
|
||||
".doc",
|
||||
".docx",
|
||||
".gif",
|
||||
".jpeg",
|
||||
".jpg",
|
||||
".md",
|
||||
".pdf",
|
||||
".png",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".svg",
|
||||
".txt",
|
||||
".typ",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".zip",
|
||||
]);
|
||||
|
||||
export async function resolveDeliverableFile(
|
||||
requestedPath: string,
|
||||
workspaceDir: string,
|
||||
): Promise<DeliverableFile | null> {
|
||||
const token = requestedPath.trim();
|
||||
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roots = await allowedRoots(workspaceDir);
|
||||
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
|
||||
if (!isAllowedPath(candidate, roots)) continue;
|
||||
const existing = await existingFile(candidate);
|
||||
if (existing !== null) return { path: existing, name: basename(existing) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function allowedRoots(workspaceDir: string): Promise<string[]> {
|
||||
const roots = [resolve(workspaceDir)];
|
||||
const gitRoot = await findGitRoot(workspaceDir);
|
||||
if (gitRoot !== undefined) roots.push(gitRoot);
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
async function findGitRoot(startDir: string): Promise<string | undefined> {
|
||||
let current = resolve(startDir);
|
||||
while (true) {
|
||||
try {
|
||||
await access(join(current, ".git"));
|
||||
return current;
|
||||
} catch {
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
|
||||
if (isAbsolute(token)) return [resolve(token)];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const root of roots) {
|
||||
candidates.push(resolve(root, token));
|
||||
}
|
||||
if (!token.includes("/") && !token.includes("\\")) {
|
||||
candidates.push(resolve(workspaceDir, "build", token));
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function isAllowedPath(path: string, roots: readonly string[]): boolean {
|
||||
return roots.some((root) => {
|
||||
const rel = relative(root, path);
|
||||
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
||||
});
|
||||
}
|
||||
|
||||
async function existingFile(path: string): Promise<string | null> {
|
||||
try {
|
||||
const s = await stat(path);
|
||||
return s.isFile() ? resolve(path) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { z } from "zod";
|
||||
import { sendFile, type FeishuRuntime } from "./client.js";
|
||||
import { resolveDeliverableFile } from "./fileDelivery.js";
|
||||
|
||||
export interface FileDeliveryToolOptions {
|
||||
readonly rt: FeishuRuntime;
|
||||
readonly chatId: string;
|
||||
readonly workspaceDir: string;
|
||||
readonly onDelivered?: (path: string) => void;
|
||||
}
|
||||
|
||||
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
|
||||
return createSdkMcpServer({
|
||||
name: "cph_hub",
|
||||
version: "0.0.0",
|
||||
alwaysLoad: true,
|
||||
instructions:
|
||||
"Use send_file when the user asks to receive, resend, download, or attach a file. " +
|
||||
"Do not claim a file was sent unless send_file returns success. " +
|
||||
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
|
||||
tools: [
|
||||
tool(
|
||||
"send_file",
|
||||
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
|
||||
{
|
||||
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
|
||||
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
|
||||
},
|
||||
async (args) => {
|
||||
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
|
||||
if (file === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }],
|
||||
};
|
||||
}
|
||||
|
||||
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name);
|
||||
if (messageId === null) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
|
||||
};
|
||||
}
|
||||
|
||||
options.onDelivered?.(file.path);
|
||||
return {
|
||||
content: [{ type: "text", text: `Sent file: ${file.path}` }],
|
||||
};
|
||||
},
|
||||
{ alwaysLoad: true },
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export interface PatchableTextSink {
|
||||
readonly create: (text: string) => Promise<string | null>;
|
||||
readonly patch: (messageId: string, text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PatchableTextStreamOptions {
|
||||
readonly patchIntervalMs?: number;
|
||||
readonly maxMessageLength?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PATCH_INTERVAL_MS = 400;
|
||||
const DEFAULT_MAX_MESSAGE_LENGTH = 4000;
|
||||
|
||||
/**
|
||||
* Serializes text-card creation/patching for streaming output.
|
||||
*
|
||||
* Feishu returns the message id asynchronously. Without this queue, multiple
|
||||
* early text deltas can all observe "no message yet" and create duplicate
|
||||
* prefix messages before the first create call resolves.
|
||||
*/
|
||||
export class PatchableTextStream {
|
||||
private currentMessageId: string | null = null;
|
||||
private text = "";
|
||||
private lastPatchAt = 0;
|
||||
private flushChain: Promise<void> = Promise.resolve();
|
||||
private flushScheduled = false;
|
||||
|
||||
private readonly patchIntervalMs: number;
|
||||
private readonly maxMessageLength: number;
|
||||
|
||||
constructor(
|
||||
private readonly sink: PatchableTextSink,
|
||||
options: PatchableTextStreamOptions = {},
|
||||
) {
|
||||
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
|
||||
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
|
||||
}
|
||||
|
||||
append(delta: string): void {
|
||||
if (delta === "") return;
|
||||
this.text += delta;
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
async finish(fallbackText: string): Promise<void> {
|
||||
await this.flushChain;
|
||||
if (this.currentMessageId !== null && this.text.length > 0) {
|
||||
await this.sink.patch(this.currentMessageId, this.text);
|
||||
return;
|
||||
}
|
||||
if (this.currentMessageId === null && fallbackText.length > 0) {
|
||||
this.currentMessageId = await this.sink.create(fallbackText);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushScheduled) return;
|
||||
this.flushScheduled = true;
|
||||
this.flushChain = this.flushChain.then(async () => {
|
||||
this.flushScheduled = false;
|
||||
await this.flush();
|
||||
});
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.currentMessageId === null) {
|
||||
this.currentMessageId = await this.sink.create(this.text);
|
||||
this.lastPatchAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - this.lastPatchAt < this.patchIntervalMs) return;
|
||||
this.lastPatchAt = now;
|
||||
|
||||
if (this.text.length > this.maxMessageLength) {
|
||||
await this.sink.patch(this.currentMessageId, this.text.slice(0, this.maxMessageLength));
|
||||
this.text = this.text.slice(this.maxMessageLength);
|
||||
this.currentMessageId = await this.sink.create(this.text);
|
||||
} else {
|
||||
await this.sink.patch(this.currentMessageId, this.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sendFile, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js";
|
||||
|
||||
describe("Feishu client helpers", () => {
|
||||
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
|
||||
try {
|
||||
const file = join(dir, "student.pdf");
|
||||
await writeFile(file, "pdf bytes");
|
||||
const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
|
||||
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
|
||||
const rt = mockRuntime({ fileCreate, messageCreate });
|
||||
|
||||
await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
|
||||
expect(messageCreate).toHaveBeenCalledWith({
|
||||
params: { receive_id_type: "chat_id" },
|
||||
data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
|
||||
});
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts top-level message_id from message create responses", async () => {
|
||||
const rt = mockRuntime({
|
||||
fileCreate: vi.fn(),
|
||||
messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
|
||||
});
|
||||
|
||||
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
|
||||
});
|
||||
});
|
||||
|
||||
function mockRuntime(options: {
|
||||
readonly fileCreate: (payload: unknown) => Promise<unknown>;
|
||||
readonly messageCreate: (payload: unknown) => Promise<unknown>;
|
||||
}): FeishuRuntime {
|
||||
return {
|
||||
client: {
|
||||
im: {
|
||||
v1: {
|
||||
file: { create: options.fileCreate },
|
||||
message: { create: options.messageCreate },
|
||||
},
|
||||
},
|
||||
} as unknown as FeishuRuntime["client"],
|
||||
logger: {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
fatal() {},
|
||||
child() { return this; },
|
||||
level: "silent",
|
||||
} as unknown as FeishuRuntime["logger"],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
|
||||
|
||||
describe("file delivery path resolution", () => {
|
||||
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(root, "README.md"), "# root readme\n");
|
||||
|
||||
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
|
||||
path: join(root, "README.md"),
|
||||
name: "README.md",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves a bare build filename against workspace/build", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
const pdf = join(workspace, "build", "student.pdf");
|
||||
await writeFile(pdf, "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
|
||||
path: pdf,
|
||||
name: "student.pdf",
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not infer files from natural-language prompts", async () => {
|
||||
const root = await makeRepo();
|
||||
try {
|
||||
const workspace = join(root, "examples", "TH-141");
|
||||
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
|
||||
|
||||
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function makeRepo(): Promise<string> {
|
||||
const root = await mkdir(join(tmpdir(), `hub-file-delivery-${Date.now()}-${Math.random().toString(36).slice(2)}`), { recursive: true });
|
||||
await mkdir(join(root, ".git"));
|
||||
await mkdir(join(root, "examples", "TH-141", "build"), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PatchableTextStream } from "../../src/feishu/textStream.js";
|
||||
|
||||
describe("PatchableTextStream", () => {
|
||||
it("creates only one initial message when deltas arrive while create is pending", async () => {
|
||||
const creates: string[] = [];
|
||||
const patches: string[] = [];
|
||||
let resolveCreate: ((id: string) => void) | undefined;
|
||||
const firstCreate = new Promise<string>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
});
|
||||
const stream = new PatchableTextStream(
|
||||
{
|
||||
create: async (text) => {
|
||||
creates.push(text);
|
||||
return firstCreate;
|
||||
},
|
||||
patch: async (_messageId, text) => {
|
||||
patches.push(text);
|
||||
},
|
||||
},
|
||||
{ patchIntervalMs: 0 },
|
||||
);
|
||||
|
||||
stream.append("我目前运行在");
|
||||
await Promise.resolve();
|
||||
stream.append(" Claude Sonnet");
|
||||
resolveCreate?.("msg-1");
|
||||
await stream.finish("我目前运行在 Claude Sonnet");
|
||||
|
||||
expect(creates).toEqual(["我目前运行在"]);
|
||||
expect(patches.at(-1)).toBe("我目前运行在 Claude Sonnet");
|
||||
});
|
||||
|
||||
it("sends a fallback message when no stream delta arrived", async () => {
|
||||
const creates: string[] = [];
|
||||
const stream = new PatchableTextStream({
|
||||
create: async (text) => {
|
||||
creates.push(text);
|
||||
return "msg-1";
|
||||
},
|
||||
patch: async () => {},
|
||||
});
|
||||
|
||||
await stream.finish("完整回复");
|
||||
|
||||
expect(creates).toEqual(["完整回复"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user