fix(feishu): send card JSON message params as cards (#100883)

* fix(feishu): send plain card JSON as interactive cards

Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>

* fix(clownfish): address review for gitcrawl-21-autonomous-drip-20260706 (1)

Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>

* fix(feishu): preserve native card compatibility

Reported-by: @ZenoRewn
Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>

* fix(feishu): fix native card fallback precedence

* fix(feishu): fix native card fallback precedence

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: martingarramon <263922628+martingarramon@users.noreply.github.com>
This commit is contained in:
Vincent Koc 2026-07-06 06:22:36 -07:00 committed by GitHub
parent bab1415b5c
commit 25a7708f19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 906 additions and 196 deletions

View file

@ -356,6 +356,217 @@ describe("feishuPlugin actions", () => {
expect(details.chatId).toBe("oc_group_1");
});
it("sends plain message card JSON as a native Feishu card", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const result = await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: JSON.stringify({
schema: "2.0",
header: {
title: { tag: "plain_text", content: "Plain JSON card" },
template: "green",
},
body: {
elements: [{ tag: "markdown", content: "Card body" }],
},
}),
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
expect(sendCardArgs.cfg).toBe(cfg);
expect(sendCardArgs.to).toBe("chat:oc_group_1");
expect(sendCardArgs.accountId).toBeUndefined();
expect(sendCardArgs.replyToMessageId).toBeUndefined();
expect(sendCardArgs.replyInThread).toBe(false);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Plain JSON card" },
template: "green",
});
expect(card.body).toEqual({
elements: [{ tag: "markdown", content: "Card body" }],
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
const details = resultDetails(result);
expect(details.ok).toBe(true);
expect(details.messageId).toBe("om_card");
expect(details.chatId).toBe("oc_group_1");
});
it("sends legacy top-level elements card JSON as a native Feishu card", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: JSON.stringify({
header: {
title: { tag: "plain_text", content: "Legacy JSON card" },
template: "green",
},
elements: [
{
tag: "div",
text: { tag: "lark_md", content: '**Legacy** <at id="ou_1">body</at>' },
},
{
tag: "div",
text: { tag: "plain_text", content: "Literal *text*" },
},
],
}),
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Legacy JSON card" },
template: "green",
});
expect(card.body).toEqual({
elements: [
{
tag: "markdown",
content: '**Legacy** &lt;at id="ou_1"&gt;body&lt;/at&gt;',
},
{ tag: "markdown", content: "Literal \\*text\\*" },
],
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
});
it("detects message card JSON after the configured response prefix", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
const cardJson = JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "Prefixed card" }],
},
});
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: `[Nexus] ${cardJson}`,
},
cfg: {
...cfg,
messages: { responsePrefix: "[Nexus]" },
},
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.body).toEqual({
elements: [{ tag: "markdown", content: "Prefixed card" }],
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
});
it("sends wrapped interactive card JSON as a Feishu thread reply card", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
await feishuPlugin.actions?.handleAction?.({
action: "thread-reply",
params: {
to: "chat:oc_group_1",
messageId: "om_parent",
text: JSON.stringify({
type: "interactive",
card: {
body: {
elements: [{ tag: "markdown", content: "Reply card" }],
},
},
}),
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
expect(sendCardArgs.replyToMessageId).toBe("om_parent");
expect(sendCardArgs.replyInThread).toBe(true);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.body).toEqual({
elements: [{ tag: "markdown", content: "Reply card" }],
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
});
it("keeps ordinary JSON messages on the text path", async () => {
sendMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_sent", chatId: "oc_group_1" });
const message = JSON.stringify({ ok: true, elements: "not-a-card" });
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message },
cfg,
accountId: undefined,
toolContext: {},
} as never);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageFeishuMock).toHaveBeenCalledWith({
cfg,
to: "chat:oc_group_1",
text: message,
accountId: undefined,
replyToMessageId: undefined,
replyInThread: false,
});
});
it("rejects card JSON sent with media", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: JSON.stringify({
elements: [{ tag: "markdown", content: "Card body" }],
}),
media: "/tmp/image.png",
},
cfg,
accountId: undefined,
toolContext: {},
mediaLocalRoots: ["/tmp"],
} as never),
).rejects.toThrow("Feishu send does not support card with media.");
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(feishuOutboundSendMediaMock).not.toHaveBeenCalled();
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
});
it("renders presentation messages as cards", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
@ -402,6 +613,72 @@ describe("feishuPlugin actions", () => {
expect(details.chatId).toBe("oc_group_1");
});
it("prefers structured presentation over raw card JSON text", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: JSON.stringify({
header: { title: { tag: "plain_text", content: "Raw card" } },
elements: [{ tag: "markdown", content: "Raw body" }],
}),
presentation: {
title: "Structured card",
blocks: [{ type: "text", text: "Structured body" }],
},
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Structured card" },
template: "blue",
});
expect(card.body).toEqual({
elements: [{ tag: "markdown", content: "Structured body" }],
});
});
it("prefers structured interactive input over raw card JSON text", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });
await feishuPlugin.actions?.handleAction?.({
action: "send",
params: {
to: "chat:oc_group_1",
message: JSON.stringify({
header: { title: { tag: "plain_text", content: "Raw card" } },
elements: [{ tag: "markdown", content: "Raw body" }],
}),
interactive: {
blocks: [{ type: "text", text: "Interactive body" }],
},
},
cfg,
accountId: undefined,
toolContext: {},
} as never);
const sendCardArgs = requireRecord(
mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"),
"send card args",
);
const card = requireRecord(sendCardArgs.card, "card");
expect(card.header).toBeUndefined();
expect(card.body).toEqual({
elements: [{ tag: "markdown", content: "Interactive body" }],
});
});
it("renders presentation buttons as native Feishu card buttons", async () => {
sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" });

View file

@ -28,7 +28,12 @@ import {
createChannelDirectoryAdapter,
createRuntimeDirectoryLiveAdapter,
} from "openclaw/plugin-sdk/directory-runtime";
import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime";
import {
interactiveReplyToPresentation,
normalizeInteractiveReply,
normalizeMessagePresentation,
resolveInteractiveTextFallback,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers";
@ -72,6 +77,7 @@ import {
import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
import { feishuDoctor } from "./doctor.js";
import { messageActionTargetAliases } from "./message-action-contract.js";
import { readNativeFeishuCardJson } from "./native-card.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { buildFeishuPresentationCard } from "./presentation-card.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
@ -584,6 +590,26 @@ function readFirstString(
return undefined;
}
const UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN = /\{[a-zA-Z][a-zA-Z0-9.]*\}/;
function resolveFeishuMessageActionResponsePrefix(ctx: ChannelMessageActionContext) {
const configured = ctx.cfg.messages?.responsePrefix;
if (!configured) {
return undefined;
}
const agentId = (ctx.agentId?.trim() || "main").toLowerCase();
const identityName = ctx.cfg.agents?.list
?.find((agent) => agent.id.trim().toLowerCase() === agentId)
?.identity?.name?.trim();
const resolved =
configured === "auto"
? identityName
? `[${identityName}]`
: undefined
: configured.replace(/\{(?:identity\.name|identityname)\}/gi, identityName ?? "$&");
return resolved && !UNRESOLVED_RESPONSE_PREFIX_VAR_PATTERN.test(resolved) ? resolved : undefined;
}
function readOptionalPositiveInteger(
params: Record<string, unknown>,
keys: string[],
@ -801,13 +827,24 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
if (ctx.action === "thread-reply" && !replyToMessageId) {
throw new Error("Feishu thread-reply requires messageId.");
}
const presentation = normalizeMessagePresentation(ctx.params.presentation);
const text = readFirstString(ctx.params, ["text", "message"]);
const textCard = readNativeFeishuCardJson(text, {
responsePrefix: resolveFeishuMessageActionResponsePrefix(ctx),
});
const interactive = normalizeInteractiveReply(ctx.params.interactive);
const presentation =
normalizeMessagePresentation(ctx.params.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
const mediaUrl = readFeishuMediaParam(ctx.params);
const audioAsVoice = readBooleanParam(ctx.params, ["asVoice", "audioAsVoice"]);
const card = presentation
? buildFeishuPresentationCard({ presentation, fallbackText: text })
: undefined;
? buildFeishuPresentationCard({
presentation,
fallbackText: textCard
? undefined
: resolveInteractiveTextFallback({ text, interactive }),
})
: textCard;
if (card && mediaUrl) {
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
}

View file

@ -0,0 +1,230 @@
// Feishu native interactive card helpers shared by action and outbound delivery.
import {
isRecord,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
const FEISHU_CARD_TEMPLATES = new Set([
"blue",
"green",
"red",
"orange",
"purple",
"indigo",
"wathet",
"turquoise",
"yellow",
"grey",
"carmine",
"violet",
"lime",
]);
export function resolveFeishuCardTemplate(template?: string): string | undefined {
const normalized = normalizeOptionalLowercaseString(template);
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
return undefined;
}
return normalized;
}
function escapeFeishuCardMarkdownText(text: string): string {
return text.replace(/[&<>]/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
default:
return char;
}
});
}
function escapeFeishuCardPlainText(text: string): string {
return escapeFeishuCardMarkdownText(text).replace(/([\\`*_{}[\]()#+\-!|>~])/g, "\\$1");
}
function resolveSafeFeishuButtonUrl(url: unknown): string | undefined {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : undefined;
} catch {
return undefined;
}
}
function sanitizeNativeFeishuButtonBehavior(
behavior: unknown,
): Record<string, unknown> | undefined {
if (!isRecord(behavior)) {
return undefined;
}
if (behavior.type === "open_url") {
const safeUrl =
resolveSafeFeishuButtonUrl(behavior.default_url) ?? resolveSafeFeishuButtonUrl(behavior.url);
return safeUrl ? { type: "open_url", default_url: safeUrl } : undefined;
}
if (behavior.type === "callback" && isRecord(behavior.value) && behavior.value.oc === "ocf1") {
return { type: "callback", value: behavior.value };
}
return undefined;
}
function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown> | undefined {
if (!isRecord(button)) {
return undefined;
}
const text =
isRecord(button.text) && typeof button.text.content === "string"
? button.text.content
: undefined;
if (!text?.trim()) {
return undefined;
}
const style =
button.type === "danger"
? "danger"
: button.type === "primary" || button.type === "success"
? "primary"
: undefined;
const behaviors = Array.isArray(button.behaviors)
? button.behaviors
.map((behavior) => sanitizeNativeFeishuButtonBehavior(behavior))
.filter((behavior): behavior is Record<string, unknown> => Boolean(behavior))
: [];
const rootSafeUrl = resolveSafeFeishuButtonUrl(button.url);
if (rootSafeUrl) {
behaviors.push({ type: "open_url", default_url: rootSafeUrl });
}
if (isRecord(button.value) && button.value.oc === "ocf1") {
behaviors.push({ type: "callback", value: button.value });
}
if (behaviors.length === 0) {
return undefined;
}
return {
tag: "button",
text: { tag: "plain_text", content: text },
type: style === "danger" ? "danger" : style === "primary" ? "primary" : "default",
behaviors,
};
}
function sanitizeNativeFeishuCardElements(element: unknown): Record<string, unknown>[] {
if (!isRecord(element) || typeof element.tag !== "string") {
return [];
}
if (element.tag === "hr") {
return [{ tag: "hr" }];
}
if (element.tag === "markdown" && typeof element.content === "string") {
return [
{
tag: "markdown",
content: escapeFeishuCardMarkdownText(element.content),
},
];
}
if (element.tag === "div" && isRecord(element.text)) {
const text = element.text;
if (text.tag === "lark_md" && typeof text.content === "string") {
return [
{
tag: "markdown",
content: escapeFeishuCardMarkdownText(text.content),
},
];
}
if (text.tag === "plain_text" && typeof text.content === "string") {
return [
{
tag: "markdown",
content: escapeFeishuCardPlainText(text.content),
},
];
}
return [];
}
if (element.tag === "button") {
const button = sanitizeNativeFeishuCardButton(element);
return button ? [button] : [];
}
if (element.tag === "action" && Array.isArray(element.actions)) {
return element.actions
.map((action) => sanitizeNativeFeishuCardButton(action))
.filter((action): action is Record<string, unknown> => Boolean(action));
}
return [];
}
export function sanitizeNativeFeishuCard(
card: Record<string, unknown>,
): Record<string, unknown> | undefined {
const normalizedCard = card.type === "interactive" && isRecord(card.card) ? card.card : card;
const body = isRecord(normalizedCard.body) ? normalizedCard.body : undefined;
const rawElements = Array.isArray(body?.elements)
? body.elements
: Array.isArray(normalizedCard.elements)
? normalizedCard.elements
: [];
const elements = rawElements
.flatMap((element) => sanitizeNativeFeishuCardElements(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (elements.length === 0) {
return undefined;
}
const header = isRecord(normalizedCard.header) ? normalizedCard.header : undefined;
const title =
isRecord(header?.title) && typeof header.title.content === "string"
? header.title.content
: undefined;
return {
schema: "2.0",
config: { width_mode: "fill" },
...(title?.trim()
? {
header: {
title: { tag: "plain_text", content: title },
template:
resolveFeishuCardTemplate(
typeof header?.template === "string" ? header.template : undefined,
) ?? "blue",
},
}
: {}),
body: { elements },
};
}
export function readNativeFeishuCardJson(
text: string | undefined,
options?: { responsePrefix?: string },
): Record<string, unknown> | undefined {
let trimmed = text?.trim();
const responsePrefix = options?.responsePrefix;
if (trimmed && responsePrefix && trimmed.startsWith(responsePrefix)) {
const suffix = trimmed.slice(responsePrefix.length);
// The runner inserts one separator before the original message. Requiring it
// avoids treating arbitrary prose before a JSON object as a native card.
if (/^\s+\{/.test(suffix)) {
trimmed = suffix.trimStart();
}
}
if (!trimmed?.startsWith("{") || !trimmed.endsWith("}")) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(trimmed);
return isRecord(parsed) ? sanitizeNativeFeishuCard(parsed) : undefined;
} catch {
return undefined;
}
}

View file

@ -329,6 +329,34 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
expect(sendMessageCall()?.accountId).toBe("main");
});
it("sends wrapped interactive card text as a native Feishu card", async () => {
const text = JSON.stringify({
type: "interactive",
card: {
body: {
elements: [{ tag: "markdown", content: "Wrapped body" }],
},
},
});
const result = await sendText({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
replyToId: "om_reply_1",
});
expect(sendCardCall()?.to).toBe("chat_1");
expect(sendCardCall()?.accountId).toBe("main");
expect(sendCardCall()?.replyToMessageId).toBe("om_reply_1");
expect(sendCardCall()?.card?.body?.elements).toEqual([
{ tag: "markdown", content: "Wrapped body" },
]);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expectFeishuResult(result, "native_card_msg");
});
it("falls back to plain text if local-image media send fails", async () => {
const { dir, file } = await createTmpImage();
sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
@ -790,6 +818,194 @@ describe("feishuOutbound.sendPayload native cards", () => {
expect(JSON.stringify(card)).not.toContain("image-secret");
});
it("sends plain payload text card JSON as a native Feishu card", async () => {
const text = JSON.stringify({
schema: "2.0",
header: {
title: { tag: "plain_text", content: "Plain JSON card" },
template: "green",
},
body: {
elements: [{ tag: "markdown", content: "Card body" }],
},
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: { text },
});
const card = sendCardCall()?.card;
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Plain JSON card" },
template: "green",
});
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Card body" }]);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expectFeishuResult(result, "native_card_msg");
});
it("sends legacy top-level elements payload text card JSON as a native Feishu card", async () => {
const text = JSON.stringify({
header: {
title: { tag: "plain_text", content: "Legacy JSON card" },
template: "green",
},
elements: [
{
tag: "div",
text: { tag: "lark_md", content: "**Legacy body**" },
},
{
tag: "div",
text: { tag: "plain_text", content: "Literal *text*" },
},
],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: { text },
});
const card = sendCardCall()?.card;
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Legacy JSON card" },
template: "green",
});
expect(card.body.elements).toEqual([
{ tag: "markdown", content: "**Legacy body**" },
{ tag: "markdown", content: "Literal \\*text\\*" },
]);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expectFeishuResult(result, "native_card_msg");
});
it.each(["lark_md", "plain_text"])(
"keeps top-level legacy %s text items on the text fallback path",
async (tag) => {
const text = JSON.stringify({
elements: [{ tag, content: "Not a valid root legacy card element" }],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: { text },
});
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageCall()?.text).toBe(text);
expectFeishuResult(result, "text_msg");
},
);
it("keeps unsupported legacy element shapes on the text fallback path", async () => {
const text = JSON.stringify({
elements: [
{
tag: "div",
text: { tag: "unsupported", content: "Not a supported legacy text element" },
},
],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: { text },
});
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageCall()?.text).toBe(text);
expectFeishuResult(result, "text_msg");
});
it("prefers structured presentation over raw card JSON payload text", async () => {
const text = JSON.stringify({
header: { title: { tag: "plain_text", content: "Raw card" } },
elements: [{ tag: "markdown", content: "Raw body" }],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: {
text,
presentation: {
title: "Structured card",
blocks: [{ type: "text", text: "Structured body" }],
},
},
});
const card = sendCardCall()?.card;
expect(card.header).toEqual({
title: { tag: "plain_text", content: "Structured card" },
template: "blue",
});
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Structured body" }]);
expectFeishuResult(result, "native_card_msg");
});
it("prefers structured interactive input over raw card JSON payload text", async () => {
const text = JSON.stringify({
header: { title: { tag: "plain_text", content: "Raw card" } },
elements: [{ tag: "markdown", content: "Raw body" }],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: {
text,
interactive: {
blocks: [{ type: "text", text: "Interactive body" }],
},
},
});
const card = sendCardCall()?.card;
expect(card.header).toBeUndefined();
expect(card.body.elements).toEqual([{ tag: "markdown", content: "Interactive body" }]);
expectFeishuResult(result, "native_card_msg");
});
it("keeps invalid plain card JSON on the text fallback path", async () => {
const text = JSON.stringify({
schema: "2.0",
body: {
elements: [{ tag: "img", img_key: "image-secret" }],
},
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "chat_1",
text,
accountId: "main",
payload: { text },
});
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(sendMessageCall()?.text).toBe(text);
expectFeishuResult(result, "text_msg");
});
it("sends payload media before final native cards", async () => {
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
@ -867,6 +1083,51 @@ describe("feishuOutbound.sendPayload native cards", () => {
expectFeishuResult(result, "reply_msg");
});
it.each([
[
"presentation",
{
presentation: {
title: "Structured card",
blocks: [{ type: "text" as const, text: "Structured body" }],
},
},
"Structured card\n\nStructured body",
],
[
"interactive",
{
interactive: {
blocks: [{ type: "text" as const, text: "Interactive body" }],
},
},
"Interactive body",
],
])(
"prefers structured %s over raw card JSON for document comments",
async (_kind, structuredPayload, expectedText) => {
const text = JSON.stringify({
header: { title: { tag: "plain_text", content: "Raw card" } },
elements: [{ tag: "markdown", content: "Raw body" }],
});
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,
to: "comment:docx:doxcn123:7623358762119646411",
text,
accountId: "main",
payload: {
text,
...structuredPayload,
},
});
expect(sendCardFeishuMock).not.toHaveBeenCalled();
expect(commentThreadParams()?.content).toBe(expectedText);
expectFeishuResult(result, "reply_msg");
},
);
it("omits command guidance when all command buttons have URLs overriding the fallback text", async () => {
const result = await feishuOutbound.sendPayload?.({
cfg: emptyConfig,

View file

@ -34,10 +34,14 @@ import {
shouldSuppressFeishuTextForVoiceMedia,
type SendMediaResult,
} from "./media.js";
import {
readNativeFeishuCardJson,
resolveFeishuCardTemplate,
sanitizeNativeFeishuCard,
} from "./native-card.js";
import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js";
import { buildFeishuPresentationCardElements } from "./presentation-card.js";
import {
resolveFeishuCardTemplate,
sendCardFeishu,
sendMarkdownCardFeishu,
sendMessageFeishu,
@ -99,159 +103,6 @@ function markRenderedFeishuCard(card: Record<string, unknown>): Record<string, u
return card;
}
function escapeFeishuCardMarkdownText(text: string): string {
return text.replace(/[&<>]/g, (char) => {
switch (char) {
case "&":
return "&amp;";
case "<":
return "&lt;";
case ">":
return "&gt;";
default:
return char;
}
});
}
function resolveSafeFeishuButtonUrl(url: unknown): string | undefined {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return undefined;
}
try {
const parsed = new URL(trimmed);
return parsed.protocol === "https:" || parsed.protocol === "http:" ? trimmed : undefined;
} catch {
return undefined;
}
}
function sanitizeNativeFeishuButtonBehavior(
behavior: unknown,
): Record<string, unknown> | undefined {
if (!isRecord(behavior)) {
return undefined;
}
if (behavior.type === "open_url") {
const safeUrl =
resolveSafeFeishuButtonUrl(behavior.default_url) ?? resolveSafeFeishuButtonUrl(behavior.url);
return safeUrl ? { type: "open_url", default_url: safeUrl } : undefined;
}
if (behavior.type === "callback" && isRecord(behavior.value) && behavior.value.oc === "ocf1") {
return { type: "callback", value: behavior.value };
}
return undefined;
}
function sanitizeNativeFeishuCardButton(button: unknown): Record<string, unknown> | undefined {
if (!isRecord(button)) {
return undefined;
}
const text =
isRecord(button.text) && typeof button.text.content === "string"
? button.text.content
: undefined;
if (!text?.trim()) {
return undefined;
}
const style =
button.type === "danger"
? "danger"
: button.type === "primary" || button.type === "success"
? "primary"
: undefined;
const behaviors = Array.isArray(button.behaviors)
? button.behaviors
.map((behavior) => sanitizeNativeFeishuButtonBehavior(behavior))
.filter((behavior): behavior is Record<string, unknown> => Boolean(behavior))
: [];
const rootSafeUrl = resolveSafeFeishuButtonUrl(button.url);
if (rootSafeUrl) {
behaviors.push({ type: "open_url", default_url: rootSafeUrl });
}
if (isRecord(button.value) && button.value.oc === "ocf1") {
behaviors.push({ type: "callback", value: button.value });
}
if (behaviors.length === 0) {
return undefined;
}
const rendered: Record<string, unknown> = {
tag: "button",
text: { tag: "plain_text", content: text },
type:
style === "danger"
? "danger"
: style === "primary" || style === "success"
? "primary"
: "default",
behaviors,
};
return rendered;
}
function sanitizeNativeFeishuCardElements(element: unknown): Record<string, unknown>[] {
if (!isRecord(element) || typeof element.tag !== "string") {
return [];
}
if (element.tag === "hr") {
return [{ tag: "hr" }];
}
if (element.tag === "markdown" && typeof element.content === "string") {
return [
{
tag: "markdown",
content: escapeFeishuCardMarkdownText(element.content),
},
];
}
if (element.tag === "button") {
const button = sanitizeNativeFeishuCardButton(element);
return button ? [button] : [];
}
if (element.tag === "action" && Array.isArray(element.actions)) {
return element.actions
.map((action) => sanitizeNativeFeishuCardButton(action))
.filter((action): action is Record<string, unknown> => Boolean(action));
}
return [];
}
function sanitizeNativeFeishuCard(
card: Record<string, unknown>,
): Record<string, unknown> | undefined {
const body = isRecord(card.body) ? card.body : undefined;
const rawElements = Array.isArray(body?.elements) ? body.elements : [];
const elements = rawElements
.flatMap((element) => sanitizeNativeFeishuCardElements(element))
.filter((element): element is Record<string, unknown> => Boolean(element));
if (elements.length === 0) {
return undefined;
}
const header = isRecord(card.header) ? card.header : undefined;
const title =
isRecord(header?.title) && typeof header.title.content === "string"
? header.title.content
: undefined;
return markRenderedFeishuCard({
schema: "2.0",
config: { width_mode: "fill" },
...(title?.trim()
? {
header: {
title: { tag: "plain_text", content: title },
template:
resolveFeishuCardTemplate(
typeof header?.template === "string" ? header.template : undefined,
) ?? "blue",
},
}
: {}),
body: { elements },
});
}
function readNativeFeishuCard(payload: { channelData?: Record<string, unknown> }) {
const feishuData = payload.channelData?.feishu;
if (!isRecord(feishuData)) {
@ -264,7 +115,8 @@ function readNativeFeishuCard(payload: { channelData?: Record<string, unknown> }
if ((card as { [RENDERED_FEISHU_CARD]?: true })[RENDERED_FEISHU_CARD] === true) {
return card;
}
return sanitizeNativeFeishuCard(card);
const sanitizedCard = sanitizeNativeFeishuCard(card);
return sanitizedCard ? markRenderedFeishuCard(sanitizedCard) : undefined;
}
function buildFeishuPayloadCard(params: {
@ -277,18 +129,22 @@ function buildFeishuPayloadCard(params: {
return nativeCard;
}
const rawText = params.text ?? params.payload.text;
const textCard = readNativeFeishuCardJson(rawText);
const interactive = normalizeInteractiveReply(params.payload.interactive);
const presentation =
normalizeMessagePresentation(params.payload.presentation) ??
(interactive ? interactiveReplyToPresentation(interactive) : undefined);
if (!presentation && !interactive) {
return undefined;
return textCard ? markRenderedFeishuCard(textCard) : undefined;
}
const text = resolveInteractiveTextFallback({
text: params.text ?? params.payload.text,
interactive,
});
const text = textCard
? undefined
: resolveInteractiveTextFallback({
text: rawText,
interactive,
});
const elements = presentation
? buildFeishuPresentationCardElements({ presentation, fallbackText: text })
: [
@ -540,8 +396,14 @@ export const feishuOutbound: ChannelOutboundAdapter = {
const interactive = normalizeInteractiveReply(ctx.payload.interactive);
return interactive ? interactiveReplyToPresentation(interactive) : undefined;
})();
// Structured content replaces raw card JSON; document comments should
// render only the usable text fallback instead of exposing both forms.
const fallbackSourceText =
normalizedPresentation && readNativeFeishuCardJson(ctx.payload.text)
? undefined
: ctx.payload.text;
const presentationFallbackText = renderMessagePresentationFallbackText({
text: ctx.payload.text,
text: fallbackSourceText,
presentation: normalizedPresentation,
});
// Direct delivery retains blocks; core-rendered delivery carries the fact.
@ -653,6 +515,18 @@ export const feishuOutbound: ChannelOutboundAdapter = {
});
}
const card = readNativeFeishuCardJson(text);
if (card) {
return await sendCardFeishu({
cfg,
to,
card: markRenderedFeishuCard(card),
accountId: accountId ?? undefined,
replyToMessageId,
replyInThread,
});
}
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));

View file

@ -4,7 +4,6 @@ import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtim
import {
isRecord,
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
import type { ClawdbotConfig } from "../runtime-api.js";
@ -13,6 +12,7 @@ import { createFeishuClient } from "./client.js";
import { requestFeishuApi } from "./comment-shared.js";
import type { MentionTarget } from "./mention-target.types.js";
import { buildMentionedCardContent } from "./mention.js";
import { resolveFeishuCardTemplate } from "./native-card.js";
import { parsePostContent } from "./post.js";
import {
assertFeishuMessageApiSuccess,
@ -22,25 +22,11 @@ import {
import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
export { resolveFeishuCardTemplate };
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
const INTERACTIVE_CARD_FALLBACK_TEXT = "[Interactive Card]";
const POST_FALLBACK_TEXT = "[Rich text message]";
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)) {
return true;
@ -745,14 +731,6 @@ export type CardHeaderConfig = {
template?: string;
};
export function resolveFeishuCardTemplate(template?: string): string | undefined {
const normalized = normalizeOptionalLowercaseString(template);
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.

View file

@ -1657,12 +1657,27 @@ describe("runMessageAction plugin dispatch", () => {
});
describe("presentation-only send behavior", () => {
const handleAction = vi.fn(async ({ params }: { params: Record<string, unknown> }) =>
jsonResult({
ok: true,
presentation: params.presentation ?? null,
message: params.message ?? null,
}),
const handleAction = vi.fn(
async ({ cfg, params }: { cfg: OpenClawConfig; params: Record<string, unknown> }) => {
const message = typeof params.message === "string" ? params.message : "";
const responsePrefix = cfg.messages?.responsePrefix;
const rawMessage =
responsePrefix && message.startsWith(`${responsePrefix} `)
? message.slice(responsePrefix.length + 1)
: message;
let detectedCard = false;
try {
detectedCard = isRecord((JSON.parse(rawMessage) as { body?: unknown }).body);
} catch {
// Non-JSON text remains a normal plugin message.
}
return jsonResult({
ok: true,
presentation: params.presentation ?? null,
message: params.message ?? null,
detectedCard,
});
},
);
const cardPlugin: ChannelPlugin = {
@ -1737,6 +1752,44 @@ describe("runMessageAction plugin dispatch", () => {
"result payload",
);
});
it("keeps prefixed JSON recoverable by plugin-owned card detection", async () => {
const cardJson = JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "Card body" }],
},
});
const result = await runMessageAction({
cfg: {
channels: {
cardchat: {
enabled: true,
},
},
messages: { responsePrefix: "[Nexus]" },
} as OpenClawConfig,
action: "send",
params: {
channel: "cardchat",
target: "channel:test-card",
message: cardJson,
},
dryRun: false,
});
expect(result.kind).toBe("send");
expect(result.handledBy).toBe("plugin");
expectRecordFields(
readRecordField(result, "payload", "result payload"),
{
ok: true,
detectedCard: true,
},
"result payload",
);
const pluginParams = readRecordField(readFirstPluginCall(handleAction), "params", "params");
expect(pluginParams.message).toBe(`[Nexus] ${cardJson}`);
});
});
describe("poll plugin forwarding", () => {