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>
This commit is contained in:
songlei 2026-03-15 09:31:46 +08:00 committed by GitHub
parent f4dbd78afd
commit df3a247db2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 372 additions and 34 deletions

View File

@ -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

View File

@ -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,
});

View File

@ -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,

View File

@ -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,

View File

@ -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 });
}

View File

@ -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",
},
}),
);
});
});

View File

@ -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<string, unknown> {
};
}
/** 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<string, unknown> {
const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
}
const card: Record<string, unknown> = {
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<FeishuSendResult> {
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.)

View File

@ -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<typeof setTimeout> | 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<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: {
@ -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<void> {
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);
@ -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 {

View File

@ -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",
});
});

View File

@ -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,
});
}

View File

@ -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";