From df3a247db2a90da2a2593f85bdd5ef07f6b39a91 Mon Sep 17 00:00:00 2001 From: songlei Date: Sun, 15 Mar 2026 09:31:46 +0800 Subject: [PATCH] feat(feishu): structured cards with identity header, note footer, and streaming enhancements (openclaw#29938) Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: nszhsl <512639+nszhsl@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/feishu/src/bot.ts | 5 + extensions/feishu/src/outbound.ts | 36 ++++++- .../feishu/src/reply-dispatcher.test.ts | 62 ++++++++---- extensions/feishu/src/reply-dispatcher.ts | 72 +++++++++++++- extensions/feishu/src/send.test.ts | 37 +++++++- extensions/feishu/src/send.ts | 86 +++++++++++++++++ extensions/feishu/src/streaming-card.ts | 94 +++++++++++++++++-- src/infra/outbound/identity.test.ts | 4 + src/infra/outbound/identity.ts | 7 +- src/plugin-sdk/feishu.ts | 2 + 11 files changed, 372 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cc32c2daf..9d47e75bcfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. +- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl. ### Fixes diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index c7943eda7b1..dc8326b1dba 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -9,6 +9,7 @@ import { issuePairingChallenge, normalizeAgentId, recordPendingHistoryEntryIfEnabled, + resolveAgentOutboundIdentity, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, @@ -1561,6 +1562,7 @@ export async function handleFeishuMessage(params: { if (agentId === activeAgentId) { // Active agent: real Feishu dispatcher (responds on Feishu) + const identity = resolveAgentOutboundIdentity(cfg, agentId); const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ cfg, agentId, @@ -1573,6 +1575,7 @@ export async function handleFeishuMessage(params: { threadReply, mentionTargets: ctx.mentionTargets, accountId: account.accountId, + identity, messageCreateTimeMs, }); @@ -1660,6 +1663,7 @@ export async function handleFeishuMessage(params: { ctx.mentionedBot, ); + const identity = resolveAgentOutboundIdentity(cfg, route.agentId); const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ cfg, agentId: route.agentId, @@ -1672,6 +1676,7 @@ export async function handleFeishuMessage(params: { threadReply, mentionTargets: ctx.mentionTargets, accountId: account.accountId, + identity, messageCreateTimeMs, }); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 75e1fa8d42b..fa121e88178 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; import { getFeishuRuntime } from "./runtime.js"; -import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js"; +import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; function normalizePossibleLocalImagePath(text: string | undefined): string | null { const raw = text?.trim(); @@ -81,7 +81,16 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => { + sendText: async ({ + cfg, + to, + text, + accountId, + replyToId, + threadId, + mediaLocalRoots, + identity, + }) => { const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); // Scheme A compatibility shim: // when upstream accidentally returns a local image path as plain text, @@ -104,6 +113,29 @@ export const feishuOutbound: ChannelOutboundAdapter = { } } + const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); + const renderMode = account.config?.renderMode ?? "auto"; + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + if (useCard) { + const header = identity + ? { + title: identity.emoji + ? `${identity.emoji} ${identity.name ?? ""}`.trim() + : (identity.name ?? ""), + template: "blue" as const, + } + : undefined; + const result = await sendStructuredCardFeishu({ + cfg, + to, + text, + replyToMessageId, + replyInThread: threadId != null && !replyToId, + accountId: accountId ?? undefined, + header: header?.title ? header : undefined, + }); + return { channel: "feishu", ...result }; + } const result = await sendOutboundText({ cfg, to, diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 3f20a594e25..c7b2f9af28b 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -4,6 +4,7 @@ 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 sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); @@ -17,6 +18,7 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock })); vi.mock("./send.js", () => ({ sendMessageFeishu: sendMessageFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, + sendStructuredCardFeishu: sendStructuredCardFeishuMock, })); vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock })); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); @@ -56,6 +58,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { vi.clearAllMocks(); streamingInstances.length = 0; sendMediaFeishuMock.mockResolvedValue(undefined); + sendStructuredCardFeishuMock.mockResolvedValue(undefined); resolveFeishuAccountMock.mockReturnValue({ accountId: "main", @@ -255,11 +258,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { - replyToMessageId: undefined, - replyInThread: undefined, - rootId: "om_root_topic", - }); + expect(streamingInstances[0].start).toHaveBeenCalledWith( + "oc_chat", + "chat_id", + expect.objectContaining({ + replyToMessageId: undefined, + replyInThread: undefined, + rootId: "om_root_topic", + header: { title: "agent", template: "blue" }, + note: "Agent: agent", + }), + ); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); @@ -275,7 +284,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```"); + expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", { + note: "Agent: agent", + }); }); it("delivers distinct final payloads after streaming close", async () => { @@ -287,9 +298,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(2); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```"); + expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", { + note: "Agent: agent", + }); expect(streamingInstances[1].close).toHaveBeenCalledTimes(1); - expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```"); + expect(streamingInstances[1].close).toHaveBeenCalledWith( + "```md\n完整回复第一段 + 第二段\n```", + { + note: "Agent: agent", + }, + ); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); @@ -303,7 +321,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```"); + expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", { + note: "Agent: agent", + }); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); @@ -367,7 +387,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(streamingInstances).toHaveLength(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); - expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world"); + expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", { + note: "Agent: agent", + }); }); it("sends media-only payloads as attachments", async () => { @@ -436,7 +458,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { ); }); - it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => { + it("passes replyInThread to sendStructuredCardFeishu for card text", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", appId: "app_id", @@ -454,7 +476,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); await options.deliver({ text: "card text" }, { kind: "final" }); - expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( + expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, @@ -591,10 +613,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(1); - expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { - replyToMessageId: "om_msg", - replyInThread: true, - }); + expect(streamingInstances[0].start).toHaveBeenCalledWith( + "oc_chat", + "chat_id", + expect.objectContaining({ + replyToMessageId: "om_msg", + replyInThread: true, + header: { title: "agent", template: "blue" }, + note: "Agent: agent", + }), + ); }); it("disables streaming for thread replies and keeps reply metadata", async () => { @@ -608,7 +636,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); expect(streamingInstances).toHaveLength(0); - expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( + expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith( expect.objectContaining({ replyToMessageId: "om_msg", replyInThread: true, diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 68f0a2c2a0f..00f5f576af2 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -3,6 +3,7 @@ import { createTypingCallbacks, logTypingFailure, type ClawdbotConfig, + type OutboundIdentity, type ReplyPayload, type RuntimeEnv, } from "openclaw/plugin-sdk/feishu"; @@ -12,7 +13,12 @@ import { sendMediaFeishu } from "./media.js"; import type { MentionTarget } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; -import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js"; +import { + sendMarkdownCardFeishu, + sendMessageFeishu, + sendStructuredCardFeishu, + type CardHeaderConfig, +} from "./send.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; import { resolveReceiveIdType } from "./targets.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; @@ -36,6 +42,36 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined { return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp; } +/** Build a card header from agent identity config. */ +function resolveCardHeader( + agentId: string, + identity: OutboundIdentity | undefined, +): CardHeaderConfig { + const name = identity?.name?.trim() || agentId; + const emoji = identity?.emoji?.trim(); + return { + title: emoji ? `${emoji} ${name}` : name, + template: identity?.theme ?? "blue", + }; +} + +/** Build a card note footer from agent identity and model context. */ +function resolveCardNote( + agentId: string, + identity: OutboundIdentity | undefined, + prefixCtx: { model?: string; provider?: string }, +): string { + const name = identity?.name?.trim() || agentId; + const parts: string[] = [`Agent: ${name}`]; + if (prefixCtx.model) { + parts.push(`Model: ${prefixCtx.model}`); + } + if (prefixCtx.provider) { + parts.push(`Provider: ${prefixCtx.provider}`); + } + return parts.join(" | "); +} + export type CreateFeishuReplyDispatcherParams = { cfg: ClawdbotConfig; agentId: string; @@ -50,6 +86,7 @@ export type CreateFeishuReplyDispatcherParams = { rootId?: string; mentionTargets?: MentionTarget[]; accountId?: string; + identity?: OutboundIdentity; /** Epoch ms when the inbound message was created. Used to suppress typing * indicators on old/replayed messages after context compaction (#30418). */ messageCreateTimeMs?: number; @@ -68,6 +105,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP rootId, mentionTargets, accountId, + identity, } = params; const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId; const threadReplyMode = threadReply === true; @@ -221,10 +259,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP params.runtime.log?.(`feishu[${account.accountId}] ${message}`), ); try { + const cardHeader = resolveCardHeader(agentId, identity); + const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); await streaming.start(chatId, resolveReceiveIdType(chatId), { replyToMessageId, replyInThread: effectiveReplyInThread, rootId, + header: cardHeader, + note: cardNote, }); } catch (error) { params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); @@ -244,7 +286,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (mentionTargets?.length) { text = buildMentionedCardContent(mentionTargets, text); } - await streaming.close(text); + const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); + await streaming.close(text, { note: finalNote }); } streaming = null; streamingStartPromise = null; @@ -320,6 +363,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (shouldDeliverText) { const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + let first = true; if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as @@ -368,7 +412,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } if (useCard) { - await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind }); + const cardHeader = resolveCardHeader(agentId, identity); + const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); + for (const chunk of core.channel.text.chunkTextWithMode( + text, + textChunkLimit, + chunkMode, + )) { + await sendStructuredCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: first ? mentionTargets : undefined, + accountId, + header: cardHeader, + note: cardNote, + }); + first = false; + } + if (info?.kind === "final") { + deliveredFinalTexts.add(text); + } } else { await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind }); } diff --git a/extensions/feishu/src/send.test.ts b/extensions/feishu/src/send.test.ts index 8971f91cb3e..21ef7e53a1a 100644 --- a/extensions/feishu/src/send.test.ts +++ b/extensions/feishu/src/send.test.ts @@ -1,6 +1,11 @@ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { getMessageFeishu, listFeishuThreadMessages } from "./send.js"; +import { + buildStructuredCard, + getMessageFeishu, + listFeishuThreadMessages, + resolveFeishuCardTemplate, +} from "./send.js"; const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({ @@ -233,3 +238,33 @@ describe("getMessageFeishu", () => { ]); }); }); + +describe("resolveFeishuCardTemplate", () => { + it("accepts supported Feishu templates", () => { + expect(resolveFeishuCardTemplate(" purple ")).toBe("purple"); + }); + + it("drops unsupported free-form identity themes", () => { + expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined(); + }); +}); + +describe("buildStructuredCard", () => { + it("falls back to blue when the header template is unsupported", () => { + const card = buildStructuredCard("hello", { + header: { + title: "Agent", + template: "space lobster", + }, + }); + + expect(card).toEqual( + expect.objectContaining({ + header: { + title: { tag: "plain_text", content: "Agent" }, + template: "blue", + }, + }), + ); + }); +}); diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts index d4cad09fe07..57c0fbc0600 100644 --- a/extensions/feishu/src/send.ts +++ b/extensions/feishu/src/send.ts @@ -10,6 +10,21 @@ import { resolveFeishuSendTarget } from "./send-target.js"; import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js"; const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]); +const FEISHU_CARD_TEMPLATES = new Set([ + "blue", + "green", + "red", + "orange", + "purple", + "indigo", + "wathet", + "turquoise", + "yellow", + "grey", + "carmine", + "violet", + "lime", +]); function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean { if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) { @@ -518,6 +533,77 @@ export function buildMarkdownCard(text: string): Record { }; } +/** Header configuration for structured Feishu cards. */ +export type CardHeaderConfig = { + /** Header title text, e.g. "💻 Coder" */ + title: string; + /** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */ + template?: string; +}; + +export function resolveFeishuCardTemplate(template?: string): string | undefined { + const normalized = template?.trim().toLowerCase(); + if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) { + return undefined; + } + return normalized; +} + +/** + * Build a Feishu interactive card with optional header and note footer. + * When header/note are omitted, behaves identically to buildMarkdownCard. + */ +export function buildStructuredCard( + text: string, + options?: { + header?: CardHeaderConfig; + note?: string; + }, +): Record { + const elements: Record[] = [{ tag: "markdown", content: text }]; + if (options?.note) { + elements.push({ tag: "hr" }); + elements.push({ tag: "markdown", content: `${options.note}` }); + } + const card: Record = { + schema: "2.0", + config: { wide_screen_mode: true }, + body: { elements }, + }; + if (options?.header) { + card.header = { + title: { tag: "plain_text", content: options.header.title }, + template: resolveFeishuCardTemplate(options.header.template) ?? "blue", + }; + } + return card; +} + +/** + * Send a message as a structured card with optional header and note. + */ +export async function sendStructuredCardFeishu(params: { + cfg: ClawdbotConfig; + to: string; + text: string; + replyToMessageId?: string; + /** When true, reply creates a Feishu topic thread instead of an inline reply */ + replyInThread?: boolean; + mentions?: MentionTarget[]; + accountId?: string; + header?: CardHeaderConfig; + note?: string; +}): Promise { + const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } = + params; + let cardText = text; + if (mentions && mentions.length > 0) { + cardText = buildMentionedCardContent(mentions, text); + } + const card = buildStructuredCard(cardText, { header, note }); + return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId }); +} + /** * Send a message as a markdown card (interactive message). * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.) diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 856c3c2fecd..bd2908218a6 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -4,10 +4,25 @@ 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 }; +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 = { @@ -152,6 +167,7 @@ export class FeishuStreamingSession { 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) { @@ -163,13 +179,24 @@ export class FeishuStreamingSession { async start( receiveId: string, receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", - options?: StreamingStartOptions, + 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: { @@ -177,14 +204,12 @@ export class FeishuStreamingSession { summary: { content: "[Generating...]" }, streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } }, }, - body: { - elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }], - }, + body: { elements }, }; if (options?.header) { cardJson.header = { title: { tag: "plain_text", content: options.header.title }, - template: options.header.template ?? "blue", + template: resolveFeishuCardTemplate(options.header.template) ?? "blue", }; } @@ -257,7 +282,13 @@ export class FeishuStreamingSession { throw new Error(`Send card failed: ${sendRes.msg}`); } - this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" }; + 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}`); } @@ -307,6 +338,10 @@ export class FeishuStreamingSession { } 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) { @@ -322,11 +357,44 @@ export class FeishuStreamingSession { await this.queue; } - async close(finalText?: string): Promise { + 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); @@ -339,6 +407,11 @@ export class FeishuStreamingSession { 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({ @@ -364,8 +437,11 @@ export class FeishuStreamingSession { 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=${this.state.cardId}`); + this.log?.(`Closed streaming: cardId=${finalState.cardId}`); } isActive(): boolean { diff --git a/src/infra/outbound/identity.test.ts b/src/infra/outbound/identity.test.ts index 6b1afc69221..d31d8a6dd06 100644 --- a/src/infra/outbound/identity.test.ts +++ b/src/infra/outbound/identity.test.ts @@ -20,11 +20,13 @@ describe("normalizeOutboundIdentity", () => { name: " Demo Bot ", avatarUrl: " https://example.com/a.png ", emoji: " 🤖 ", + theme: " ocean ", }), ).toEqual({ name: "Demo Bot", avatarUrl: "https://example.com/a.png", emoji: "🤖", + theme: "ocean", }); expect( normalizeOutboundIdentity({ @@ -41,6 +43,7 @@ describe("resolveAgentOutboundIdentity", () => { resolveAgentIdentityMock.mockReturnValueOnce({ name: " Agent Smith ", emoji: " 🕶️ ", + theme: " noir ", }); resolveAgentAvatarMock.mockReturnValueOnce({ kind: "remote", @@ -51,6 +54,7 @@ describe("resolveAgentOutboundIdentity", () => { name: "Agent Smith", emoji: "🕶️", avatarUrl: "https://example.com/avatar.png", + theme: "noir", }); }); diff --git a/src/infra/outbound/identity.ts b/src/infra/outbound/identity.ts index 64b522a6ad0..536b5a801e8 100644 --- a/src/infra/outbound/identity.ts +++ b/src/infra/outbound/identity.ts @@ -6,6 +6,7 @@ export type OutboundIdentity = { name?: string; avatarUrl?: string; emoji?: string; + theme?: string; }; export function normalizeOutboundIdentity( @@ -17,10 +18,11 @@ export function normalizeOutboundIdentity( const name = identity.name?.trim() || undefined; const avatarUrl = identity.avatarUrl?.trim() || undefined; const emoji = identity.emoji?.trim() || undefined; - if (!name && !avatarUrl && !emoji) { + const theme = identity.theme?.trim() || undefined; + if (!name && !avatarUrl && !emoji && !theme) { return undefined; } - return { name, avatarUrl, emoji }; + return { name, avatarUrl, emoji, theme }; } export function resolveAgentOutboundIdentity( @@ -33,5 +35,6 @@ export function resolveAgentOutboundIdentity( name: agentIdentity?.name, emoji: agentIdentity?.emoji, avatarUrl: avatar.kind === "remote" ? avatar.url : undefined, + theme: agentIdentity?.theme, }); } diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index 783f730edbe..772cde76ff2 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -56,6 +56,8 @@ export { buildSecretInputSchema } from "./secret-input-schema.js"; export { createDedupeCache } from "../infra/dedupe.js"; export { installRequestBodyLimitGuard, readJsonBodyWithLimit } from "../infra/http-body.js"; export { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +export { resolveAgentOutboundIdentity } from "../infra/outbound/identity.js"; +export type { OutboundIdentity } from "../infra/outbound/identity.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { AnyAgentTool, OpenClawPluginApi } from "../plugins/types.js";