/** * Feishu Streaming Card - Card Kit streaming API for real-time text output */ import type { Client } from "@larksuiteoapi/node-sdk"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js"; import type { FeishuDomain } from "./types.js"; type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain }; type CardState = { cardId: string; messageId: string; sequence: number; currentText: string; hasNote: boolean; }; /** Options for customising the initial streaming card appearance. */ export type StreamingCardOptions = { /** Optional header with title and color template. */ header?: CardHeaderConfig; /** Optional grey note footer text. */ note?: string; }; /** Optional header for streaming cards (title bar with color template) */ export type StreamingCardHeader = { title: string; /** Color template: blue, green, red, orange, purple, indigo, wathet, turquoise, yellow, grey, carmine, violet, lime */ template?: string; }; type StreamingStartOptions = { replyToMessageId?: string; replyInThread?: boolean; rootId?: string; header?: StreamingCardHeader; }; // Token cache (keyed by domain + appId) 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"; } function resolveAllowedHostnames(domain?: FeishuDomain): string[] { if (domain === "lark") { return ["open.larksuite.com"]; } if (domain && domain !== "feishu" && domain.startsWith("http")) { try { return [new URL(domain).hostname]; } catch { return []; } } return ["open.feishu.cn"]; } async function getToken(creds: Credentials): Promise { const key = `${creds.domain ?? "feishu"}|${creds.appId}`; const cached = tokenCache.get(key); if (cached && cached.expiresAt > Date.now() + 60000) { return cached.token; } const { response, release } = await fetchWithSsrFGuard({ url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, init: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }), }, policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) }, auditContext: "feishu.streaming-card.token", }); if (!response.ok) { await release(); 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; }; await release(); 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; } function truncateSummary(text: string, max = 50): string { if (!text) { return ""; } 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; } // Merge partial overlaps, e.g. "这" + "这是" => "这是". 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)}`; } } // Fallback for fragmented partial chunks: append as-is to avoid losing tokens. return `${previous}${next}`; } export function resolveStreamingCardSendMode(options?: StreamingStartOptions) { if (options?.replyToMessageId) { return "reply"; } if (options?.rootId) { return "root_create"; } return "create"; } /** Streaming card session manager */ export class FeishuStreamingSession { private client: Client; private creds: Credentials; private state: CardState | null = null; private queue: Promise = Promise.resolve(); private closed = false; private log?: (msg: string) => void; private lastUpdateTime = 0; private pendingText: string | null = null; private flushTimer: ReturnType | null = null; private updateThrottleMs = 100; // Throttle updates to max 10/sec constructor(client: Client, creds: Credentials, log?: (msg: string) => void) { this.client = client; this.creds = creds; this.log = log; } async start( receiveId: string, receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", options?: StreamingCardOptions & StreamingStartOptions, ): Promise { if (this.state) { return; } const apiBase = resolveApiBase(this.creds.domain); const elements: Record[] = [ { tag: "markdown", content: "⏳ Thinking...", element_id: "content" }, ]; if (options?.note) { elements.push({ tag: "hr" }); elements.push({ tag: "markdown", content: `${options.note}`, element_id: "note", }); } const cardJson: Record = { schema: "2.0", config: { streaming_mode: true, summary: { content: "[Generating...]" }, streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } }, }, body: { elements }, }; if (options?.header) { cardJson.header = { title: { tag: "plain_text", content: options.header.title }, template: resolveFeishuCardTemplate(options.header.template) ?? "blue", }; } // Create card entity const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({ url: `${apiBase}/cardkit/v1/cards`, init: { method: "POST", headers: { Authorization: `Bearer ${await getToken(this.creds)}`, "Content-Type": "application/json", }, body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), }, policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, auditContext: "feishu.streaming-card.create", }); if (!createRes.ok) { await releaseCreate(); throw new Error(`Create card request failed with HTTP ${createRes.status}`); } const createData = (await createRes.json()) as { code: number; msg: string; data?: { card_id: string }; }; await releaseCreate(); if (createData.code !== 0 || !createData.data?.card_id) { throw new Error(`Create card failed: ${createData.msg}`); } const cardId = createData.data.card_id; const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } }); // Prefer message.reply when we have a reply target — reply_in_thread // reliably routes streaming cards into Feishu topics, whereas // message.create with root_id may silently ignore root_id for card // references (card_id format). let sendRes; const sendOptions = options ?? {}; const sendMode = resolveStreamingCardSendMode(sendOptions); if (sendMode === "reply") { sendRes = await this.client.im.message.reply({ path: { message_id: sendOptions.replyToMessageId! }, data: { msg_type: "interactive", content: cardContent, ...(sendOptions.replyInThread ? { reply_in_thread: true } : {}), }, }); } else if (sendMode === "root_create") { // root_id is undeclared in the SDK types but accepted at runtime sendRes = await this.client.im.message.create({ params: { receive_id_type: receiveIdType }, data: Object.assign( { receive_id: receiveId, msg_type: "interactive", content: cardContent }, { root_id: sendOptions.rootId }, ), }); } else { sendRes = await this.client.im.message.create({ params: { receive_id_type: receiveIdType }, data: { receive_id: receiveId, msg_type: "interactive", content: cardContent, }, }); } if (sendRes.code !== 0 || !sendRes.data?.message_id) { throw new Error(`Send card failed: ${sendRes.msg}`); } this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "", hasNote: !!options?.note, }; this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); } private async updateCardContent(text: string, onError?: (error: unknown) => void): Promise { if (!this.state) { return; } const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; await fetchWithSsrFGuard({ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, init: { method: "PUT", headers: { Authorization: `Bearer ${await getToken(this.creds)}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: text, sequence: this.state.sequence, uuid: `s_${this.state.cardId}_${this.state.sequence}`, }), }, policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, auditContext: "feishu.streaming-card.update", }) .then(async ({ release }) => { await release(); }) .catch((error) => onError?.(error)); } 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; } // Throttle: skip if updated recently, but remember pending text const now = Date.now(); if (now - this.lastUpdateTime < this.updateThrottleMs) { this.pendingText = mergedInput; return; } this.pendingText = null; this.lastUpdateTime = now; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } 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; await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`)); }); await this.queue; } private async updateNoteContent(note: string): Promise { if (!this.state || !this.state.hasNote) { return; } const apiBase = resolveApiBase(this.creds.domain); this.state.sequence += 1; await fetchWithSsrFGuard({ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`, init: { method: "PUT", headers: { Authorization: `Bearer ${await getToken(this.creds)}`, "Content-Type": "application/json", }, body: JSON.stringify({ content: `${note}`, sequence: this.state.sequence, uuid: `n_${this.state.cardId}_${this.state.sequence}`, }), }, policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, auditContext: "feishu.streaming-card.note-update", }) .then(async ({ release }) => { await release(); }) .catch((e) => this.log?.(`Note update failed: ${String(e)}`)); } async close(finalText?: string, options?: { note?: string }): Promise { if (!this.state || this.closed) { return; } this.closed = true; if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; } await this.queue; const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined); const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged; const apiBase = resolveApiBase(this.creds.domain); // Only send final update if content differs from what's already displayed if (text && text !== this.state.currentText) { await this.updateCardContent(text); this.state.currentText = text; } // Update note with final model/provider info if (options?.note) { await this.updateNoteContent(options.note); } // Close streaming mode this.state.sequence += 1; await fetchWithSsrFGuard({ url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, init: { method: "PATCH", headers: { Authorization: `Bearer ${await getToken(this.creds)}`, "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ settings: JSON.stringify({ config: { streaming_mode: false, summary: { content: truncateSummary(text) } }, }), sequence: this.state.sequence, uuid: `c_${this.state.cardId}_${this.state.sequence}`, }), }, policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) }, auditContext: "feishu.streaming-card.close", }) .then(async ({ release }) => { await release(); }) .catch((e) => this.log?.(`Close failed: ${String(e)}`)); const finalState = this.state; this.state = null; this.pendingText = null; this.log?.(`Closed streaming: cardId=${finalState.cardId}`); } isActive(): boolean { return this.state !== null && !this.closed; } }