/** * Feishu lark client + long-connection event dispatcher. * * Handles: ws event receiving (im.message.receive_v1 + card.action.trigger), * message sending, streaming patch via interactive cards, 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"; import { DEFAULT_MAX_MESSAGE_LENGTH, splitAtBoundary } from "./textStream.js"; import type { SenderNameCache } from "./senderCache.js"; export interface FeishuConfig { readonly appId: string; readonly appSecret: string; readonly botOpenId: string; } export interface FeishuRuntime { readonly client: lark.Client; readonly logger: FastifyBaseLogger; } export interface OutboundPayload { readonly msgType: string; readonly content: string; } export interface SendMessageOptions { readonly replyToMessageId?: string | undefined; } export async function withRetry( fn: () => Promise, options: { maxAttempts?: number; baseDelayMs?: number; shouldRetry?: (error: unknown) => boolean; } = {}, ): Promise { const maxAttempts = Math.max(1, options.maxAttempts ?? 3); const baseDelayMs = options.baseDelayMs ?? 1000; const shouldRetry = options.shouldRetry ?? (() => true); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { return await fn(); } catch (error) { if (attempt === maxAttempts || !shouldRetry(error)) { throw error; } const delay = baseDelayMs * 2 ** (attempt - 1); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw new Error("withRetry reached an unreachable state"); } /** Raw `im.message.receive_v1` event payload. */ export interface MessageReceiveEvent { /** Feishu's SDK dispatcher flattens v2 headers onto the top-level payload. */ 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 root_id?: string; readonly parent_id?: string; readonly thread_id?: string; readonly create_time?: string; readonly update_time?: 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; } interface ContactBasicUser { readonly name?: string | undefined; readonly i18n_name?: { readonly zh_cn?: string | undefined; readonly en_us?: string | undefined; readonly ja_jp?: string | undefined; } | undefined; } type ContactUserBasicBatchResponse = { readonly data?: { readonly users?: readonly ContactBasicUser[] | undefined; } | undefined; } | null; // --- Message helpers --- export function buildOutboundPayload(content: string): OutboundPayload { if (containsMarkdownTable(content)) { return { msgType: "text", content: JSON.stringify({ text: content }) }; } if (containsMarkdownSyntax(content)) { return { msgType: "post", content: JSON.stringify({ zh_cn: { content: _buildMarkdownPostRows(content) } }), }; } return { msgType: "text", content: JSON.stringify({ text: content }) }; } export function _buildMarkdownPostRows(content: string): unknown[][] { if (!content.includes("```")) { return [[{ tag: "md", text: content }]]; } const rows: unknown[][] = []; const codeBlockPattern = /```[\s\S]*?```/g; let cursor = 0; let match: RegExpExecArray | null; while ((match = codeBlockPattern.exec(content)) !== null) { if (match.index > cursor) { rows.push([{ tag: "md", text: content.slice(cursor, match.index) }]); } rows.push([{ tag: "md", text: match[0] }]); cursor = match.index + match[0].length; } if (rows.length === 0) { return [[{ tag: "md", text: content }]]; } if (cursor < content.length) { rows.push([{ tag: "md", text: content.slice(cursor) }]); } return rows; } export function _stripMarkdownToPlainText(text: string): string { return text .replace(/```[^\n`]*\n?([\s\S]*?)```/g, "$1") .replace(/`([^`\n]+)`/g, "$1") .replace(/!\[([^\]\n]*)\]\([^)]+\)/g, "$1") .replace(/\[([^\]\n]+)\]\([^)]+\)/g, "$1") .replace(/^ {0,3}#{1,6}\s+/gm, "") .replace(/^ {0,3}>\s?/gm, "") .replace(/^(\s*)[-*+]\s+/gm, "$1") .replace(/^(\s*)\d+[.)]\s+/gm, "$1") .replace(/\*\*([^*\n]+)\*\*/g, "$1") .replace(/__([^_\n]+)__/g, "$1") .replace(/~~([^~\n]+)~~/g, "$1") .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1$2") .replace(/(^|[^_])_([^_\n]+)_/g, "$1$2"); } export function parsePostMessage(content: string): { text: string; imageKeys: string[]; fileKeys: string[] } { const parsed = parseJsonObject(content); const root = parsed === null ? null : postRoot(parsed); const locale = root === null ? null : selectPostLocale(root); if (locale === null) { return { text: "", imageKeys: [], fileKeys: [] }; } const imageKeys: string[] = []; const fileKeys: string[] = []; const rows = Array.isArray(locale["content"]) ? locale["content"] : []; const body = rows .map((row) => { const elements = Array.isArray(row) ? row : [row]; return elements.map((element) => renderPostElement(element, imageKeys, fileKeys)).join(""); }) .join("\n"); const title = stringField(locale, "title"); const text = title === undefined || title === "" ? body : body === "" ? `# ${title}` : `# ${title}\n\n${body}`; return { text, imageKeys, fileKeys }; } /** Send a text message using the best Feishu message type for the content. */ export async function sendTextMessage( rt: FeishuRuntime, chatId: string, text: string, options?: SendMessageOptions, ): Promise { const payload = buildOutboundPayload(text); try { return await sendMessagePayload(rt, chatId, payload, options); } catch (e) { if (payload.msgType === "post" && isPostContentFormatError(e)) { try { return await sendMessagePayload( rt, chatId, { msgType: "text", content: JSON.stringify({ text: _stripMarkdownToPlainText(text) }) }, options, ); } catch { return null; } } return null; } } /** Send long text as multiple Feishu messages. Returns the last successful message_id. */ export async function sendLongText( rt: FeishuRuntime, chatId: string, text: string, options?: SendMessageOptions, ): Promise { let lastMessageId: string | null = null; for (const chunk of splitAtBoundary(text, DEFAULT_MAX_MESSAGE_LENGTH)) { const messageId = await sendTextMessage(rt, chatId, chunk, options); if (messageId !== null) { lastMessageId = messageId; } } return lastMessageId; } /** Send a plain text message (fire-and-forget). */ export async function sendText( rt: FeishuRuntime, chatId: string, text: string, options?: SendMessageOptions, ): Promise { await sendTextMessage(rt, chatId, text, options); } /** Send an interactive card message (always patchable). Used for streaming. */ export async function sendInteractiveCardMessage( rt: FeishuRuntime, chatId: string, text: string, options?: SendMessageOptions, ): Promise { const payload = buildOutboundPayload(text); const card = buildInteractiveCardFromOutboundPayload(payload); try { return await sendMessagePayload( rt, chatId, { msgType: "interactive", content: JSON.stringify(card) }, options, ); } catch { return null; } } /** Patch a text-as-card message in-place (streaming updates). */ export async function patchTextMessage(rt: FeishuRuntime, messageId: string, text: string): Promise { const payload = buildOutboundPayload(text); const card = buildInteractiveCardFromOutboundPayload(payload); 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"); } } /** Send a raw interactive card (arbitrary JSON). Returns message_id on success. */ export async function sendCard( rt: FeishuRuntime, chatId: string, card: Record, options?: SendMessageOptions, ): Promise { try { return await sendMessagePayload( rt, chatId, { msgType: "interactive", content: JSON.stringify(card) }, options, ); } catch { return null; } } /** Patch an interactive card in-place with raw JSON. */ export async function patchCard(rt: FeishuRuntime, messageId: string, card: Record): Promise { 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) }, }); return true; } catch (e) { rt.logger.warn({ messageId, err: e instanceof Error ? e.message : String(e) }, "patchCard failed"); return false; } } /** Send an approval card with buttons. Returns message_id on success. */ export async function sendApprovalCard( rt: FeishuRuntime, chatId: string, title: string, body: string, buttons: ReadonlyArray<{ label: string; value: string; style?: "primary" | "default" | "danger" }>, options?: SendMessageOptions, ): Promise { const card = { config: { wide_screen_mode: true }, header: { title: { content: title, tag: "plain_text" }, template: "orange" }, elements: [ { tag: "markdown", content: body }, { tag: "action", actions: buttons.map((button) => ({ tag: "button", text: { tag: "plain_text", content: button.label }, type: button.style ?? "default", value: { approval_action: button.value }, })), }, ], }; try { return await sendMessagePayload( rt, chatId, { msgType: "interactive", content: JSON.stringify(card) }, options, ); } catch (e) { rt.logger.warn({ chatId, err: e instanceof Error ? e.message : String(e) }, "sendApprovalCard failed"); return null; } } /** Update a card to show it has been resolved. */ export async function resolveCard( rt: FeishuRuntime, messageId: string, icon: string, label: string, template: "green" | "red", resolvedBy: string, ): Promise { const card = { config: { wide_screen_mode: true }, header: { title: { content: `${icon} ${label}`, tag: "plain_text" }, template }, elements: [{ tag: "markdown", content: `${icon} **${label}** by ${resolvedBy}` }], }; 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) }, "resolveCard 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"); } } /** Add a reaction to a message, returning the reaction_id (needed for later removal). */ export async function addReaction(rt: FeishuRuntime, messageId: string, emojiType: string): Promise { try { const res = await rt.client.request({ method: "POST", url: `/open-apis/im/v1/messages/${messageId}/reactions`, data: { reaction_type: { emoji_type: emojiType } }, }) as ReactionCreateResponse; const reactionId = reactionIdFromResponse(res); if (reactionId === null) { rt.logger.warn({ messageId, emojiType }, "addReaction failed: response missing reaction_id"); } return reactionId; } catch (e) { rt.logger.warn({ messageId, emojiType, err: e instanceof Error ? e.message : String(e) }, "addReaction failed"); return null; } } /** Remove a previously added reaction by its reaction_id. */ export async function removeReaction(rt: FeishuRuntime, messageId: string, reactionId: string): Promise { try { await rt.client.request({ method: "DELETE", url: `/open-apis/im/v1/messages/${messageId}/reactions/${reactionId}`, }); return true; } catch (e) { rt.logger.warn({ messageId, reactionId, err: e instanceof Error ? e.message : String(e) }, "removeReaction failed"); return false; } } /** Resolve a Feishu sender display name from open_id, optionally using a cache. */ export async function resolveSenderName( rt: FeishuRuntime, openId: string, cache?: SenderNameCache, ): Promise { if (openId === "") return null; const fetchName = async (id: string): Promise => { try { const res = await rt.client.contact.v3.user.basicBatch({ params: { user_id_type: "open_id" }, data: { user_ids: [id] }, }) as ContactUserBasicBatchResponse; const user = res?.data?.users?.[0]; return user === undefined ? null : contactBasicUserName(user); } catch (e) { rt.logger.warn({ openId: id, err: e instanceof Error ? e.message : String(e) }, "resolveSenderName failed"); return null; } }; if (cache !== undefined) { return cache.getOrFetch(openId, fetchName); } return fetchName(openId); } function contactBasicUserName(user: ContactBasicUser): string | null { const candidates = [ user.name, user.i18n_name?.zh_cn, user.i18n_name?.en_us, user.i18n_name?.ja_jp, ]; for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim() !== "") return candidate.trim(); } return null; } // --- 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 withRetry(async () => { return rt.client.request({ method: "GET", url: `/open-apis/im/v1/messages/${messageId}/resources/${fileKey}`, params: { type: "file" }, responseType: "arraybuffer", }) as Promise<{ 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, options?: SendMessageOptions, ): Promise { const client = rt.client as unknown as { im: { v1: { file: { 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 withRetry(async () => 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 messageId = await withRetry(async () => sendMessagePayload(rt, chatId, { msgType: "file", content: JSON.stringify({ file_key: fileKey }) }, options), ); 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; } } async function sendMessagePayload( rt: FeishuRuntime, chatId: string, payload: OutboundPayload, options?: SendMessageOptions, ): Promise { const client = rt.client as unknown as { im: { v1: { message: { create: (p: unknown) => Promise; reply: (p: unknown) => Promise; } } }; }; const replyToMessageId = normalizeReplyMessageId(options?.replyToMessageId); if (replyToMessageId !== null) { const res = await client.im.v1.message.reply({ path: { message_id: replyToMessageId }, data: { msg_type: payload.msgType, content: payload.content, }, }); return messageIdFromResponse(res); } const res = await client.im.v1.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, msg_type: payload.msgType, content: payload.content }, }); return messageIdFromResponse(res); } function normalizeReplyMessageId(messageId: string | undefined): string | null { return messageId === undefined || messageId === "" ? null : messageId; } 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; type ReactionCreateResponse = { readonly reaction_id?: string | undefined; readonly data?: { readonly reaction_id?: 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; } function reactionIdFromResponse(res: ReactionCreateResponse): string | null { return res?.data?.reaction_id ?? res?.reaction_id ?? null; } function parseJsonObject(content: string): Record | null { try { const parsed = JSON.parse(content) as unknown; return isRecord(parsed) ? parsed : null; } catch { return null; } } function postRoot(parsed: Record): Record { const post = parsed["post"]; return isRecord(post) ? post : parsed; } function selectPostLocale(root: Record): Record | null { if (Array.isArray(root["content"])) return root; for (const locale of ["zh_cn", "en_us"]) { const candidate = root[locale]; if (isPostLocale(candidate)) return candidate; } for (const candidate of Object.values(root)) { if (isPostLocale(candidate)) return candidate; } return null; } function isPostLocale(value: unknown): value is Record { return isRecord(value) && (Array.isArray(value["content"]) || typeof value["title"] === "string"); } function renderPostElement(element: unknown, imageKeys: string[], fileKeys: string[]): string { if (!isRecord(element)) return ""; switch (stringField(element, "tag")) { case "text": return applyTextStyles(stringField(element, "text") ?? "", element); case "a": return renderLink(element); case "at": return renderMention(element); case "img": case "image": pushStringField(imageKeys, element, "image_key"); return "[Image]"; case "media": case "file": case "audio": case "video": pushStringField(fileKeys, element, "file_key"); return renderAttachmentPlaceholder(element); case "code": case "code_block": return renderCodeBlock(element); case "br": return "\n"; case "hr": case "divider": return "\n\n---\n\n"; case "emotion": case "emoji": return renderEmoji(element); case "md": return stringField(element, "text") ?? ""; default: return stringField(element, "text") ?? ""; } } function renderLink(element: Record): string { const text = stringField(element, "text") ?? ""; const href = stringField(element, "href") ?? ""; if (href === "") return text; return `[${text}](${href})`; } function renderMention(element: Record): string { if (stringField(element, "user_id") === "@_all") return "@all"; const name = stringField(element, "user_name") ?? stringField(element, "name") ?? stringField(element, "text") ?? stringField(element, "user_id") ?? ""; return name === "" ? "" : `@${name.replace(/^@/, "")}`; } function renderAttachmentPlaceholder(element: Record): string { const fileName = stringField(element, "file_name") ?? stringField(element, "name"); return fileName === undefined || fileName === "" ? "[Attachment]" : `[Attachment: ${fileName}]`; } function renderCodeBlock(element: Record): string { const language = stringField(element, "language") ?? ""; const text = stringField(element, "text") ?? ""; return `\`\`\`${language}\n${text}\n\`\`\``; } function renderEmoji(element: Record): string { const emojiType = stringField(element, "emoji_type") ?? stringField(element, "emotion_key") ?? stringField(element, "name") ?? stringField(element, "text"); return emojiType === undefined || emojiType === "" ? "" : `:${emojiType}:`; } function applyTextStyles(text: string, element: Record): string { let rendered = text; if (hasPostStyle(element, "bold")) rendered = `**${rendered}**`; if (hasPostStyle(element, "italic")) rendered = `*${rendered}*`; if (hasPostStyle(element, "strikethrough", "strike", "line_through", "lineThrough")) rendered = `~~${rendered}~~`; if (hasPostStyle(element, "code", "inline_code", "inlineCode")) rendered = `\`${rendered}\``; return rendered; } function hasPostStyle(element: Record, ...names: string[]): boolean { const style = element["style"] ?? element["styles"]; if (styleMatches(style, names)) return true; return names.some((name) => element[name] === true); } function styleMatches(style: unknown, names: readonly string[]): boolean { if (Array.isArray(style)) { return style.some((entry) => typeof entry === "string" && names.includes(entry)); } if (typeof style === "string") { const tokens = style.split(/[\s,]+/).filter((token) => token !== ""); return tokens.some((token) => names.includes(token)); } if (isRecord(style)) { return names.some((name) => style[name] === true || style[name] === "true"); } return false; } function pushStringField(target: string[], record: Record, field: string): void { const value = stringField(record, field); if (value !== undefined && value !== "") target.push(value); } function stringField(record: Record, field: string): string | undefined { const value = record[field]; return typeof value === "string" ? value : undefined; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function containsMarkdownSyntax(content: string): boolean { const patterns = [ /^ {0,3}#{1,6}\s+\S/m, /^ {0,3}>\s+\S/m, /^ {0,3}(?:[-*+]\s+\S|\d+[.)]\s+\S)/m, /```/, /`[^`\n]+`/, /\*\*[^*\n]+\*\*/, /__[^_\n]+__/, /~~[^~\n]+~~/, /(^|[^*])\*[^*\s][^*\n]*\*(?!\*)/, /(^|[^_])_[^_\s][^_\n]*_(?!_)/, /\[[^\]\n]+\]\([^)]+\)/, ]; return patterns.some((pattern) => pattern.test(content)); } function containsMarkdownTable(content: string): boolean { const lines = content.split(/\r?\n/); for (let i = 0; i < lines.length - 1; i += 1) { const current = lines[i]; const next = lines[i + 1]; if (current !== undefined && next !== undefined && isTableHeaderLine(current) && isTableSeparatorLine(next)) { return true; } } return false; } function isTableHeaderLine(line: string): boolean { const trimmed = line.trim(); return trimmed.startsWith("|") && trimmed.includes("|", 1); } function isTableSeparatorLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed.startsWith("|")) return false; const cells = trimmed.replace(/^\|/, "").replace(/\|$/, "").split("|"); return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); } function buildInteractiveCardFromOutboundPayload(payload: OutboundPayload): { elements: Array<{ tag: "div"; text: { tag: "lark_md"; content: string } }> } { return { elements: rowsFromOutboundPayload(payload).map((row) => ({ tag: "div", text: { tag: "lark_md", content: row.map(markdownTextFromPostElement).join("") }, })), }; } function rowsFromOutboundPayload(payload: OutboundPayload): unknown[][] { if (payload.msgType !== "post") { return [[{ tag: "md", text: textFromTextPayload(payload.content) }]]; } try { const parsed = JSON.parse(payload.content) as { zh_cn?: { content?: unknown } }; return Array.isArray(parsed.zh_cn?.content) ? parsed.zh_cn.content as unknown[][] : [[{ tag: "md", text: "" }]]; } catch { return [[{ tag: "md", text: "" }]]; } } function textFromTextPayload(content: string): string { try { const parsed = JSON.parse(content) as { text?: unknown }; return typeof parsed.text === "string" ? parsed.text : content; } catch { return content; } } function markdownTextFromPostElement(element: unknown): string { if (typeof element !== "object" || element === null) return ""; const text = (element as { text?: unknown }).text; return typeof text === "string" ? text : ""; } function isPostContentFormatError(error: unknown): boolean { return errorText(error).includes("content format of the post type is incorrect"); } function errorText(error: unknown): string { if (error instanceof Error) { return `${error.message} ${errorText((error as { response?: unknown }).response)}`; } if (typeof error === "string") return error; if (typeof error === "object" && error !== null) { try { return JSON.stringify(error); } catch { return String(error); } } return String(error); } // --- 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) => { void Promise.resolve() .then(() => 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); }