diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts
index c2a921a4df0..ca26a42957c 100644
--- a/extensions/feishu/src/channel.test.ts
+++ b/extensions/feishu/src/channel.test.ts
@@ -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** body' },
+ },
+ {
+ 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** <at id="ou_1">body</at>',
+ },
+ { 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" });
diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts
index b8847552b96..50f35ffd624 100644
--- a/extensions/feishu/src/channel.ts
+++ b/extensions/feishu/src/channel.ts
@@ -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,
keys: string[],
@@ -801,13 +827,24 @@ export const feishuPlugin: ChannelPlugin]/g, (char) => {
+ switch (char) {
+ case "&":
+ return "&";
+ case "<":
+ return "<";
+ case ">":
+ return ">";
+ 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 | 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 | 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 => 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[] {
+ 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 => Boolean(action));
+ }
+ return [];
+}
+
+export function sanitizeNativeFeishuCard(
+ card: Record,
+): Record | 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 => 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 | 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;
+ }
+}
diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts
index 041a86836fa..fccea538f20 100644
--- a/extensions/feishu/src/outbound.test.ts
+++ b/extensions/feishu/src/outbound.test.ts
@@ -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,
diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts
index 6650d791b3d..90b79d90904 100644
--- a/extensions/feishu/src/outbound.ts
+++ b/extensions/feishu/src/outbound.ts
@@ -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): Record]/g, (char) => {
- switch (char) {
- case "&":
- return "&";
- case "<":
- return "<";
- case ">":
- return ">";
- 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 | 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 | 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 => 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 = {
- 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[] {
- 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 => Boolean(action));
- }
- return [];
-}
-
-function sanitizeNativeFeishuCard(
- card: Record,
-): Record | 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 => 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 }) {
const feishuData = payload.channelData?.feishu;
if (!isRecord(feishuData)) {
@@ -264,7 +115,8 @@ function readNativeFeishuCard(payload: { channelData?: Record }
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));
diff --git a/extensions/feishu/src/send.ts b/extensions/feishu/src/send.ts
index 2da1319bead..b82348cda9d 100644
--- a/extensions/feishu/src/send.ts
+++ b/extensions/feishu/src/send.ts
@@ -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.
diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts
index b013f87ac6b..bfc43cccf0b 100644
--- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts
+++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts
@@ -1657,12 +1657,27 @@ describe("runMessageAction plugin dispatch", () => {
});
describe("presentation-only send behavior", () => {
- const handleAction = vi.fn(async ({ params }: { params: Record }) =>
- jsonResult({
- ok: true,
- presentation: params.presentation ?? null,
- message: params.message ?? null,
- }),
+ const handleAction = vi.fn(
+ async ({ cfg, params }: { cfg: OpenClawConfig; params: Record }) => {
+ 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", () => {