import type { Client } from "@larksuiteoapi/node-sdk"; type FeishuDomain = string | undefined; type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain; }; type CardState = { cardId: string; messageId: string; sequence: number; currentText: string; }; type StreamingStartOptions = { replyToMessageId: string; replyInThread?: boolean; }; const tokenCache = new Map(); function resolveApiBase(domain?: FeishuDomain): string { if (domain === "lark") { return "https://open.larksuite.com/open-apis"; } if (domain && domain !== "feishu" && domain.startsWith("http")) { return `${domain.replace(/\/+$/, "")}/open-apis`; } return "https://open.feishu.cn/open-apis"; } async function getToken(creds: Credentials): Promise { const key = `${creds.domain ?? "feishu"}|${creds.appId}`; const cached = tokenCache.get(key); if (cached && cached.expiresAt > Date.now() + 60_000) { return cached.token; } const response = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }), }); if (!response.ok) { throw new Error(`Token request failed with HTTP ${response.status}`); } const data = (await response.json()) as { code: number; msg: string; tenant_access_token?: string; expire?: number; }; if (data.code !== 0 || !data.tenant_access_token) { throw new Error(`Token error: ${data.msg}`); } tokenCache.set(key, { token: data.tenant_access_token, expiresAt: Date.now() + (data.expire ?? 7200) * 1000, }); return data.tenant_access_token; } async function readResponseBodySafe(response: Response): Promise { try { const text = await response.text(); return text.trim(); } catch { return ""; } } function truncateSummary(text: string, max = 50): string { const clean = text.replace(/\n/g, " ").trim(); return clean.length <= max ? clean : `${clean.slice(0, max - 3)}...`; } export function mergeStreamingText(previousText: string | undefined, nextText: string | undefined): string { const previous = typeof previousText === "string" ? previousText : ""; const next = typeof nextText === "string" ? nextText : ""; if (!next) return previous; if (!previous || next === previous) return next; if (next.startsWith(previous)) return next; if (previous.startsWith(next)) return previous; if (next.includes(previous)) return next; if (previous.includes(next)) return previous; const maxOverlap = Math.min(previous.length, next.length); for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { if (previous.slice(-overlap) === next.slice(0, overlap)) { return `${previous}${next.slice(overlap)}`; } } return `${previous}${next}`; } export class FeishuStreamingCardSession { private state: CardState | null = null; private queue: Promise = Promise.resolve(); private closed = false; private pendingText: string | null = null; private flushTimer: ReturnType | null = null; private lastUpdateTime = 0; private readonly updateThrottleMs = 100; constructor( private readonly client: Client, private readonly creds: Credentials, private readonly log?: (message: string, fields?: Record) => void, ) {} async start( _receiveId: string, _receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", options?: StreamingStartOptions, ): Promise { if (this.state) return; if (!options?.replyToMessageId) { throw new Error("replyToMessageId is required for feishu streaming card replies"); } const cardJson = { schema: "2.0", config: { streaming_mode: true, summary: { content: "[Generating...]" }, streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } }, }, body: { elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }], }, }; const token = await getToken(this.creds); const createRes = await fetch(`${resolveApiBase(this.creds.domain)}/cardkit/v1/cards`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), }); if (!createRes.ok) { const body = await readResponseBodySafe(createRes); throw new Error( `Create card request failed with HTTP ${createRes.status}${body ? `: ${body}` : ""}`, ); } const createData = (await createRes.json()) as { code: number; msg: string; data?: { card_id: string }; }; if (createData.code !== 0 || !createData.data?.card_id) { throw new Error(`Create card failed (code=${createData.code}): ${createData.msg}`); } const cardId = createData.data.card_id; const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } }); const sendRes = (await this.client.im.message.reply({ path: { message_id: options.replyToMessageId }, data: { msg_type: "interactive", content: cardContent, ...(options.replyInThread ? { reply_in_thread: true } : {}), }, })) as { code?: number; msg?: string; data?: { message_id?: string }; }; if (sendRes.code !== 0 || !sendRes.data?.message_id) { throw new Error(`Send card failed: ${sendRes.msg}`); } this.log?.("stream start", { cardId, messageId: sendRes.data.message_id }); this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "", }; } private async updateCardContent(text: string): Promise { if (!this.state) return; this.state.sequence += 1; const token = await getToken(this.creds); const response = await fetch( `${resolveApiBase(this.creds.domain)}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: text, sequence: this.state.sequence, uuid: `s_${this.state.cardId}_${this.state.sequence}`, }), }, ); if (!response.ok) { const body = await readResponseBodySafe(response); throw new Error( `Update card request failed with HTTP ${response.status}${body ? `: ${body}` : ""}`, ); } } async update(text: string): Promise { if (!this.state || this.closed) return; const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text); if (!mergedInput || mergedInput === this.state.currentText) return; const now = Date.now(); if (now - this.lastUpdateTime < this.updateThrottleMs) { this.pendingText = mergedInput; if (!this.flushTimer) { this.flushTimer = setTimeout(() => { this.flushTimer = null; const pending = this.pendingText; this.pendingText = null; if (pending) { void this.update(pending).catch((error) => this.log?.("stream update failed", { error: String(error) })); } }, this.updateThrottleMs); } return; } this.pendingText = null; this.lastUpdateTime = now; this.queue = this.queue.then(async () => { if (!this.state || this.closed) return; const mergedText = mergeStreamingText(this.state.currentText, mergedInput); if (!mergedText || mergedText === this.state.currentText) return; this.state.currentText = mergedText; this.log?.("stream update", { text: mergedText }); await this.updateCardContent(mergedText); }); await this.queue; } async close(finalText?: string): Promise { if (!this.state || this.closed) return; this.closed = true; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } await this.queue; const text = finalText ? mergeStreamingText(this.state.currentText, finalText) : this.state.currentText; if (text && text !== this.state.currentText) { await this.updateCardContent(text); this.state.currentText = text; } this.state.sequence += 1; const token = await getToken(this.creds); const response = await fetch(`${resolveApiBase(this.creds.domain)}/cardkit/v1/cards/${this.state.cardId}/settings`, { method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ settings: JSON.stringify({ config: { streaming_mode: false, summary: { content: truncateSummary(this.state.currentText) } }, }), sequence: this.state.sequence, uuid: `c_${this.state.cardId}_${this.state.sequence}`, }), }); if (!response.ok) { const body = await readResponseBodySafe(response); throw new Error( `Close card request failed with HTTP ${response.status}${body ? `: ${body}` : ""}`, ); } this.state = null; this.pendingText = null; } isActive(): boolean { return this.state !== null && !this.closed; } }