mirror of https://github.com/openclaw/openclaw.git
451 lines
14 KiB
TypeScript
451 lines
14 KiB
TypeScript
/**
|
|
* 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<string, { token: string; expiresAt: number }>();
|
|
|
|
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<string> {
|
|
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<void> = Promise.resolve();
|
|
private closed = false;
|
|
private log?: (msg: string) => void;
|
|
private lastUpdateTime = 0;
|
|
private pendingText: string | null = null;
|
|
private flushTimer: ReturnType<typeof setTimeout> | 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<void> {
|
|
if (this.state) {
|
|
return;
|
|
}
|
|
|
|
const apiBase = resolveApiBase(this.creds.domain);
|
|
const elements: Record<string, unknown>[] = [
|
|
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
|
|
];
|
|
if (options?.note) {
|
|
elements.push({ tag: "hr" });
|
|
elements.push({
|
|
tag: "markdown",
|
|
content: `<font color='grey'>${options.note}</font>`,
|
|
element_id: "note",
|
|
});
|
|
}
|
|
const cardJson: Record<string, unknown> = {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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: `<font color='grey'>${note}</font>`,
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|