Skip to content

Commit df3a247

Browse files
nszhslTakhoffman
andauthored
feat(feishu): structured cards with identity header, note footer, and streaming enhancements (#29938)
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: nszhsl <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent f4dbd78 commit df3a247

File tree

11 files changed

+372
-34
lines changed

11 files changed

+372
-34
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai
99
- 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.
1010
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
1111
- 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.
12+
- 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.
1213

1314
### Fixes
1415

extensions/feishu/src/bot.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
issuePairingChallenge,
1010
normalizeAgentId,
1111
recordPendingHistoryEntryIfEnabled,
12+
resolveAgentOutboundIdentity,
1213
resolveOpenProviderRuntimeGroupPolicy,
1314
resolveDefaultGroupPolicy,
1415
warnMissingProviderGroupPolicyFallbackOnce,
@@ -1561,6 +1562,7 @@ export async function handleFeishuMessage(params: {
15611562

15621563
if (agentId === activeAgentId) {
15631564
// Active agent: real Feishu dispatcher (responds on Feishu)
1565+
const identity = resolveAgentOutboundIdentity(cfg, agentId);
15641566
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
15651567
cfg,
15661568
agentId,
@@ -1573,6 +1575,7 @@ export async function handleFeishuMessage(params: {
15731575
threadReply,
15741576
mentionTargets: ctx.mentionTargets,
15751577
accountId: account.accountId,
1578+
identity,
15761579
messageCreateTimeMs,
15771580
});
15781581

@@ -1660,6 +1663,7 @@ export async function handleFeishuMessage(params: {
16601663
ctx.mentionedBot,
16611664
);
16621665

1666+
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
16631667
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
16641668
cfg,
16651669
agentId: route.agentId,
@@ -1672,6 +1676,7 @@ export async function handleFeishuMessage(params: {
16721676
threadReply,
16731677
mentionTargets: ctx.mentionTargets,
16741678
accountId: account.accountId,
1679+
identity,
16751680
messageCreateTimeMs,
16761681
});
16771682

extensions/feishu/src/outbound.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
44
import { resolveFeishuAccount } from "./accounts.js";
55
import { sendMediaFeishu } from "./media.js";
66
import { getFeishuRuntime } from "./runtime.js";
7-
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
7+
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
88

99
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
1010
const raw = text?.trim();
@@ -81,7 +81,16 @@ export const feishuOutbound: ChannelOutboundAdapter = {
8181
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
8282
chunkerMode: "markdown",
8383
textChunkLimit: 4000,
84-
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
84+
sendText: async ({
85+
cfg,
86+
to,
87+
text,
88+
accountId,
89+
replyToId,
90+
threadId,
91+
mediaLocalRoots,
92+
identity,
93+
}) => {
8594
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
8695
// Scheme A compatibility shim:
8796
// when upstream accidentally returns a local image path as plain text,
@@ -104,6 +113,29 @@ export const feishuOutbound: ChannelOutboundAdapter = {
104113
}
105114
}
106115

116+
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
117+
const renderMode = account.config?.renderMode ?? "auto";
118+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
119+
if (useCard) {
120+
const header = identity
121+
? {
122+
title: identity.emoji
123+
? `${identity.emoji} ${identity.name ?? ""}`.trim()
124+
: (identity.name ?? ""),
125+
template: "blue" as const,
126+
}
127+
: undefined;
128+
const result = await sendStructuredCardFeishu({
129+
cfg,
130+
to,
131+
text,
132+
replyToMessageId,
133+
replyInThread: threadId != null && !replyToId,
134+
accountId: accountId ?? undefined,
135+
header: header?.title ? header : undefined,
136+
});
137+
return { channel: "feishu", ...result };
138+
}
107139
const result = await sendOutboundText({
108140
cfg,
109141
to,

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
44
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
55
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
66
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7+
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
78
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
89
const createFeishuClientMock = vi.hoisted(() => vi.fn());
910
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
@@ -17,6 +18,7 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
1718
vi.mock("./send.js", () => ({
1819
sendMessageFeishu: sendMessageFeishuMock,
1920
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
21+
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
2022
}));
2123
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
2224
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
@@ -56,6 +58,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
5658
vi.clearAllMocks();
5759
streamingInstances.length = 0;
5860
sendMediaFeishuMock.mockResolvedValue(undefined);
61+
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
5962

6063
resolveFeishuAccountMock.mockReturnValue({
6164
accountId: "main",
@@ -255,11 +258,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
255258

256259
expect(streamingInstances).toHaveLength(1);
257260
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
258-
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
259-
replyToMessageId: undefined,
260-
replyInThread: undefined,
261-
rootId: "om_root_topic",
262-
});
261+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
262+
"oc_chat",
263+
"chat_id",
264+
expect.objectContaining({
265+
replyToMessageId: undefined,
266+
replyInThread: undefined,
267+
rootId: "om_root_topic",
268+
header: { title: "agent", template: "blue" },
269+
note: "Agent: agent",
270+
}),
271+
);
263272
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
264273
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
265274
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
@@ -275,7 +284,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
275284
expect(streamingInstances).toHaveLength(1);
276285
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
277286
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
278-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
287+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
288+
note: "Agent: agent",
289+
});
279290
});
280291

281292
it("delivers distinct final payloads after streaming close", async () => {
@@ -287,9 +298,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
287298

288299
expect(streamingInstances).toHaveLength(2);
289300
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
290-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
301+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", {
302+
note: "Agent: agent",
303+
});
291304
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
292-
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
305+
expect(streamingInstances[1].close).toHaveBeenCalledWith(
306+
"```md\n完整回复第一段 + 第二段\n```",
307+
{
308+
note: "Agent: agent",
309+
},
310+
);
293311
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
294312
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
295313
});
@@ -303,7 +321,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
303321

304322
expect(streamingInstances).toHaveLength(1);
305323
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
306-
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
324+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
325+
note: "Agent: agent",
326+
});
307327
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
308328
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
309329
});
@@ -367,7 +387,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
367387

368388
expect(streamingInstances).toHaveLength(1);
369389
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
370-
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
390+
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
391+
note: "Agent: agent",
392+
});
371393
});
372394

373395
it("sends media-only payloads as attachments", async () => {
@@ -436,7 +458,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
436458
);
437459
});
438460

439-
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
461+
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
440462
resolveFeishuAccountMock.mockReturnValue({
441463
accountId: "main",
442464
appId: "app_id",
@@ -454,7 +476,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
454476
});
455477
await options.deliver({ text: "card text" }, { kind: "final" });
456478

457-
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
479+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
458480
expect.objectContaining({
459481
replyToMessageId: "om_msg",
460482
replyInThread: true,
@@ -591,10 +613,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
591613
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
592614

593615
expect(streamingInstances).toHaveLength(1);
594-
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
595-
replyToMessageId: "om_msg",
596-
replyInThread: true,
597-
});
616+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
617+
"oc_chat",
618+
"chat_id",
619+
expect.objectContaining({
620+
replyToMessageId: "om_msg",
621+
replyInThread: true,
622+
header: { title: "agent", template: "blue" },
623+
note: "Agent: agent",
624+
}),
625+
);
598626
});
599627

600628
it("disables streaming for thread replies and keeps reply metadata", async () => {
@@ -608,7 +636,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
608636
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
609637

610638
expect(streamingInstances).toHaveLength(0);
611-
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
639+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
612640
expect.objectContaining({
613641
replyToMessageId: "om_msg",
614642
replyInThread: true,

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
createTypingCallbacks,
44
logTypingFailure,
55
type ClawdbotConfig,
6+
type OutboundIdentity,
67
type ReplyPayload,
78
type RuntimeEnv,
89
} from "openclaw/plugin-sdk/feishu";
@@ -12,7 +13,12 @@ import { sendMediaFeishu } from "./media.js";
1213
import type { MentionTarget } from "./mention.js";
1314
import { buildMentionedCardContent } from "./mention.js";
1415
import { getFeishuRuntime } from "./runtime.js";
15-
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
16+
import {
17+
sendMarkdownCardFeishu,
18+
sendMessageFeishu,
19+
sendStructuredCardFeishu,
20+
type CardHeaderConfig,
21+
} from "./send.js";
1622
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
1723
import { resolveReceiveIdType } from "./targets.js";
1824
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@@ -36,6 +42,36 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
3642
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
3743
}
3844

45+
/** Build a card header from agent identity config. */
46+
function resolveCardHeader(
47+
agentId: string,
48+
identity: OutboundIdentity | undefined,
49+
): CardHeaderConfig {
50+
const name = identity?.name?.trim() || agentId;
51+
const emoji = identity?.emoji?.trim();
52+
return {
53+
title: emoji ? `${emoji} ${name}` : name,
54+
template: identity?.theme ?? "blue",
55+
};
56+
}
57+
58+
/** Build a card note footer from agent identity and model context. */
59+
function resolveCardNote(
60+
agentId: string,
61+
identity: OutboundIdentity | undefined,
62+
prefixCtx: { model?: string; provider?: string },
63+
): string {
64+
const name = identity?.name?.trim() || agentId;
65+
const parts: string[] = [`Agent: ${name}`];
66+
if (prefixCtx.model) {
67+
parts.push(`Model: ${prefixCtx.model}`);
68+
}
69+
if (prefixCtx.provider) {
70+
parts.push(`Provider: ${prefixCtx.provider}`);
71+
}
72+
return parts.join(" | ");
73+
}
74+
3975
export type CreateFeishuReplyDispatcherParams = {
4076
cfg: ClawdbotConfig;
4177
agentId: string;
@@ -50,6 +86,7 @@ export type CreateFeishuReplyDispatcherParams = {
5086
rootId?: string;
5187
mentionTargets?: MentionTarget[];
5288
accountId?: string;
89+
identity?: OutboundIdentity;
5390
/** Epoch ms when the inbound message was created. Used to suppress typing
5491
* indicators on old/replayed messages after context compaction (#30418). */
5592
messageCreateTimeMs?: number;
@@ -68,6 +105,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
68105
rootId,
69106
mentionTargets,
70107
accountId,
108+
identity,
71109
} = params;
72110
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
73111
const threadReplyMode = threadReply === true;
@@ -221,10 +259,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
221259
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
222260
);
223261
try {
262+
const cardHeader = resolveCardHeader(agentId, identity);
263+
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
224264
await streaming.start(chatId, resolveReceiveIdType(chatId), {
225265
replyToMessageId,
226266
replyInThread: effectiveReplyInThread,
227267
rootId,
268+
header: cardHeader,
269+
note: cardNote,
228270
});
229271
} catch (error) {
230272
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
@@ -244,7 +286,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
244286
if (mentionTargets?.length) {
245287
text = buildMentionedCardContent(mentionTargets, text);
246288
}
247-
await streaming.close(text);
289+
const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
290+
await streaming.close(text, { note: finalNote });
248291
}
249292
streaming = null;
250293
streamingStartPromise = null;
@@ -320,6 +363,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
320363

321364
if (shouldDeliverText) {
322365
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
366+
let first = true;
323367

324368
if (info?.kind === "block") {
325369
// Drop internal block chunks unless we can safely consume them as
@@ -368,7 +412,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
368412
}
369413

370414
if (useCard) {
371-
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
415+
const cardHeader = resolveCardHeader(agentId, identity);
416+
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
417+
for (const chunk of core.channel.text.chunkTextWithMode(
418+
text,
419+
textChunkLimit,
420+
chunkMode,
421+
)) {
422+
await sendStructuredCardFeishu({
423+
cfg,
424+
to: chatId,
425+
text: chunk,
426+
replyToMessageId: sendReplyToMessageId,
427+
replyInThread: effectiveReplyInThread,
428+
mentions: first ? mentionTargets : undefined,
429+
accountId,
430+
header: cardHeader,
431+
note: cardNote,
432+
});
433+
first = false;
434+
}
435+
if (info?.kind === "final") {
436+
deliveredFinalTexts.add(text);
437+
}
372438
} else {
373439
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
374440
}

0 commit comments

Comments
 (0)