forked from bai/curriculum-project-hub
feat(hub): 文件收发 + 卡片回调 + 更好的流式
client.ts 重写: - downloadMessageFile: 下载飞书消息里的文件/图片到本地 - sendFile: 上传文件到飞书 + 发送文件消息(msg_type=file) - CardActionEvent 类型 + startFeishuListenerWithClient 加 onCardAction 回调参数,注册 card.action.trigger 事件(消除了 'no card.action.trigger handle' 警告) - 去掉未使用的 card builder 函数 trigger.ts: - 处理 file/image 消息:下载到 workspace/.cph/inbox/,prompt 里告诉 agent 文件路径 - 流式 throttle 从 800ms 降到 400ms,初始占位从 '…' 改为空(更干净) - run 完成后扫描 workspace/build/ 下 5 分钟内的新文件,自动发送给 老师(cph build 产出的 PDF 自动回传) tsc rc=0。
This commit is contained in:
+88
-102
@@ -1,16 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Feishu lark client + long-connection event dispatcher.
|
* Feishu lark client + long-connection event dispatcher.
|
||||||
*
|
*
|
||||||
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
|
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
|
||||||
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
|
* message sending (text-as-card for patchability), streaming patch, emoji
|
||||||
* event to our handler. This version of the SDK does not auto-normalize on the
|
* reactions, file download/upload, and the card action callback.
|
||||||
* ws path — `normalize()` is a separate helper — so we work with the raw event
|
|
||||||
* shape directly (it carries chat_id, message_type, content, mentions).
|
|
||||||
*
|
|
||||||
* ADR-0001: the bot operates inside a project's bound group. It does not own
|
|
||||||
* the lock (ADR-0002) and is not the session owner.
|
|
||||||
*/
|
*/
|
||||||
import * as lark from "@larksuiteoapi/node-sdk";
|
import * as lark from "@larksuiteoapi/node-sdk";
|
||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { dirname } from "node:path";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
|
|
||||||
export interface FeishuConfig {
|
export interface FeishuConfig {
|
||||||
@@ -24,12 +21,9 @@ export interface FeishuRuntime {
|
|||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raw `im.message.receive_v1` event payload (subset we use). */
|
/** Raw `im.message.receive_v1` event payload. */
|
||||||
export interface MessageReceiveEvent {
|
export interface MessageReceiveEvent {
|
||||||
readonly header?: {
|
readonly header?: { readonly event_id?: string; readonly event_type?: string };
|
||||||
readonly event_id?: string;
|
|
||||||
readonly event_type?: string;
|
|
||||||
};
|
|
||||||
readonly message: {
|
readonly message: {
|
||||||
readonly message_id: string;
|
readonly message_id: string;
|
||||||
readonly chat_id: string;
|
readonly chat_id: string;
|
||||||
@@ -48,8 +42,21 @@ export interface MessageReceiveEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a text-as-card message (looks like text to the user, but is patchable
|
/** Card action trigger event (button clicks, select changes). */
|
||||||
* because Feishu only allows patching interactive/card messages, not text). */
|
export interface CardActionEvent {
|
||||||
|
readonly operator: { readonly open_id?: string };
|
||||||
|
readonly action: {
|
||||||
|
readonly value?: unknown;
|
||||||
|
readonly tag?: string;
|
||||||
|
readonly option?: string;
|
||||||
|
};
|
||||||
|
readonly context?: { readonly open_message_id?: string; readonly open_chat_id?: string };
|
||||||
|
readonly token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message helpers ---
|
||||||
|
|
||||||
|
/** Send a text-as-card message (looks like text, but patchable). */
|
||||||
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
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 card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
||||||
const client = rt.client as unknown as {
|
const client = rt.client as unknown as {
|
||||||
@@ -61,17 +68,15 @@ export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: s
|
|||||||
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
||||||
});
|
});
|
||||||
return res.data?.message_id ?? null;
|
return res.data?.message_id ?? null;
|
||||||
} catch {
|
} catch { return null; }
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a text message (fire-and-forget, message_id not needed). */
|
/** Send a plain text message (fire-and-forget). */
|
||||||
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
export async function sendText(rt: FeishuRuntime, chatId: string, text: string): Promise<void> {
|
||||||
await sendTextMessage(rt, chatId, text);
|
await sendTextMessage(rt, chatId, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Patch a text-as-card message's content in-place (streaming updates). */
|
/** Patch a text-as-card message in-place (streaming updates). */
|
||||||
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise<void> {
|
||||||
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
||||||
const client = rt.client as unknown as {
|
const client = rt.client as unknown as {
|
||||||
@@ -88,7 +93,7 @@ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, tex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** React to a message with an emoji (Feishu "表情回复"). */
|
/** React to a message with an emoji. */
|
||||||
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
|
export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await rt.client.request({
|
await rt.client.request({
|
||||||
@@ -101,75 +106,57 @@ export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a streaming card for an in-progress run. Shows accumulated text + tool status. */
|
// --- File helpers ---
|
||||||
export function buildStreamingCard(opts: {
|
|
||||||
readonly status: string;
|
/** Download a file/image from a Feishu message to a local path. */
|
||||||
readonly model: string;
|
export async function downloadMessageFile(
|
||||||
readonly text: string;
|
rt: FeishuRuntime,
|
||||||
readonly toolName: string | null;
|
messageId: string,
|
||||||
}): unknown {
|
fileKey: string,
|
||||||
const elements: unknown[] = [
|
savePath: string,
|
||||||
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
|
): Promise<void> {
|
||||||
];
|
await mkdir(dirname(savePath), { recursive: true });
|
||||||
if (opts.toolName !== null) {
|
try {
|
||||||
elements.push({ tag: "div", text: { tag: "lark_md", content: `🔧 *${opts.toolName}...*` } });
|
const res = await rt.client.request({
|
||||||
|
method: "GET",
|
||||||
|
url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`,
|
||||||
|
params: { type: "file" },
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
}) as { data: Buffer };
|
||||||
|
await writeFile(savePath, res.data);
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ messageId, fileKey, err: e instanceof Error ? e.message : String(e) }, "downloadMessageFile failed");
|
||||||
}
|
}
|
||||||
if (opts.text !== "") {
|
|
||||||
elements.push({ tag: "div", text: { tag: "lark_md", content: opts.text } });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
config: { wide_screen_mode: true },
|
|
||||||
header: {
|
|
||||||
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
|
|
||||||
template: "blue",
|
|
||||||
},
|
|
||||||
elements,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a run-status card: status text + model selector + cancel button. */
|
/** Upload and send a file to a chat. */
|
||||||
export function buildRunStatusCard(opts: {
|
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
|
||||||
readonly status: string;
|
const client = rt.client as unknown as {
|
||||||
readonly model: string;
|
im: { v1: {
|
||||||
readonly models: readonly { readonly id: string; readonly label: string }[];
|
file: { create: (p: unknown) => Promise<{ data?: { file_key?: string } }> }
|
||||||
readonly runId: string;
|
message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> }
|
||||||
}): unknown {
|
} };
|
||||||
return {
|
|
||||||
config: { wide_screen_mode: true },
|
|
||||||
header: {
|
|
||||||
title: { tag: "plain_text", content: `@Claude · ${opts.status}` },
|
|
||||||
template: opts.status === "处理完成" ? "green" : opts.status === "处理出错" ? "red" : "blue",
|
|
||||||
},
|
|
||||||
elements: [
|
|
||||||
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
|
|
||||||
{
|
|
||||||
tag: "action",
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
tag: "select_static",
|
|
||||||
placeholder: { tag: "plain_text", content: "切换 model" },
|
|
||||||
options: opts.models.map((m) => ({ text: { tag: "plain_text", content: m.label }, value: m.id })),
|
|
||||||
value: opts.model,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tag: "button",
|
|
||||||
text: { tag: "plain_text", content: "取消" },
|
|
||||||
type: "danger",
|
|
||||||
value: JSON.stringify({ action: "cancel", runId: opts.runId }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
try {
|
||||||
|
const buf = await readFile(filePath);
|
||||||
|
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 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;
|
||||||
|
} catch (e) {
|
||||||
|
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- Lark client + ws listener ---
|
||||||
* Start the lark WebSocket client and route `im.message.receive_v1` events to
|
|
||||||
* `onMessage`. The ws connection lives in the background; this resolves the
|
|
||||||
* runtime handle.
|
|
||||||
*/
|
|
||||||
/** Build a lark Client (HTTP API surface). Used before the ws starts, so tools
|
|
||||||
* that need to call Feishu APIs can hold it from server boot. */
|
|
||||||
export function createLarkClient(config: FeishuConfig): lark.Client {
|
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||||
return new lark.Client({
|
return new lark.Client({
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
@@ -178,17 +165,12 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeishuListener {
|
|
||||||
readonly runtime: FeishuRuntime;
|
|
||||||
readonly stop: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Start the ws listener using an already-built client. Returns the runtime. */
|
|
||||||
export function startFeishuListenerWithClient(
|
export function startFeishuListenerWithClient(
|
||||||
config: FeishuConfig,
|
config: FeishuConfig,
|
||||||
client: lark.Client,
|
client: lark.Client,
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
): FeishuRuntime {
|
): FeishuRuntime {
|
||||||
const wsClient = new lark.WSClient({
|
const wsClient = new lark.WSClient({
|
||||||
appId: config.appId,
|
appId: config.appId,
|
||||||
@@ -197,23 +179,27 @@ export function startFeishuListenerWithClient(
|
|||||||
loggerLevel: lark.LoggerLevel.info,
|
loggerLevel: lark.LoggerLevel.info,
|
||||||
});
|
});
|
||||||
const rt: FeishuRuntime = { client, logger };
|
const rt: FeishuRuntime = { client, logger };
|
||||||
void wsClient.start({
|
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
||||||
eventDispatcher: new lark.EventDispatcher({}).register({
|
"im.message.receive_v1": async (data) => {
|
||||||
"im.message.receive_v1": async (data) => {
|
try { await onMessage(data as MessageReceiveEvent, rt); }
|
||||||
try {
|
catch (e) { logger.error({ err: e }, "feishu message handler threw"); }
|
||||||
await onMessage(data as MessageReceiveEvent, rt);
|
},
|
||||||
} catch (e) {
|
};
|
||||||
logger.error({ err: e }, "feishu message handler threw");
|
if (onCardAction !== undefined) {
|
||||||
}
|
handlers["card.action.trigger"] = async (data) => {
|
||||||
},
|
try { await onCardAction(data as CardActionEvent, rt); }
|
||||||
}),
|
catch (e) { logger.error({ err: e }, "feishu card action handler threw"); }
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||||
return rt;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startFeishuListener(
|
export function startFeishuListener(
|
||||||
config: FeishuConfig,
|
config: FeishuConfig,
|
||||||
logger: FastifyBaseLogger,
|
logger: FastifyBaseLogger,
|
||||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
|
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||||
): FeishuRuntime {
|
): FeishuRuntime {
|
||||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
|
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-17
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, sendFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||||
import type { ModelRegistry } from "../agent/models.js";
|
import type { ModelRegistry } from "../agent/models.js";
|
||||||
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
@@ -58,8 +58,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only react to text messages in group chats.
|
// Only react to text, file, or image messages in group chats.
|
||||||
if (msg.message_type !== "text") return;
|
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image") return;
|
||||||
const chatId = msg.chat_id;
|
const chatId = msg.chat_id;
|
||||||
if (chatId === "") return;
|
if (chatId === "") return;
|
||||||
|
|
||||||
@@ -86,13 +86,34 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = extractPrompt(msg);
|
let cleanPrompt: string | null = null;
|
||||||
if (prompt === null) return;
|
let downloadedFiles: string[] = [];
|
||||||
|
if (msg.message_type === "text") {
|
||||||
|
cleanPrompt = extractPrompt(msg);
|
||||||
|
} else if (msg.message_type === "file" || msg.message_type === "image") {
|
||||||
|
// Download the file/image to the workspace, build a prompt around it.
|
||||||
|
const project = await deps.prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { workspaceDir: true },
|
||||||
|
});
|
||||||
|
if (project === null) return;
|
||||||
|
try {
|
||||||
|
const content = JSON.parse(msg.content) as { file_key?: string; image_key?: string };
|
||||||
|
const key = content.file_key ?? content.image_key ?? "";
|
||||||
|
if (key !== "") {
|
||||||
|
const fileName = `${msg.message_type}-${Date.now()}.${msg.message_type === "image" ? "png" : "bin"}`;
|
||||||
|
const savePath = `${project.workspaceDir}/.cph/inbox/${fileName}`;
|
||||||
|
await downloadMessageFile(rt, msg.message_id, key, savePath);
|
||||||
|
downloadedFiles.push(savePath);
|
||||||
|
cleanPrompt = `用户发来一个文件: ${savePath}`;
|
||||||
|
}
|
||||||
|
} catch { return; }
|
||||||
|
}
|
||||||
|
if (cleanPrompt === null) return;
|
||||||
// Slash commands: session management, not agent runs. These bypass the
|
// Slash commands: session management, not agent runs. These bypass the
|
||||||
// lock — they don't create a run, so they can't conflict with one.
|
// lock — they don't create a run, so they can't conflict with one.
|
||||||
if (prompt.startsWith("/")) {
|
if (cleanPrompt.startsWith("/")) {
|
||||||
const cmd = prompt.split(/\s+/)[0];
|
const cmd = cleanPrompt.split(/\s+/)[0];
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case "/new": {
|
case "/new": {
|
||||||
await deps.prisma.agentSession.updateMany({
|
await deps.prisma.agentSession.updateMany({
|
||||||
@@ -151,7 +172,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||||
// slashes are left as literal text (extractRole returns null). Falls back
|
// slashes are left as literal text (extractRole returns null). Falls back
|
||||||
// to "draft" when no role is named — the registry degrades gracefully.
|
// to "draft" when no role is named — the registry degrades gracefully.
|
||||||
const { roleId: parsedRole, prompt: cleanPrompt } = extractRole(prompt, deps.models);
|
const { roleId: parsedRole } = extractRole(cleanPrompt, deps.models);
|
||||||
const roleId = parsedRole ?? "draft";
|
const roleId = parsedRole ?? "draft";
|
||||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||||
@@ -236,13 +257,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// Emoji reaction on the user's message (acknowledge receipt).
|
// Emoji reaction on the user's message (acknowledge receipt).
|
||||||
await reactToMessage(rt, msg.message_id, "THUMBSUP");
|
await reactToMessage(rt, msg.message_id, "THUMBSUP");
|
||||||
|
|
||||||
// Stream agent response as text messages — no cards, no truncation.
|
// Stream agent response as text-as-card messages — patchable, markdown,
|
||||||
// Send an initial placeholder, patch it as text arrives. If text exceeds
|
// no truncation. Lower throttle (400ms) for smoother streaming.
|
||||||
// 4000 chars, start a new message and continue there.
|
let currentMsgId = await sendTextMessage(rt, chatId, "");
|
||||||
let currentMsgId = await sendTextMessage(rt, chatId, "…");
|
|
||||||
let accText = "";
|
let accText = "";
|
||||||
let lastPatch = 0;
|
let lastPatch = 0;
|
||||||
const PATCH_INTERVAL = 800;
|
const PATCH_INTERVAL = 400;
|
||||||
const MAX_MSG_LEN = 4000;
|
const MAX_MSG_LEN = 4000;
|
||||||
|
|
||||||
const flushText = async () => {
|
const flushText = async () => {
|
||||||
@@ -250,12 +270,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
if (now - lastPatch < PATCH_INTERVAL || currentMsgId === null) return;
|
if (now - lastPatch < PATCH_INTERVAL || currentMsgId === null) return;
|
||||||
lastPatch = now;
|
lastPatch = now;
|
||||||
if (accText.length > MAX_MSG_LEN) {
|
if (accText.length > MAX_MSG_LEN) {
|
||||||
// Split: finalize current message, start a new one.
|
|
||||||
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
|
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
|
||||||
accText = accText.slice(MAX_MSG_LEN);
|
accText = accText.slice(MAX_MSG_LEN);
|
||||||
currentMsgId = await sendTextMessage(rt, chatId, accText || "…");
|
currentMsgId = await sendTextMessage(rt, chatId, accText);
|
||||||
} else {
|
} else {
|
||||||
await patchTextMessage(rt, currentMsgId, accText || "…");
|
await patchTextMessage(rt, currentMsgId, accText);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -290,6 +309,22 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
|
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 */ }
|
||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user