From 65be9ccf63f30e9a06c21d32e5ff6f2e194fa363 Mon Sep 17 00:00:00 2001 From: LeftX <53989315+xzq-xu@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:19:27 +0800 Subject: [PATCH] feat(feishu): add streaming card support via Card Kit API (openclaw#10379) thanks @xzq-xu Verified: - pnpm build - pnpm check - pnpm test Co-authored-by: xzq-xu <53989315+xzq-xu@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/config-schema.ts | 6 + .../feishu/src/reply-dispatcher.test.ts | 116 +++++++++ extensions/feishu/src/reply-dispatcher.ts | 191 +++++++++------ extensions/feishu/src/streaming-card.ts | 223 ++++++++++++++++++ extensions/feishu/src/targets.test.ts | 16 ++ extensions/feishu/src/targets.ts | 2 +- 7 files changed, 487 insertions(+), 68 deletions(-) create mode 100644 extensions/feishu/src/reply-dispatcher.test.ts create mode 100644 extensions/feishu/src/streaming-card.ts create mode 100644 extensions/feishu/src/targets.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f960c49bb0..53a4e95dc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle. - Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf. - Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat. +- Feishu: add streaming card replies via Card Kit API and preserve `renderMode=auto` fallback behavior for plain-text responses. (#10379) Thanks @xzq-xu. - Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159. - Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015. - CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini. diff --git a/extensions/feishu/src/config-schema.ts b/extensions/feishu/src/config-schema.ts index 9c09af9ec9..231a1e9b29 100644 --- a/extensions/feishu/src/config-schema.ts +++ b/extensions/feishu/src/config-schema.ts @@ -36,6 +36,10 @@ const MarkdownConfigSchema = z // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional(); +// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API +// for incremental text display with a "Thinking..." placeholder +const StreamingModeSchema = z.boolean().optional(); + const BlockStreamingCoalesceSchema = z .object({ enabled: z.boolean().optional(), @@ -142,6 +146,7 @@ export const FeishuAccountConfigSchema = z mediaMaxMb: z.number().positive().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, renderMode: RenderModeSchema, + streaming: StreamingModeSchema, // Enable streaming card mode (default: true) tools: FeishuToolsConfigSchema, }) .strict(); @@ -177,6 +182,7 @@ export const FeishuConfigSchema = z mediaMaxMb: z.number().positive().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown + streaming: StreamingModeSchema, // Enable streaming card mode (default: true) tools: FeishuToolsConfigSchema, // Dynamic agent creation for DM users dynamicAgentCreation: DynamicAgentCreationSchema, diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts new file mode 100644 index 0000000000..36dcfc9a04 --- /dev/null +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const resolveFeishuAccountMock = vi.hoisted(() => vi.fn()); +const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); +const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); +const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); +const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn()); +const streamingInstances = vi.hoisted(() => [] as any[]); + +vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock })); +vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock })); +vi.mock("./send.js", () => ({ + sendMessageFeishu: sendMessageFeishuMock, + sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, +})); +vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); +vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock })); +vi.mock("./streaming-card.js", () => ({ + FeishuStreamingSession: class { + active = false; + start = vi.fn(async () => { + this.active = true; + }); + update = vi.fn(async () => {}); + close = vi.fn(async () => { + this.active = false; + }); + isActive = vi.fn(() => this.active); + + constructor() { + streamingInstances.push(this); + } + }, +})); + +import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; + +describe("createFeishuReplyDispatcher streaming behavior", () => { + beforeEach(() => { + vi.clearAllMocks(); + streamingInstances.length = 0; + + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: true, + }, + }); + + resolveReceiveIdTypeMock.mockReturnValue("chat_id"); + createFeishuClientMock.mockReturnValue({}); + + createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + _opts: opts, + })); + + getFeishuRuntimeMock.mockReturnValue({ + channel: { + text: { + resolveTextChunkLimit: vi.fn(() => 4000), + resolveChunkMode: vi.fn(() => "line"), + resolveMarkdownTableMode: vi.fn(() => "preserve"), + convertMarkdownTables: vi.fn((text) => text), + chunkTextWithMode: vi.fn((text) => [text]), + }, + reply: { + createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, + resolveHumanDelayConfig: vi.fn(() => undefined), + }, + }, + }); + }); + + it("keeps auto mode plain text on non-streaming send path", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: {} as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "plain text" }, { kind: "final" }); + + expect(streamingInstances).toHaveLength(0); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("uses streaming session for auto mode markdown payloads", async () => { + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 9d50042c1d..15fd0d506a 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -3,29 +3,22 @@ import { createTypingCallbacks, logTypingFailure, type ClawdbotConfig, - type RuntimeEnv, type ReplyPayload, + type RuntimeEnv, } from "openclaw/plugin-sdk"; import type { MentionTarget } from "./mention.js"; import { resolveFeishuAccount } from "./accounts.js"; +import { createFeishuClient } from "./client.js"; +import { buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; -import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js"; +import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js"; +import { FeishuStreamingSession } from "./streaming-card.js"; +import { resolveReceiveIdType } from "./targets.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; -/** - * Detect if text contains markdown elements that benefit from card rendering. - * Used by auto render mode. - */ +/** Detect if text contains markdown elements that benefit from card rendering */ function shouldUseCard(text: string): boolean { - // Code blocks (fenced) - if (/```[\s\S]*?```/.test(text)) { - return true; - } - // Tables (at least header + separator row with |) - if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) { - return true; - } - return false; + return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } export type CreateFeishuReplyDispatcherParams = { @@ -34,35 +27,23 @@ export type CreateFeishuReplyDispatcherParams = { runtime: RuntimeEnv; chatId: string; replyToMessageId?: string; - /** Mention targets, will be auto-included in replies */ mentionTargets?: MentionTarget[]; - /** Account ID for multi-account support */ accountId?: string; }; export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) { const core = getFeishuRuntime(); const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params; - - // Resolve account for config access const account = resolveFeishuAccount({ cfg, accountId }); + const prefixContext = createReplyPrefixContext({ cfg, agentId }); - const prefixContext = createReplyPrefixContext({ - cfg, - agentId, - }); - - // Feishu doesn't have a native typing indicator API. - // We use message reactions as a typing indicator substitute. let typingState: TypingIndicatorState | null = null; - const typingCallbacks = createTypingCallbacks({ start: async () => { if (!replyToMessageId) { return; } typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId }); - params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`); }, stop: async () => { if (!typingState) { @@ -70,24 +51,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } await removeTypingIndicator({ cfg, state: typingState, accountId }); typingState = null; - params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`); }, - onStartError: (err) => { + onStartError: (err) => logTypingFailure({ log: (message) => params.runtime.log?.(message), channel: "feishu", action: "start", error: err, - }); - }, - onStopError: (err) => { + }), + onStopError: (err) => logTypingFailure({ log: (message) => params.runtime.log?.(message), channel: "feishu", action: "stop", error: err, - }); - }, + }), }); const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, { @@ -95,77 +73,139 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP }); const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu"); const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" }); + const renderMode = account.config?.renderMode ?? "auto"; + const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw"; + + let streaming: FeishuStreamingSession | null = null; + let streamText = ""; + let lastPartial = ""; + let partialUpdateQueue: Promise = Promise.resolve(); + let streamingStartPromise: Promise | null = null; + + const startStreaming = () => { + if (!streamingEnabled || streamingStartPromise || streaming) { + return; + } + streamingStartPromise = (async () => { + const creds = + account.appId && account.appSecret + ? { appId: account.appId, appSecret: account.appSecret, domain: account.domain } + : null; + if (!creds) { + return; + } + + streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) => + params.runtime.log?.(`feishu[${account.accountId}] ${message}`), + ); + try { + await streaming.start(chatId, resolveReceiveIdType(chatId)); + } catch (error) { + params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); + streaming = null; + } + })(); + }; + + const closeStreaming = async () => { + if (streamingStartPromise) { + await streamingStartPromise; + } + await partialUpdateQueue; + if (streaming?.isActive()) { + let text = streamText; + if (mentionTargets?.length) { + text = buildMentionedCardContent(mentionTargets, text); + } + await streaming.close(text); + } + streaming = null; + streamingStartPromise = null; + streamText = ""; + lastPartial = ""; + }; const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, responsePrefixContextProvider: prefixContext.responsePrefixContextProvider, humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), - onReplyStart: typingCallbacks.onReplyStart, - deliver: async (payload: ReplyPayload) => { - params.runtime.log?.( - `feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`, - ); + onReplyStart: () => { + if (streamingEnabled && renderMode === "card") { + startStreaming(); + } + void typingCallbacks.onReplyStart?.(); + }, + deliver: async (payload: ReplyPayload, info) => { const text = payload.text ?? ""; if (!text.trim()) { - params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`); return; } - // Check render mode: auto (default), raw, or card - const feishuCfg = account.config; - const renderMode = feishuCfg?.renderMode ?? "auto"; - - // Determine if we should use card for this message const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - // Only include @mentions in the first chunk (avoid duplicate @s) - let isFirstChunk = true; + if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) { + startStreaming(); + if (streamingStartPromise) { + await streamingStartPromise; + } + } + if (streaming?.isActive()) { + if (info?.kind === "final") { + streamText = text; + await closeStreaming(); + } + return; + } + + let first = true; if (useCard) { - // Card mode: send as interactive card with markdown rendering - const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode); - params.runtime.log?.( - `feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`, - ); - for (const chunk of chunks) { + for (const chunk of core.channel.text.chunkTextWithMode( + text, + textChunkLimit, + chunkMode, + )) { await sendMarkdownCardFeishu({ cfg, to: chatId, text: chunk, replyToMessageId, - mentions: isFirstChunk ? mentionTargets : undefined, + mentions: first ? mentionTargets : undefined, accountId, }); - isFirstChunk = false; + first = false; } } else { - // Raw mode: send as plain text with table conversion const converted = core.channel.text.convertMarkdownTables(text, tableMode); - const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode); - params.runtime.log?.( - `feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`, - ); - for (const chunk of chunks) { + for (const chunk of core.channel.text.chunkTextWithMode( + converted, + textChunkLimit, + chunkMode, + )) { await sendMessageFeishu({ cfg, to: chatId, text: chunk, replyToMessageId, - mentions: isFirstChunk ? mentionTargets : undefined, + mentions: first ? mentionTargets : undefined, accountId, }); - isFirstChunk = false; + first = false; } } }, - onError: (err, info) => { + onError: async (error, info) => { params.runtime.error?.( - `feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`, + `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`, ); + await closeStreaming(); + typingCallbacks.onIdle?.(); + }, + onIdle: async () => { + await closeStreaming(); typingCallbacks.onIdle?.(); }, - onIdle: typingCallbacks.onIdle, }); return { @@ -173,6 +213,23 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP replyOptions: { ...replyOptions, onModelSelected: prefixContext.onModelSelected, + onPartialReply: streamingEnabled + ? (payload: ReplyPayload) => { + if (!payload.text || payload.text === lastPartial) { + return; + } + lastPartial = payload.text; + streamText = payload.text; + partialUpdateQueue = partialUpdateQueue.then(async () => { + if (streamingStartPromise) { + await streamingStartPromise; + } + if (streaming?.isActive()) { + await streaming.update(streamText); + } + }); + } + : undefined, }, markDispatchIdle, }; diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts new file mode 100644 index 0000000000..93cf416610 --- /dev/null +++ b/extensions/feishu/src/streaming-card.ts @@ -0,0 +1,223 @@ +/** + * Feishu Streaming Card - Card Kit streaming API for real-time text output + */ + +import type { Client } from "@larksuiteoapi/node-sdk"; +import type { FeishuDomain } from "./types.js"; + +type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain }; +type CardState = { cardId: string; messageId: string; sequence: number; currentText: string }; + +// 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"; +} + +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 res = 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 }), + }); + const data = (await res.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; +} + +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) + "..."; +} + +/** 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 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", + ): Promise { + if (this.state) { + return; + } + + const apiBase = resolveApiBase(this.creds.domain); + const cardJson = { + schema: "2.0", + config: { + streaming_mode: true, + summary: { content: "[Generating...]" }, + streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } }, + }, + body: { + elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }], + }, + }; + + // Create card entity + const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, { + method: "POST", + headers: { + Authorization: `Bearer ${await getToken(this.creds)}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }), + }); + 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: ${createData.msg}`); + } + const cardId = createData.data.card_id; + + // Send card message + const sendRes = await this.client.im.message.create({ + params: { receive_id_type: receiveIdType }, + data: { + receive_id: receiveId, + msg_type: "interactive", + content: JSON.stringify({ type: "card", data: { card_id: cardId } }), + }, + }); + 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: "" }; + this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); + } + + async update(text: string): Promise { + if (!this.state || this.closed) { + return; + } + // Throttle: skip if updated recently, but remember pending text + const now = Date.now(); + if (now - this.lastUpdateTime < this.updateThrottleMs) { + this.pendingText = text; + return; + } + this.pendingText = null; + this.lastUpdateTime = now; + + this.queue = this.queue.then(async () => { + if (!this.state || this.closed) { + return; + } + this.state.currentText = text; + this.state.sequence += 1; + const apiBase = resolveApiBase(this.creds.domain); + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { + 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}`, + }), + }).catch((e) => this.log?.(`Update failed: ${String(e)}`)); + }); + await this.queue; + } + + async close(finalText?: string): Promise { + if (!this.state || this.closed) { + return; + } + this.closed = true; + await this.queue; + + // Use finalText, or pending throttled text, or current text + const text = finalText ?? this.pendingText ?? this.state.currentText; + const apiBase = resolveApiBase(this.creds.domain); + + // Only send final update if content differs from what's already displayed + if (text && text !== this.state.currentText) { + this.state.sequence += 1; + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, { + 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}`, + }), + }).catch(() => {}); + this.state.currentText = text; + } + + // Close streaming mode + this.state.sequence += 1; + await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, { + 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}`, + }), + }).catch((e) => this.log?.(`Close failed: ${String(e)}`)); + + this.log?.(`Closed streaming: cardId=${this.state.cardId}`); + } + + isActive(): boolean { + return this.state !== null && !this.closed; + } +} diff --git a/extensions/feishu/src/targets.test.ts b/extensions/feishu/src/targets.test.ts new file mode 100644 index 0000000000..a9b1d5d8fd --- /dev/null +++ b/extensions/feishu/src/targets.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolveReceiveIdType } from "./targets.js"; + +describe("resolveReceiveIdType", () => { + it("resolves chat IDs by oc_ prefix", () => { + expect(resolveReceiveIdType("oc_123")).toBe("chat_id"); + }); + + it("resolves open IDs by ou_ prefix", () => { + expect(resolveReceiveIdType("ou_123")).toBe("open_id"); + }); + + it("defaults unprefixed IDs to user_id", () => { + expect(resolveReceiveIdType("u_123")).toBe("user_id"); + }); +}); diff --git a/extensions/feishu/src/targets.ts b/extensions/feishu/src/targets.ts index 94f46a9e48..a0bd20fb1a 100644 --- a/extensions/feishu/src/targets.ts +++ b/extensions/feishu/src/targets.ts @@ -57,7 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_ if (trimmed.startsWith(OPEN_ID_PREFIX)) { return "open_id"; } - return "open_id"; + return "user_id"; } export function looksLikeFeishuId(raw: string): boolean {