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:
2026-07-08 02:13:51 +08:00
parent 1ad78dbcce
commit eaa7fd4fd8
2 changed files with 140 additions and 119 deletions
+88 -102
View File
@@ -1,16 +1,13 @@
/**
* Feishu lark client + long-connection event dispatcher.
*
* Uses lark's WebSocket long-connection mode (no public callback endpoint). The
* `EventDispatcher` registers `im.message.receive_v1`; the SDK delivers the raw
* event to our handler. This version of the SDK does not auto-normalize on the
* 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.
* Handles: ws event receiving (im.message.receive_v1 + card.action.trigger),
* message sending (text-as-card for patchability), streaming patch, emoji
* reactions, file download/upload, and the card action callback.
*/
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";
export interface FeishuConfig {
@@ -24,12 +21,9 @@ export interface FeishuRuntime {
readonly logger: FastifyBaseLogger;
}
/** Raw `im.message.receive_v1` event payload (subset we use). */
/** Raw `im.message.receive_v1` event payload. */
export interface MessageReceiveEvent {
readonly header?: {
readonly event_id?: string;
readonly event_type?: string;
};
readonly header?: { readonly event_id?: string; readonly event_type?: string };
readonly message: {
readonly message_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
* because Feishu only allows patching interactive/card messages, not text). */
/** Card action trigger event (button clicks, select changes). */
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> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
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) },
});
return res.data?.message_id ?? null;
} catch {
return null;
}
} catch { 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> {
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> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
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> {
try {
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. */
export function buildStreamingCard(opts: {
readonly status: string;
readonly model: string;
readonly text: string;
readonly toolName: string | null;
}): unknown {
const elements: unknown[] = [
{ tag: "div", text: { tag: "lark_md", content: `**model:** ${opts.model}` } },
];
if (opts.toolName !== null) {
elements.push({ tag: "div", text: { tag: "lark_md", content: `🔧 *${opts.toolName}...*` } });
// --- File helpers ---
/** Download a file/image from a Feishu message to a local path. */
export async function downloadMessageFile(
rt: FeishuRuntime,
messageId: string,
fileKey: string,
savePath: string,
): Promise<void> {
await mkdir(dirname(savePath), { recursive: true });
try {
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. */
export function buildRunStatusCard(opts: {
readonly status: string;
readonly model: string;
readonly models: readonly { readonly id: string; readonly label: string }[];
readonly runId: 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 }),
},
],
},
],
/** Upload and send a file to a chat. */
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 } }> }
} };
};
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;
}
}
/**
* 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. */
// --- Lark client + ws listener ---
export function createLarkClient(config: FeishuConfig): lark.Client {
return new lark.Client({
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(
config: FeishuConfig,
client: lark.Client,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
const wsClient = new lark.WSClient({
appId: config.appId,
@@ -197,23 +179,27 @@ export function startFeishuListenerWithClient(
loggerLevel: lark.LoggerLevel.info,
});
const rt: FeishuRuntime = { client, logger };
void wsClient.start({
eventDispatcher: new lark.EventDispatcher({}).register({
"im.message.receive_v1": async (data) => {
try {
await onMessage(data as MessageReceiveEvent, rt);
} catch (e) {
logger.error({ err: e }, "feishu message handler threw");
}
},
}),
});
const handlers: Record<string, (data: unknown) => Promise<void>> = {
"im.message.receive_v1": async (data) => {
try { 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;
}
export function startFeishuListener(
config: FeishuConfig,
logger: FastifyBaseLogger,
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
): FeishuRuntime {
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage);
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
}