Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
631 lines
20 KiB
TypeScript
631 lines
20 KiB
TypeScript
/**
|
||
* Feishu Streaming Card - Card Kit streaming API for real-time text output
|
||
*/
|
||
|
||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||
import {
|
||
asDateTimestampMs,
|
||
resolveDateTimestampMs,
|
||
resolveExpiresAtMsFromDurationSeconds,
|
||
} from "openclaw/plugin-sdk/number-runtime";
|
||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||
import { getFeishuUserAgent } from "./client.js";
|
||
import { requestFeishuApi } from "./comment-shared.js";
|
||
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;
|
||
sentText: string;
|
||
hasNote: boolean;
|
||
};
|
||
|
||
/** Options for customising the initial streaming card appearance. */
|
||
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) */
|
||
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;
|
||
};
|
||
|
||
const STREAMING_UPDATE_THROTTLE_MS = 160;
|
||
const STREAMING_SIGNIFICANT_DELTA_CHARS = 18;
|
||
const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200;
|
||
|
||
// Token cache (keyed by domain + appId)
|
||
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||
|
||
function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||
const now = resolveDateTimestampMs(nowMs);
|
||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||
return now;
|
||
}
|
||
return (
|
||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||
resolveExpiresAtMsFromDurationSeconds(FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS, {
|
||
nowMs: now,
|
||
}) ??
|
||
now
|
||
);
|
||
}
|
||
|
||
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);
|
||
const rawNow = Date.now();
|
||
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
|
||
const now = resolveDateTimestampMs(rawNow);
|
||
const minUsableExpiresAt = resolveExpiresAtMsFromDurationSeconds(60, { nowMs: now }) ?? now;
|
||
if (cached && hasValidClock && cached.expiresAt > minUsableExpiresAt) {
|
||
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", "User-Agent": getFeishuUserAgent() },
|
||
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: resolveStreamingTokenExpiresAt(data.expire, now),
|
||
});
|
||
return data.tenant_access_token;
|
||
}
|
||
|
||
function truncateSummary(text: string, max = 50): string {
|
||
if (!text) {
|
||
return "";
|
||
}
|
||
const clean = text.replace(/\n/g, " ").trim();
|
||
// Slice on a code-point boundary so a surrogate pair (emoji / astral char)
|
||
// straddling the limit is dropped whole, instead of leaving a lone surrogate
|
||
// half that Feishu renders as the replacement char.
|
||
return clean.length <= max ? clean : sliceUtf16Safe(clean, 0, max - 3) + "...";
|
||
}
|
||
|
||
function hasNaturalStreamingBoundary(text: string): boolean {
|
||
return /[\n。!?!?;;::]$/.test(text);
|
||
}
|
||
|
||
function shouldPushStreamingUpdate(previousText: string, nextText: string): boolean {
|
||
if (!previousText) {
|
||
return true;
|
||
}
|
||
if (hasNaturalStreamingBoundary(nextText)) {
|
||
return true;
|
||
}
|
||
return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
|
||
}
|
||
|
||
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 = STREAMING_UPDATE_THROTTLE_MS;
|
||
|
||
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: "", 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",
|
||
"User-Agent": getFeishuUserAgent(),
|
||
},
|
||
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 requestFeishuApi(
|
||
() =>
|
||
this.client.im.message.reply({
|
||
path: { message_id: sendOptions.replyToMessageId! },
|
||
data: {
|
||
msg_type: "interactive",
|
||
content: cardContent,
|
||
...(sendOptions.replyInThread ? { reply_in_thread: true } : {}),
|
||
},
|
||
}),
|
||
"Send card failed",
|
||
);
|
||
} else if (sendMode === "root_create") {
|
||
// root_id is undeclared in the SDK types but accepted at runtime
|
||
sendRes = await requestFeishuApi(
|
||
() =>
|
||
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 },
|
||
),
|
||
}),
|
||
"Send card failed",
|
||
);
|
||
} else {
|
||
sendRes = await requestFeishuApi(
|
||
() =>
|
||
this.client.im.message.create({
|
||
params: { receive_id_type: receiveIdType },
|
||
data: {
|
||
receive_id: receiveId,
|
||
msg_type: "interactive",
|
||
content: cardContent,
|
||
},
|
||
}),
|
||
"Send card failed",
|
||
);
|
||
}
|
||
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: "",
|
||
sentText: "",
|
||
hasNote: Boolean(options?.note),
|
||
};
|
||
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
||
}
|
||
|
||
private async updateCardContent(
|
||
text: string,
|
||
onError?: (error: unknown) => void,
|
||
): Promise<boolean> {
|
||
if (!this.state) {
|
||
return false;
|
||
}
|
||
const apiBase = resolveApiBase(this.creds.domain);
|
||
this.state.sequence += 1;
|
||
try {
|
||
const { response, release } = 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",
|
||
"User-Agent": getFeishuUserAgent(),
|
||
},
|
||
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",
|
||
});
|
||
await release();
|
||
if (!response.ok) {
|
||
onError?.(new Error(`Update card content failed with HTTP ${response.status}`));
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
onError?.(error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private async replaceCardContent(
|
||
text: string,
|
||
onError?: (error: unknown) => void,
|
||
): Promise<boolean> {
|
||
if (!this.state) {
|
||
return false;
|
||
}
|
||
const apiBase = resolveApiBase(this.creds.domain);
|
||
this.state.sequence += 1;
|
||
try {
|
||
const { response, release } = await fetchWithSsrFGuard({
|
||
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content`,
|
||
init: {
|
||
method: "PUT",
|
||
headers: {
|
||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||
"Content-Type": "application/json",
|
||
"User-Agent": getFeishuUserAgent(),
|
||
},
|
||
body: JSON.stringify({
|
||
element: JSON.stringify({ tag: "markdown", content: text, element_id: "content" }),
|
||
sequence: this.state.sequence,
|
||
uuid: `r_${this.state.cardId}_${this.state.sequence}`,
|
||
}),
|
||
},
|
||
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
||
auditContext: "feishu.streaming-card.replace",
|
||
});
|
||
await release();
|
||
if (!response.ok) {
|
||
onError?.(new Error(`Replace card content failed with HTTP ${response.status}`));
|
||
return false;
|
||
}
|
||
return true;
|
||
} catch (error) {
|
||
onError?.(error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private clearFlushTimer(): void {
|
||
if (this.flushTimer) {
|
||
clearTimeout(this.flushTimer);
|
||
this.flushTimer = null;
|
||
}
|
||
}
|
||
|
||
private schedulePendingFlush(): void {
|
||
if (this.flushTimer || !this.pendingText || this.closed) {
|
||
return;
|
||
}
|
||
const delayMs = Math.max(0, this.updateThrottleMs - (Date.now() - this.lastUpdateTime));
|
||
this.flushTimer = setTimeout(() => {
|
||
this.flushTimer = null;
|
||
const pending = this.pendingText;
|
||
if (!pending || this.closed) {
|
||
return;
|
||
}
|
||
void this.update(pending).catch((error: unknown) =>
|
||
this.log?.(`Scheduled flush update failed: ${String(error)}`),
|
||
);
|
||
}, delayMs);
|
||
}
|
||
|
||
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;
|
||
}
|
||
this.pendingText = mergedInput;
|
||
this.clearFlushTimer();
|
||
|
||
const shouldForceUpdate = shouldPushStreamingUpdate(this.state.currentText, mergedInput);
|
||
const now = Date.now();
|
||
if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
|
||
this.schedulePendingFlush();
|
||
return;
|
||
}
|
||
this.lastUpdateTime = now;
|
||
|
||
this.queue = this.queue.then(async () => {
|
||
if (!this.state || this.closed) {
|
||
return;
|
||
}
|
||
const nextText = this.pendingText ?? mergedInput;
|
||
const mergedText = mergeStreamingText(this.state.currentText, nextText);
|
||
if (!mergedText || mergedText === this.state.currentText) {
|
||
return;
|
||
}
|
||
if (mergedText === this.state.sentText) {
|
||
return;
|
||
}
|
||
this.pendingText = null;
|
||
this.state.currentText = mergedText;
|
||
const sent = await this.updateCardContent(mergedText, (e) =>
|
||
this.log?.(`Update failed: ${String(e)}`),
|
||
);
|
||
if (sent && this.state) {
|
||
this.state.sentText = mergedText;
|
||
}
|
||
});
|
||
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",
|
||
"User-Agent": getFeishuUserAgent(),
|
||
},
|
||
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: unknown) => this.log?.(`Note update failed: ${String(e)}`));
|
||
}
|
||
|
||
async close(finalText?: string, options?: { note?: string }): Promise<boolean> {
|
||
if (!this.state || this.closed) {
|
||
return false;
|
||
}
|
||
this.closed = true;
|
||
this.clearFlushTimer();
|
||
await this.queue;
|
||
|
||
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
|
||
const text = finalText ?? pendingMerged;
|
||
const apiBase = resolveApiBase(this.creds.domain);
|
||
let visibleContentSent = Boolean(this.state.sentText.trim());
|
||
|
||
// Only send final update if content differs from what's already displayed.
|
||
// An explicit empty final text clears a transient preview before closeout.
|
||
if ((text || finalText !== undefined) && text !== this.state.sentText) {
|
||
const sent = text.startsWith(this.state.sentText)
|
||
? await this.updateCardContent(text, (e) => this.log?.(`Final update failed: ${String(e)}`))
|
||
: await this.replaceCardContent(text, (e) =>
|
||
this.log?.(`Final replace failed: ${String(e)}`),
|
||
);
|
||
this.state.currentText = text;
|
||
if (sent) {
|
||
this.state.sentText = text;
|
||
visibleContentSent = Boolean(text.trim());
|
||
}
|
||
}
|
||
|
||
// 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",
|
||
"User-Agent": getFeishuUserAgent(),
|
||
},
|
||
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: unknown) => this.log?.(`Close failed: ${String(e)}`));
|
||
const finalState = this.state;
|
||
this.state = null;
|
||
this.pendingText = null;
|
||
|
||
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
|
||
return visibleContentSent;
|
||
}
|
||
|
||
async discard(): Promise<void> {
|
||
if (!this.state || this.closed) {
|
||
return;
|
||
}
|
||
this.closed = true;
|
||
this.clearFlushTimer();
|
||
await this.queue;
|
||
|
||
const currentState = this.state;
|
||
try {
|
||
const response = await this.client.im.message.delete({
|
||
path: { message_id: currentState.messageId },
|
||
});
|
||
if (response.code !== undefined && response.code !== 0) {
|
||
throw new Error(`Delete streaming card message failed: ${response.msg ?? response.code}`);
|
||
}
|
||
this.state = null;
|
||
this.pendingText = null;
|
||
this.log?.(`Discarded streaming card: cardId=${currentState.cardId}`);
|
||
} catch (error) {
|
||
this.log?.(`Discard failed: ${String(error)}`);
|
||
this.closed = false;
|
||
await this.close("");
|
||
}
|
||
}
|
||
|
||
isActive(): boolean {
|
||
return this.state !== null && !this.closed;
|
||
}
|
||
}
|