From 25a7708f19ca10c757350ebf68d1e117d0734541 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 6 Jul 2026 06:22:36 -0700 Subject: [PATCH] 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> --- extensions/feishu/src/channel.test.ts | 277 ++++++++++++++++++ extensions/feishu/src/channel.ts | 45 ++- extensions/feishu/src/native-card.ts | 230 +++++++++++++++ extensions/feishu/src/outbound.test.ts | 261 +++++++++++++++++ extensions/feishu/src/outbound.ts | 196 +++---------- extensions/feishu/src/send.ts | 28 +- ...sage-action-runner.plugin-dispatch.test.ts | 65 +++- 7 files changed, 906 insertions(+), 196 deletions(-) create mode 100644 extensions/feishu/src/native-card.ts 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", () => {