diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index 22865a4..edef08a 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -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 { 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 { 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 { 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 { 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 { + 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 { + 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; -} - -/** 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, + onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, ): 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 Promise> = { + "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, + onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise, ): FeishuRuntime { - return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage); + return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 54ba5bb..28ff748 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -10,7 +10,7 @@ */ import type { PrismaClient } from "@prisma/client"; 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 { runAgent, type ProjectContext } from "../agent/runner.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. - if (msg.message_type !== "text") return; + // Only react to text, file, or image messages in group chats. + if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image") return; const chatId = msg.chat_id; if (chatId === "") return; @@ -86,13 +86,34 @@ export function makeTriggerHandler(deps: TriggerDeps) { } } - const prompt = extractPrompt(msg); - if (prompt === null) return; - + let cleanPrompt: string | null = null; + 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 // lock — they don't create a run, so they can't conflict with one. - if (prompt.startsWith("/")) { - const cmd = prompt.split(/\s+/)[0]; + if (cleanPrompt.startsWith("/")) { + const cmd = cleanPrompt.split(/\s+/)[0]; switch (cmd) { case "/new": { await deps.prisma.agentSession.updateMany({ @@ -151,7 +172,7 @@ export function makeTriggerHandler(deps: TriggerDeps) { // ADR-0017: role-as-data. Parse a leading `/` 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, prompt: cleanPrompt } = extractRole(prompt, deps.models); + const { roleId: parsedRole } = 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"; @@ -236,13 +257,12 @@ export function makeTriggerHandler(deps: TriggerDeps) { // Emoji reaction on the user's message (acknowledge receipt). await reactToMessage(rt, msg.message_id, "THUMBSUP"); - // Stream agent response as text messages — no cards, no truncation. - // Send an initial placeholder, patch it as text arrives. If text exceeds - // 4000 chars, start a new message and continue there. - let currentMsgId = await sendTextMessage(rt, chatId, "…"); + // Stream agent response as text-as-card messages — patchable, markdown, + // no truncation. Lower throttle (400ms) for smoother streaming. + let currentMsgId = await sendTextMessage(rt, chatId, ""); let accText = ""; let lastPatch = 0; - const PATCH_INTERVAL = 800; + const PATCH_INTERVAL = 400; const MAX_MSG_LEN = 4000; const flushText = async () => { @@ -250,12 +270,11 @@ export function makeTriggerHandler(deps: TriggerDeps) { if (now - lastPatch < PATCH_INTERVAL || currentMsgId === null) return; lastPatch = now; if (accText.length > MAX_MSG_LEN) { - // Split: finalize current message, start a new one. await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN)); accText = accText.slice(MAX_MSG_LEN); - currentMsgId = await sendTextMessage(rt, chatId, accText || "…"); + currentMsgId = await sendTextMessage(rt, chatId, accText); } 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 } }); + + // 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) => { try {