mirror of https://github.com/openclaw/openclaw.git
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:
parent
f4dbd78afd
commit
df3a247db2
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue