/** * feishu-client.ts — 飞书 WebSocket 客户端 * * 基于 @larksuiteoapi/node-sdk 的 Client + WSClient + EventDispatcher 封装。 * 负责:WebSocket 长连接管理、事件接收、消息发送、媒体收发、 * 消息去重、Reaction 输入指示、交互卡片。 */ import * as Lark from "@larksuiteoapi/node-sdk"; import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { FeishuConfig, BridgeStatus, InboundMessageContext, InboundResource } from "./types.js"; import { FeishuCardKitClient } from "./feishu/cardkit-client.js"; import type { MetricsCollector } from "./monitoring/metrics.js"; import { splitTextCards } from "./cardkit/markdown.js"; import { debug as _log, warn as _warn } from "./log.js"; export type { InboundResource } from "./types.js"; // ─── 常量 ───────────────────────────────────────────── /** 消息去重 TTL(12 小时) */ const DEDUP_TTL_MS = 12 * 60 * 60 * 1000; /** 去重最大条目 */ const DEDUP_MAX_ENTRIES = 5000; /** 去重定期清理间隔(5 分钟) */ const DEDUP_SWEEP_INTERVAL = 5 * 60 * 1000; /** 消息过期判定(30 分钟) */ const MESSAGE_EXPIRY_MS = 30 * 60 * 1000; /** 媒体文件临时目录 */ const MEDIA_TEMP_DIR = join(tmpdir(), "feishu-media"); /** 飞书 Reaction emoji 类型 */ const REACTION_TYPING = "Typing"; const REACTION_CROSS_MARK = "CrossMark"; // ─── 飞书事件类型 ─────────────────────────────────────── /** SDK 传入的 im.message.receive_v1 事件数据结构 */ interface FeishuMessageEvent { sender: { sender_id: { open_id?: string; user_id?: string; union_id?: string; }; sender_type?: string; tenant_key?: string; }; message: { message_id: string; root_id?: string; parent_id?: string; create_time?: string; update_time?: string; chat_id: string; thread_id?: string; chat_type: "p2p" | "group"; message_type: string; content: string; // JSON 字符串 mentions?: Array<{ key: string; id: { open_id?: string; user_id?: string; union_id?: string }; name: string; tenant_key?: string; }>; user_agent?: string; }; } // ─── 导出类型 ────────────────────────────────────────── /** 入站消息中可能携带的资源描述 */ // ─── 富文本(post)解析 ───────────────────────────────── /** post 消息的一个内容片段 */ interface PostElement { tag?: string; text?: string; href?: string; user_id?: string; user_name?: string; image_key?: string; file_key?: string; language?: string; emoji_type?: string; } /** * 解析飞书富文本(post)为纯文本。 * * `content` 是二维数组:外层每项一行,内层是该行的片段。 * 行内片段直接相接,行之间补换行 —— 否则有序列表、多行段落 * 会被压成一行(历史 bug)。 * * 匹配到的图片会追加进 `resources`。 */ export function parsePostContent(parsed: unknown, resources: InboundResource[]): string { const root = parsed as Record | null | undefined; // 兼容两种 content 结构:旧版按 locale(zh_cn/en_us/ja_jp)包装, // 新版富文本编辑器(如有序列表 Ctrl+Shift+7)直接暴露 {title, content, content_v2},无 locale 键 const body = root?.zh_cn ?? root?.en_us ?? root?.ja_jp ?? root; const lines: string[] = []; if (body?.title) lines.push(String(body.title)); // 新版富文本同时带 content 与 content_v2(两者内容一致);content 缺失/为空时回退到 content_v2 const content = Array.isArray(body?.content) && body.content.length > 0 ? body.content : Array.isArray(body?.content_v2) ? body.content_v2 : null; if (Array.isArray(content)) { for (const row of content) { if (!Array.isArray(row)) continue; const segments = (row as PostElement[]).map((elem) => renderPostElement(elem, resources)); lines.push(segments.join("")); } } // 保留行内空行(段落间隔),仅去掉首尾 return lines.join("\n").replace(/^\n+|\n+$/g, ""); } /** 渲染单个 post 片段为纯文本;副作用是把图片/媒体记入 resources */ function renderPostElement(elem: PostElement | null | undefined, resources: InboundResource[]): string { switch (elem?.tag) { case "text": case "md": case "code": // 行内代码:飞书客户端会把反引号内容转成该片段,缺失则整行内容丢失 return elem.text ?? ""; case "code_block": // 代码块:保留语言与内容,输出 markdown 代码块语法 return elem.text ? "```" + (elem.language ? elem.language + "\n" : "") + elem.text + "\n```" : ""; case "a": // 保留 URL,否则模型只看到锚文本无法访问链接 if (elem.href) return elem.text ? `[${elem.text}](${elem.href})` : elem.href; return elem.text ?? ""; case "at": return `@${elem.user_name ?? elem.user_id ?? ""}`; case "img": if (elem.image_key) resources.push({ type: "image", fileKey: elem.image_key }); return "[图片]"; case "media": if (elem.file_key) resources.push({ type: "file", fileKey: elem.file_key }); return "[媒体]"; case "emotion": return "[表情]"; case "hr": // 水平分割线:保留分隔语义,避免该行被整体丢弃 return "---"; default: // 未知 tag 或 null/undefined 元素:有文本则兜底保留,防止内容静默丢失(与官方 SDK 一致) return elem?.text ?? ""; } } // ─── FeishuClient 类 ─────────────────────────────────── export class FeishuClient { private client: Lark.Client; private wsClient: Lark.WSClient | null = null; private abortController: AbortController | null = null; private status: BridgeStatus = "disconnected"; // 消息去重 private dedupMap: Map = new Map(); private dedupSweepTimer: ReturnType | null = null; // Bot 身份(连接后探测) private botOpenId: string = ""; // 回调 — 扩展为包含资源列表 private onMessageCallback: | ((context: InboundMessageContext) => void) | null = null; private onStatusChangeCallback: ((status: BridgeStatus) => void) | null = null; private onCardActionCallback: ((action: { clarifyId: string; choice: string; senderOpenId: string }) => void) | null = null; // Reaction 跟踪:chatId → { msgId, reactionId } private typingMessages: Map = new Map(); constructor(private config: FeishuConfig) { const domain = config.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu; this.client = new Lark.Client({ appId: config.appId, appSecret: config.appSecret, appType: Lark.AppType.SelfBuild, domain, }); // 确保临时目录存在 if (!existsSync(MEDIA_TEMP_DIR)) { mkdirSync(MEDIA_TEMP_DIR, { recursive: true }); } } // ─── 公开 API ─────────────────────────────────────── /** 连接飞书 WebSocket 长连接 */ async connect(): Promise { if (this.status === "connected" || this.status === "connecting") { _log("Already connected or connecting, skip"); return; } this.setStatus("connecting"); _log("Connecting to Feishu WebSocket..."); try { this.abortController = new AbortController(); const dispatcher = new Lark.EventDispatcher({ encryptKey: this.config.encryptKey ?? "", verificationToken: this.config.verificationToken ?? "", }); dispatcher.register({ "im.message.receive_v1": (data: any) => { this.handleInboundMessage(data); }, "im.message.message_read_v1": async () => {}, "im.message.reaction.created_v1": async () => {}, "im.message.reaction.deleted_v1": async () => {}, "im.chat.member.bot.added_v1": async () => {}, "im.chat.member.bot.deleted_v1": async () => {}, "im.chat.access_event.bot_p2p_chat_entered_v1": async () => {}, "card.action.trigger": (data: any) => { const value = data?.action?.value; const parsed = typeof value === "string" ? (() => { try { return JSON.parse(value); } catch { return {}; } })() : (value ?? {}); const clarifyId = parsed.clarify_id ?? data?.action?.name; // 下拉框(select_static):选中值经 action.option 回传,元素 value 携带 clarify_id const choice = data?.action?.option ?? parsed.choice; const senderOpenId = data?.operator?.open_id ?? ""; if (typeof clarifyId === "string" && typeof choice === "string") this.onCardActionCallback?.({ clarifyId, choice, senderOpenId }); }, }); if (this.wsClient) { try { (this.wsClient as any).close({ force: true }); } catch { /* ignore */ } } const domain = this.config.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu; this.wsClient = new Lark.WSClient({ appId: this.config.appId, appSecret: this.config.appSecret, domain, // info 会刷 `[ws] ws client ready`;默认只保留 warn/error loggerLevel: Lark.LoggerLevel.warn, autoReconnect: true, handshakeTimeoutMs: 15000, wsConfig: { pingTimeout: 30, }, onReady: () => { _log("WSClient: first connection established"); }, onReconnecting: () => { _warn("WSClient: connection lost, reconnecting..."); this.setStatus("connecting"); }, onReconnected: () => { _log("WSClient: reconnected successfully"); this.setStatus("connected"); }, onError: (err: Error) => { _warn("WSClient: terminal error:", err.message); this.setStatus("error"); }, }); this.patchCardEvents(); this.startDedupSweep(); try { const response = await this.client.request({ url: "/open-apis/bot/v3/info", method: "GET" }) as any; this.botOpenId = response?.bot?.open_id ?? ""; } catch (err) { _warn("Unable to resolve bot open_id; group mention detection may reject messages:", err); } await this.wsClient.start({ eventDispatcher: dispatcher }); this.setStatus("connected"); _log("Feishu WebSocket connected"); } catch (err) { _warn("Connect failed:", err); this.setStatus("error"); throw err; } } /** 断开连接 */ disconnect(): void { _log("Disconnecting..."); if (this.abortController) { this.abortController.abort(); this.abortController = null; } if (this.wsClient) { try { (this.wsClient as any).close({ force: true }); } catch { /* ignore */ } this.wsClient = null; } if (this.dedupSweepTimer) { clearInterval(this.dedupSweepTimer); this.dedupSweepTimer = null; } // 清除所有 typing reactions for (const [chatId, entry] of this.typingMessages) { this.removeReactionById(entry.msgId, entry.reactionId).catch(() => {}); } this.typingMessages.clear(); this.setStatus("disconnected"); } getStatus(): BridgeStatus { return this.status; } createCardKitClient(metrics?: MetricsCollector): FeishuCardKitClient { return new FeishuCardKitClient(this.client, undefined, metrics); } async checkCardKitAvailability(): Promise { try { const cardkit = this.createCardKitClient(); const cardId = await cardkit.createCard({ schema: "2.0", config: { streaming_mode: true }, body: { elements: [{ tag: "markdown", element_id: "doctor_stream", content: "probe" }] } }); await cardkit.updateElement(cardId, "doctor_stream", "probe-ok", 1); await cardkit.updateSettings(cardId, { streaming_mode: false }, 2); return true; } catch (error: any) { const code = error?.code ?? error?.response?.data?.code; const operation = error?.operation ? `${error.operation} ` : ""; const message = error?.response?.data?.msg ?? error?.message ?? String(error); return `${operation}code=${code ?? "unknown"}, msg=${message}`; } } private ensureApiSuccess(response: any, operation: string): any { const code = Number(response?.code ?? 0); if (!Number.isFinite(code) || code === 0) return response; const error = Object.assign(new Error(`${operation}: ${response?.msg ?? `Feishu code ${code}`}`), { code, msg: response?.msg, response: { data: response } }); throw error; } // ─── 消息发送 ────────────────────────────────────────── /** 发送文本消息到飞书(回复模式优先,使用 interactive 卡片格式) */ async sendMessage(chatId: string, text: string, replyToMsgId?: string): Promise { const chunks = splitTextCards(text); for (let index = 0; index < chunks.length; index++) { const content = JSON.stringify(FeishuClient.buildTextCard(chunks[index])); const replyId = index === 0 ? replyToMsgId : undefined; try { if (replyId) { const response = await this.client.im.message.reply({ path: { message_id: replyId }, data: { content, msg_type: "interactive" }, }); this.ensureApiSuccess(response, "im.message.reply"); } else { const response = await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content, msg_type: "interactive" }, }); this.ensureApiSuccess(response, "im.message.create"); } } catch (err: any) { if (!replyId || (err?.code !== 230011 && err?.code !== 231003)) throw err; _warn("Reply failed (message withdrawn), falling back to create"); const response = await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content, msg_type: "interactive" }, }); this.ensureApiSuccess(response, "im.message.create"); } } _log(`Message sent to ${chatId}${replyToMsgId ? ` (reply to ${replyToMsgId})` : ""}, chunks=${chunks.length}`); } /** 发送交互卡片消息 */ async sendCard( chatId: string, card: Record, replyToMsgId?: string, ): Promise { const content = JSON.stringify(card); try { if (replyToMsgId) { const resp = this.ensureApiSuccess(await this.client.im.message.reply({ path: { message_id: replyToMsgId }, data: { content, msg_type: "interactive" }, }), "im.message.reply"); return resp?.data?.message_id ?? null; } else { const resp = this.ensureApiSuccess(await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content, msg_type: "interactive" }, }), "im.message.create"); return resp?.data?.message_id ?? null; } } catch (err: any) { _warn("Send card failed:", err?.message ?? err); // 抛出让调用方(澄清卡/降级卡)能拿到真实原因;提取飞书 HTTP 错误里的 code/msg const detail = extractApiErrorDetail(err); throw err instanceof Error ? new Error(detail ? `${err.message} | ${detail}` : err.message) : new Error(String(err)); } } /** 发送可复用的状态卡片,返回后续 PATCH 所需的消息 id */ async sendStatusCard(chatId: string, text: string, replyToMsgId?: string): Promise { return this.sendCard(chatId, FeishuClient.buildTextCard(text), replyToMsgId); } /** 更新(PATCH)已有的卡片消息 */ async updateCard(messageId: string, card: Record): Promise { const response = await this.client.im.message.patch({ path: { message_id: messageId }, data: { content: JSON.stringify(card) }, }); this.ensureApiSuccess(response, "im.message.patch"); } /** 更新状态卡片正文 */ async updateTextCard(messageId: string, text: string): Promise { await this.updateCard(messageId, FeishuClient.buildTextCard(text)); } // ─── 媒体收发 ────────────────────────────────────────── /** 下载消息中的资源(图片/文件)到本地临时目录 */ async downloadResource( messageId: string, fileKey: string, resourceType: string, fileName?: string, ): Promise { try { _log(`Downloading resource: ${fileKey} from message ${messageId}`); const resp = await this.client.im.messageResource.get({ path: { message_id: messageId, file_key: fileKey }, params: { type: resourceType as any }, }); if (!resp) return null; // SDK 返回 { writeFile, getReadableStream, headers } // 生成文件名 const ext = resourceType === "image" ? ".png" : resourceType === "audio" ? ".ogg" : ""; const safeName = (fileName && fileName.length > 0) ? fileName.replace(/[^a-zA-Z0-9._-]/g, "_") : `${fileKey}${ext}`; const localPath = join(MEDIA_TEMP_DIR, `${Date.now()}-${safeName}`); // 优先使用 writeFile()(SDK 原生写入磁盘) if (typeof resp.writeFile === "function") { await resp.writeFile(localPath); _log(`Resource downloaded via writeFile to ${localPath}`); return localPath; } // 回退:使用 getReadableStream() 手动收集 if (typeof resp.getReadableStream === "function") { const stream = resp.getReadableStream(); const chunks: Buffer[] = []; for await (const chunk of stream as AsyncIterable) { chunks.push(Buffer.from(chunk)); } const buffer = Buffer.concat(chunks); writeFileSync(localPath, buffer); _log(`Resource downloaded via stream to ${localPath} (${buffer.length} bytes)`); return localPath; } _warn("No writeFile or getReadableStream on response"); return null; } catch (err) { _warn("Download resource failed:", err); return null; } } /** 上传图片到飞书,返回 image_key */ async uploadImage(filePath: string): Promise { try { const { createReadStream } = await import("node:fs"); const stream = createReadStream(filePath); const resp = await this.client.im.image.create({ data: { image_type: "message" as any, image: stream as any, }, }); // SDK 返回 { image_key } | null,image_key 在顶层 const imageKey = resp?.image_key ?? null; _log(`Image uploaded: ${imageKey}`); return imageKey; } catch (err) { _warn("Upload image failed:", err); return null; } } /** 上传文件到飞书,返回 file_key */ async uploadFile(filePath: string, fileName: string, fileType: string = "stream"): Promise { try { const { createReadStream } = await import("node:fs"); const stream = createReadStream(filePath); const resp = await this.client.im.file.create({ data: { file_type: fileType as any, file_name: fileName, file: stream as any, }, }); // SDK 返回 { file_key } | null,file_key 在顶层 const fileKey = resp?.file_key ?? null; _log(`File uploaded: ${fileKey}`); return fileKey; } catch (err) { _warn("Upload file failed:", err); return null; } } /** 发送图片消息(通过 image_key) */ async sendImage(chatId: string, imageKey: string, replyToMsgId?: string): Promise { const content = JSON.stringify({ image_key: imageKey }); if (replyToMsgId) { await this.client.im.message.reply({ path: { message_id: replyToMsgId }, data: { content, msg_type: "image" }, }); } else { await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content, msg_type: "image" }, }); } } /** 发送文件消息(通过 file_key) */ async sendFile(chatId: string, fileKey: string, replyToMsgId?: string): Promise { const content = JSON.stringify({ file_key: fileKey }); if (replyToMsgId) { await this.client.im.message.reply({ path: { message_id: replyToMsgId }, data: { content, msg_type: "file" }, }); } else { await this.client.im.message.create({ params: { receive_id_type: "chat_id" }, data: { receive_id: chatId, content, msg_type: "file" }, }); } } // ─── Reaction 输入指示 ───────────────────────────────── /** 添加 typing 指示(开始处理消息时调用) */ async startTyping(chatId: string, msgId: string): Promise { const reactionId = await this.addReaction(msgId, REACTION_TYPING); if (reactionId) { this.typingMessages.set(chatId, { msgId, reactionId }); } } /** 停止 typing 指示(处理完成时调用) */ async stopTyping(chatId: string, success: boolean = true): Promise { const entry = this.typingMessages.get(chatId); if (!entry) return; this.typingMessages.delete(chatId); // 移除 Typing reaction(用真实的 reaction_id) await this.removeReactionById(entry.msgId, entry.reactionId).catch(() => {}); // 失败时添加 CrossMark if (!success) { await this.addReaction(entry.msgId, REACTION_CROSS_MARK).catch(() => {}); } } // ─── 回调注册 ────────────────────────────────────────── setOnMessage( cb: (context: InboundMessageContext) => void, ): void { this.onMessageCallback = cb; } setOnStatusChange(cb: (status: BridgeStatus) => void): void { this.onStatusChangeCallback = cb; } setOnCardAction(cb: (action: { clarifyId: string; choice: string; senderOpenId: string }) => void): void { this.onCardActionCallback = cb; } // ─── 内部方法 ─────────────────────────────────────── private setStatus(status: BridgeStatus): void { this.status = status; this.onStatusChangeCallback?.(status); } /** 处理入站消息事件 */ private handleInboundMessage(data: FeishuMessageEvent): void { try { const msg = data.message; const sender = data.sender; if (msg.create_time && this.isMessageExpired(msg.create_time)) { _log(`Drop expired message: ${msg.message_id} create_time=${msg.create_time}`); return; } // 双键去重:tenant:message_id + 纯 message_id(覆盖 tenant 字段不稳定的重投) if (!this.tryRecordDedupKeys([ `${sender.tenant_key ?? "unknown"}:${msg.message_id}`, `mid:${msg.message_id}`, ])) return; const senderType = sender.sender_type; if (senderType === "bot" || senderType === "app") return; const chatId = msg.chat_id; const chatType = msg.chat_type; const messageId = msg.message_id; // 解析消息内容和资源 const { text, resources } = this.parseContentWithResources(msg.content, msg.message_type, msg.mentions); if (!text && resources.length === 0) { // 内容为空即被丢弃:打日志便于排查无法入站的格式(如新版富文本 post 结构) _warn(`Drop empty inbound: type=${msg.message_type} content=${msg.content}`); return; } _log( `Inbound: chatId=${chatId}, type=${chatType}, msgId=${messageId}, ` + `text=${(text ?? "").substring(0, 50)}..., resources=${resources.length}`, ); const senderOpenId = sender.sender_id.open_id ?? ""; const mentionedBot = Boolean(this.botOpenId && msg.mentions?.some((mention) => mention.id.open_id === this.botOpenId)); this.onMessageCallback?.({ chatId, messageId, senderOpenId, chatType, mentionedBot, text: text ?? "", resources, }); } catch (err) { _warn("Error handling inbound message:", err); } } // ─── 内容解析 ───────────────────────────────────────── /** * 解析消息内容和资源列表。 * 媒体类型的消息会返回占位文本 + 资源描述,由 index.ts 决定是否下载。 */ private parseContentWithResources( rawContent: string, messageType: string, mentions?: FeishuMessageEvent["message"]["mentions"], ): { text: string; resources: InboundResource[] } { let parsed: any; try { parsed = JSON.parse(rawContent); } catch { return { text: rawContent, resources: [] }; } let text = ""; const resources: InboundResource[] = []; switch (messageType) { case "text": text = parsed?.text ?? ""; break; case "post": { // content 是二维数组:外层每项一行,内层是该行的片段。 // 行内直接相接,行间必须补换行,否则有序列表等会被压成一行。 text = parsePostContent(parsed, resources); break; } case "image": text = "[图片]"; if (parsed?.image_key) { resources.push({ type: "image", fileKey: parsed.image_key }); } break; case "file": text = `[文件: ${parsed?.file_name ?? "unknown"}]`; if (parsed?.file_key) { resources.push({ type: "file", fileKey: parsed.file_key, fileName: parsed?.file_name, }); } break; case "audio": text = "[语音消息]"; if (parsed?.file_key) { resources.push({ type: "audio", fileKey: parsed.file_key }); } break; case "video": text = "[视频]"; if (parsed?.file_key) { resources.push({ type: "video", fileKey: parsed.file_key, fileName: parsed?.file_name }); } break; case "sticker": text = "[表情]"; break; case "interactive": text = "[卡片消息]"; break; case "share_chat": text = "[群分享]"; break; case "merge_forward": text = "[合并转发消息]"; break; default: text = typeof parsed === "string" ? parsed : JSON.stringify(parsed); } // 移除 @Bot mention 占位符 if (mentions && text) { text = this.stripMentionPlaceholders(text, mentions); } return { text: text.trim(), resources }; } /** 移除消息中的 mention 占位符 */ private stripMentionPlaceholders( text: string, mentions: FeishuMessageEvent["message"]["mentions"], ): string { let result = text; for (const m of mentions ?? []) { if (this.botOpenId && m.id.open_id === this.botOpenId) { result = result.replace(new RegExp(escapeRegExp(m.key) + "\\s*", "g"), ""); } } return result; } // ─── 交互卡片构建 ────────────────────────────────────── /** v2 卡片外壳 */ private static v2Card(elements: Record[]): Record { return { schema: "2.0", body: { elements }, }; } /** 截断过长文本 */ private static safeText(text: string, limit = 30000): string { return text.length > limit ? text.substring(0, limit) + "\n..." : text; } /** 构建纯文本卡片(用于普通消息回复,支持完整 Markdown) */ static buildTextCard(text: string): Record { return FeishuClient.v2Card([ { tag: "markdown", content: FeishuClient.safeText(text) }, ]); } // ─── Reaction ────────────────────────────────────────── /** 添加 Reaction,返回 reaction_id(用于后续删除) */ private async addReaction(msgId: string, emojiType: string): Promise { try { const resp = await this.client.im.messageReaction.create({ path: { message_id: msgId }, data: { reaction_type: { emoji_type: emojiType } }, }); const reactionId = resp?.data?.reaction_id ?? null; _log(`Reaction added: ${emojiType} → ${reactionId}`); return reactionId; } catch (err) { _log("Add reaction failed:", emojiType, err); return null; } } /** 通过真实的 reaction_id 删除 Reaction */ private async removeReactionById(msgId: string, reactionId: string): Promise { try { await this.client.im.messageReaction.delete({ path: { message_id: msgId, reaction_id: reactionId }, }); } catch (err) { _log("Remove reaction failed:", reactionId, err); } } // ─── Card Event Patch ───────────────────────────────── private patchCardEvents(): void { if (!this.wsClient) return; const wsClientAny = this.wsClient as any; const origHandleEventData = wsClientAny.handleEventData?.bind(wsClientAny); if (!origHandleEventData) return; wsClientAny.handleEventData = (data: any) => { const msgType = data?.headers?.find?.((h: any) => h?.key === "type")?.value; if (msgType === "card") { const patchedData = { ...data, headers: data.headers.map((h: any) => h.key === "type" ? { ...h, value: "event" } : h, ), }; return origHandleEventData(patchedData); } return origHandleEventData(data); }; } // ─── 去重 ─────────────────────────────────────────── /** 任一键命中未过期 TTL 则整条丢弃;否则原子写入全部键 */ private tryRecordDedupKeys(keys: string[]): boolean { const now = Date.now(); const unique = [...new Set(keys.filter(Boolean))]; for (const key of unique) { const existing = this.dedupMap.get(key); if (existing !== undefined && now - existing < DEDUP_TTL_MS) { _log(`Dedup hit, drop redelivered message: ${key}`); return false; } } while (this.dedupMap.size + unique.length > DEDUP_MAX_ENTRIES) { const firstKey = this.dedupMap.keys().next().value; if (firstKey === undefined) break; this.dedupMap.delete(firstKey); } for (const key of unique) this.dedupMap.set(key, now); return true; } private startDedupSweep(): void { if (this.dedupSweepTimer) clearInterval(this.dedupSweepTimer); this.dedupSweepTimer = setInterval(() => { const now = Date.now(); for (const [key, ts] of this.dedupMap) { if (now - ts >= DEDUP_TTL_MS) this.dedupMap.delete(key); } }, DEDUP_SWEEP_INTERVAL); if (this.dedupSweepTimer.unref) this.dedupSweepTimer.unref(); } private isMessageExpired(createTimeStr: string): boolean { const raw = parseInt(createTimeStr, 10); if (isNaN(raw)) return false; // 飞书 create_time 可能是秒或毫秒时间戳 const createTimeMs = raw < 1e12 ? raw * 1000 : raw; return Date.now() - createTimeMs > MESSAGE_EXPIRY_MS; } } // ─── 工具函数 ───────────────────────────────────────── function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } /** 从飞书 SDK/axios 错误中提取 API 返回的 code/msg 详情 */ function extractApiErrorDetail(err: any): string { const data = err?.response?.data; if (!data) return ""; if (typeof data === "string") return data.slice(0, 200); try { const code = data.code ?? data.status_code; const msg = data.msg ?? data.message ?? data.error_description ?? data.error; const parts: string[] = []; if (code !== undefined) parts.push(`code=${code}`); if (msg) parts.push(`msg=${msg}`); return parts.length ? parts.join(" ").slice(0, 300) : JSON.stringify(data).slice(0, 300); } catch { return String(data).slice(0, 300); } }