/** * Feishu lark client + long-connection event dispatcher. * * 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 { readonly appId: string; readonly appSecret: string; readonly botOpenId: string; } export interface FeishuRuntime { readonly client: lark.Client; readonly logger: FastifyBaseLogger; } /** Raw `im.message.receive_v1` event payload. */ export interface MessageReceiveEvent { readonly header?: { readonly event_id?: string; readonly event_type?: string }; readonly message: { readonly message_id: string; readonly chat_id: string; readonly chat_type: string; readonly message_type: string; readonly content: string; readonly mentions?: ReadonlyArray<{ readonly key: string; readonly id: { readonly open_id?: string }; readonly name: string; }>; }; readonly sender: { readonly sender_id: { readonly open_id?: string }; readonly sender_type: string; }; } /** 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 { im: { v1: { message: { create: (p: unknown) => Promise } } }; }; 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 messageIdFromResponse(res); } catch { return null; } } /** 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 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 { im: { v1: { message: { patch: (p: unknown) => Promise } } }; }; try { await client.im.v1.message.patch({ path: { message_id: messageId }, params: { msg_type: "interactive" }, data: { content: JSON.stringify(card) }, }); } catch (e) { rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchTextMessage failed"); } } /** React to a message with an emoji. */ export async function reactToMessage(rt: FeishuRuntime, messageId: string, emoji: string): Promise { try { await rt.client.request({ method: "POST", url: `/open-apis/im/v1/messages/${messageId}/reactions`, data: { reaction_type: { emoji_type: emoji } }, }); } catch (e) { rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "reactToMessage failed"); } } // --- 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"); } } /** 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 } message: { create: (p: unknown) => Promise } } }; }; 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 = 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 }) }, }); 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 { return new lark.Client({ appId: config.appId, appSecret: config.appSecret, appType: lark.AppType.SelfBuild, }); } 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, appSecret: config.appSecret, domain: lark.Domain.Feishu, loggerLevel: lark.LoggerLevel.info, }); const rt: FeishuRuntime = { client, logger }; 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, onCardAction); }