diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index 9bc6fa07fd1..1c55a413159 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -395,7 +395,7 @@ describe("feishuPlugin actions", () => { expect(details.chatId).toBe("oc_group_1"); }); - it("renders presentation button labels into the card fallback", async () => { + it("renders presentation buttons as native Feishu card buttons", async () => { sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" }); await feishuPlugin.actions?.handleAction?.({ @@ -421,10 +421,97 @@ describe("feishuPlugin actions", () => { "send card args", ); const card = requireRecord(sendCardArgs.card, "card"); + const elements = requireArray(requireRecord(card.body, "card body").elements, "card elements"); + expect(elements).toEqual([ + { + tag: "button", + text: { tag: "plain_text", content: "Run help" }, + type: "default", + behaviors: [ + { + type: "callback", + value: { + oc: "ocf1", + k: "quick", + a: "feishu.payload.button", + q: "feishu.quick_actions.help", + }, + }, + ], + }, + ]); + expect( + elements.some((element) => requireRecord(element, "card element").tag === "action"), + ).toBe(false); + }); + + it("renders legacy web_app presentation buttons as native Feishu link buttons", async () => { + sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { + to: "chat:oc_group_1", + presentation: { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Open app", web_app: { url: "https://example.com/app" } }], + }, + ], + }, + }, + cfg, + accountId: undefined, + toolContext: {}, + } as never); + + const sendCardArgs = requireRecord( + mockCallArg(sendCardFeishuMock, 0, 0, "sendCardFeishu"), + "send card args", + ); + const card = requireRecord(sendCardArgs.card, "card"); + const elements = requireArray(requireRecord(card.body, "card body").elements, "card elements"); + expect(elements).toEqual([ + { + tag: "button", + text: { tag: "plain_text", content: "Open app" }, + type: "default", + behaviors: [{ type: "open_url", default_url: "https://example.com/app" }], + }, + ]); + }); + + it("does not duplicate title-only presentation cards in the body fallback", async () => { + sendCardFeishuMock.mockResolvedValueOnce({ messageId: "om_card", chatId: "oc_group_1" }); + + await feishuPlugin.actions?.handleAction?.({ + action: "send", + params: { + to: "chat:oc_group_1", + presentation: { + title: "Status", + blocks: [], + }, + }, + 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: "Status" }, + template: "blue", + }); expect(requireRecord(card.body, "card body").elements).toEqual([ { tag: "markdown", - content: "- Run help", + content: "", }, ]); }); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 1bca4e0bad5..e7dd9492762 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -25,10 +25,7 @@ import { createChannelDirectoryAdapter, createRuntimeDirectoryLiveAdapter, } from "openclaw/plugin-sdk/directory-runtime"; -import { - normalizeMessagePresentation, - renderMessagePresentationFallbackText, -} from "openclaw/plugin-sdk/interactive-runtime"; +import { normalizeMessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; @@ -70,6 +67,7 @@ import { import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js"; import { messageActionTargetAliases } from "./message-action-contract.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; +import { buildFeishuPresentationCard } from "./presentation-card.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; import { createFeishuSendReceipt } from "./send-result.js"; @@ -118,6 +116,15 @@ function containsLegacyFeishuCardCommandValue(node: unknown): boolean { if (node.tag === "button" && hasLegacyFeishuCardCommandValue(node.value)) { return true; } + if ( + node.tag === "button" && + Array.isArray(node.behaviors) && + node.behaviors.some( + (behavior) => isRecord(behavior) && hasLegacyFeishuCardCommandValue(behavior.value), + ) + ) { + return true; + } return Object.values(node).some((value) => containsLegacyFeishuCardCommandValue(value)); } @@ -183,41 +190,6 @@ const feishuMessageAdapter = defineChannelMessageAdapter({ }, }); -function buildFeishuPresentationCard(params: { - presentation: NonNullable>; - fallbackText?: string; -}): Record { - const fallbackPresentation: NonNullable> = { - ...(params.presentation.tone ? { tone: params.presentation.tone } : {}), - blocks: params.presentation.blocks, - }; - return { - schema: "2.0", - config: { - width_mode: "fill", - }, - ...(params.presentation.title - ? { - header: { - title: { tag: "plain_text", content: params.presentation.title }, - template: "blue", - }, - } - : {}), - body: { - elements: [ - { - tag: "markdown", - content: renderMessagePresentationFallbackText({ - text: params.fallbackText, - presentation: fallbackPresentation, - }), - }, - ], - }, - }; -} - async function createFeishuActionClient(account: ResolvedFeishuAccount) { const { createFeishuClient } = await import("./client.js"); return createFeishuClient(account); diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index 71a65b51f8d..08bd9707b7d 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -447,9 +447,31 @@ describe("feishuOutbound.sendPayload native cards", () => { tag: "markdown", content: "Approve the request?", }); + expect(renderedCard?.body?.elements).toEqual([ + { + tag: "markdown", + content: "Approve the request?", + }, + { + tag: "button", + text: { tag: "plain_text", content: "Approve" }, + type: "primary", + behaviors: [ + { + type: "callback", + value: { + oc: "ocf1", + k: "quick", + a: "feishu.payload.button", + q: "/approve req_1 allow-once", + }, + }, + ], + }, + ]); expect( renderedCard?.body?.elements?.some((element: { tag?: string }) => element.tag === "action"), - ).toBe(true); + ).toBe(false); const { presentation: _presentation, ...coreRenderedPayload } = rendered; const result = await feishuOutbound.sendPayload?.({ cfg: emptyConfig, @@ -468,6 +490,82 @@ describe("feishuOutbound.sendPayload native cards", () => { expectFeishuResult(result, "native_card_msg"); }); + it("renders webApp presentation buttons into Feishu channelData link buttons", async () => { + const presentation: MessagePresentation = { + blocks: [ + { + type: "buttons", + buttons: [{ label: "Open app", webApp: { url: "https://example.com/app" } }], + }, + ], + }; + const payload = { presentation }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + + if (!rendered) { + throw new Error("expected Feishu presentation renderer to return a payload"); + } + expect(rendered.text).toBe("- Open app: https://example.com/app"); + const renderedChannelData = rendered.channelData as + | { feishu?: { card?: Record } } + | undefined; + expect(renderedChannelData?.feishu?.card?.body?.elements).toEqual([ + { + tag: "button", + text: { tag: "plain_text", content: "Open app" }, + type: "default", + behaviors: [{ type: "open_url", default_url: "https://example.com/app" }], + }, + ]); + }); + + it("does not duplicate title-only presentation cards in outbound fallbacks", async () => { + const presentation: MessagePresentation = { + title: "Status", + blocks: [], + }; + const payload = { presentation }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + + if (!rendered) { + throw new Error("expected Feishu presentation renderer to return a payload"); + } + const renderedChannelData = rendered.channelData as + | { feishu?: { card?: Record } } + | undefined; + const renderedCard = renderedChannelData?.feishu?.card; + expect(renderedCard?.header).toEqual({ + title: { tag: "plain_text", content: "Status" }, + template: "blue", + }); + expect(renderedCard?.body?.elements).toEqual([ + { + tag: "markdown", + content: "", + }, + ]); + }); + it("sends interactive button payloads as native Feishu cards", async () => { const result = await feishuOutbound.sendPayload?.({ cfg: emptyConfig, @@ -501,19 +599,48 @@ describe("feishuOutbound.sendPayload native cards", () => { tag: "markdown", content: "Approve the request?", }); - const actionElement = card.body.elements.find( - (element: { tag?: string }) => element.tag === "action", + expect(card.body.elements).toEqual([ + { tag: "markdown", content: "Choose an action" }, + { + tag: "markdown", + content: "Approve the request?", + }, + { + tag: "button", + text: { tag: "plain_text", content: "Approve" }, + type: "primary", + behaviors: [ + { + type: "callback", + value: { + oc: "ocf1", + k: "quick", + a: "feishu.payload.button", + q: "/approve req_1 allow-once", + }, + }, + ], + }, + { + tag: "button", + text: { tag: "plain_text", content: "Deny" }, + type: "danger", + behaviors: [ + { + type: "callback", + value: { + oc: "ocf1", + k: "quick", + a: "feishu.payload.button", + q: "/approve req_1 deny", + }, + }, + ], + }, + ]); + expect(card.body.elements.some((element: { tag?: string }) => element.tag === "action")).toBe( + false, ); - expect(actionElement?.actions[0]?.text).toEqual({ tag: "plain_text", content: "Approve" }); - expect(actionElement?.actions[0]?.type).toBe("primary"); - expect(actionElement?.actions[0]?.value?.oc).toBe("ocf1"); - expect(actionElement?.actions[0]?.value?.k).toBe("quick"); - expect(actionElement?.actions[0]?.value?.q).toBe("/approve req_1 allow-once"); - expect(actionElement?.actions[1]?.text).toEqual({ tag: "plain_text", content: "Deny" }); - expect(actionElement?.actions[1]?.type).toBe("danger"); - expect(actionElement?.actions[1]?.value?.oc).toBe("ocf1"); - expect(actionElement?.actions[1]?.value?.k).toBe("quick"); - expect(actionElement?.actions[1]?.value?.q).toBe("/approve req_1 deny"); expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expectFeishuResult(result, "native_card_msg"); }); @@ -550,11 +677,13 @@ describe("feishuOutbound.sendPayload native cards", () => { tag: "markdown", content: "</font><at id=\"ou_2\">Injected</at>", }); - const actionElement = card.body.elements.find( - (element: { tag?: string }) => element.tag === "action", + const buttonElement = card.body.elements.find( + (element: { tag?: string }) => element.tag === "button", ); - expect(actionElement?.actions[0]?.text).toEqual({ tag: "plain_text", content: "Open" }); - expect(actionElement?.actions[0]?.url).toBe("https://example.com/path"); + expect(buttonElement?.text).toEqual({ tag: "plain_text", content: "Open" }); + expect(buttonElement?.behaviors).toEqual([ + { type: "open_url", default_url: "https://example.com/path" }, + ]); expect(JSON.stringify(card)).not.toContain("javascript:"); }); @@ -581,6 +710,12 @@ describe("feishuOutbound.sendPayload native cards", () => { { tag: "action", actions: [ + { + tag: "button", + text: { tag: "plain_text", content: "Promote" }, + type: "success", + url: "https://example.com/promote", + }, { tag: "button", text: { tag: "plain_text", content: "Bad link" }, @@ -606,15 +741,16 @@ describe("feishuOutbound.sendPayload native cards", () => { expect(card.body.elements).toEqual([ { tag: "markdown", content: '<at id="ou_1">ping</at>' }, { - tag: "action", - actions: [ - { - tag: "button", - text: { tag: "plain_text", content: "Good link" }, - type: "default", - url: "https://example.com", - }, - ], + tag: "button", + text: { tag: "plain_text", content: "Promote" }, + type: "primary", + behaviors: [{ type: "open_url", default_url: "https://example.com/promote" }], + }, + { + tag: "button", + text: { tag: "plain_text", content: "Good link" }, + type: "default", + behaviors: [{ type: "open_url", default_url: "https://example.com" }], }, ]); expect(JSON.stringify(card)).not.toContain("file://"); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 60c54e32235..93ff2c37f52 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -9,8 +9,6 @@ import { normalizeMessagePresentation, renderMessagePresentationFallbackText, resolveInteractiveTextFallback, - type MessagePresentationBlock, - type MessagePresentationButton, } from "openclaw/plugin-sdk/interactive-runtime"; import { resolvePayloadMediaUrls, @@ -20,13 +18,13 @@ import { import { statRegularFileSync } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveFeishuAccount } from "./accounts.js"; -import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; import { createFeishuClient } from "./client.js"; import { cleanupAmbientCommentTypingReaction } from "./comment-reaction.js"; import { parseFeishuCommentTarget } from "./comment-target.js"; import { deliverCommentThreadText } from "./drive.js"; import { sendMediaFeishu, shouldSuppressFeishuTextForVoiceMedia } from "./media.js"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js"; +import { buildFeishuPresentationCardElements } from "./presentation-card.js"; import { resolveFeishuCardTemplate, sendCardFeishu, @@ -86,23 +84,16 @@ function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } -function escapeFeishuCardMarkdownText(text: string): string { - return text.replace(/[&<>]/g, (char) => { - switch (char) { - case "&": - return "&"; - case "<": - return "<"; - case ">": - return ">"; - default: - return char; - } +function markRenderedFeishuCard(card: Record): Record { + Object.defineProperty(card, RENDERED_FEISHU_CARD, { + value: true, + enumerable: false, }); + return card; } -function resolveSafeFeishuButtonUrl(url: string | undefined): string | undefined { - const trimmed = url?.trim(); +function resolveSafeFeishuButtonUrl(url: unknown): string | undefined { + const trimmed = typeof url === "string" ? url.trim() : ""; if (!trimmed) { return undefined; } @@ -114,12 +105,21 @@ function resolveSafeFeishuButtonUrl(url: string | undefined): string | undefined } } -function markRenderedFeishuCard(card: Record): Record { - Object.defineProperty(card, RENDERED_FEISHU_CARD, { - value: true, - enumerable: false, - }); - return card; +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 { @@ -134,41 +134,76 @@ function sanitizeNativeFeishuCardButton(button: unknown): Record 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: mapFeishuButtonType(style), + type: + style === "danger" + ? "danger" + : style === "primary" || style === "success" + ? "primary" + : "default", + behaviors, }; - const safeUrl = resolveSafeFeishuButtonUrl( - typeof button.url === "string" ? button.url : undefined, - ); - if (safeUrl) { - rendered.url = safeUrl; - } - if (isRecord(button.value) && button.value.oc === "ocf1") { - rendered.value = button.value; - } - return rendered.url || rendered.value ? rendered : undefined; + return rendered; } -function sanitizeNativeFeishuCardElement(element: unknown): Record | undefined { +function sanitizeNativeFeishuCardElements(element: unknown): Record[] { if (!isRecord(element) || typeof element.tag !== "string") { - return undefined; + return []; } if (element.tag === "hr") { - return { tag: "hr" }; + return [{ tag: "hr" }]; } if (element.tag === "markdown" && typeof element.content === "string") { - return { tag: "markdown", content: escapeFeishuCardMarkdownText(element.content) }; + return [ + { + tag: "markdown", + content: element.content.replace(/[&<>]/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + default: + return char; + } + }), + }, + ]; + } + if (element.tag === "button") { + const button = sanitizeNativeFeishuCardButton(element); + return button ? [button] : []; } if (element.tag === "action" && Array.isArray(element.actions)) { - const actions = element.actions + return element.actions .map((action) => sanitizeNativeFeishuCardButton(action)) .filter((action): action is Record => Boolean(action)); - return actions.length > 0 ? { tag: "action", actions } : undefined; } - return undefined; + return []; } function sanitizeNativeFeishuCard( @@ -177,7 +212,7 @@ function sanitizeNativeFeishuCard( const body = isRecord(card.body) ? card.body : undefined; const rawElements = Array.isArray(body?.elements) ? body.elements : []; const elements = rawElements - .map((element) => sanitizeNativeFeishuCardElement(element)) + .flatMap((element) => sanitizeNativeFeishuCardElements(element)) .filter((element): element is Record => Boolean(element)); if (elements.length === 0) { return undefined; @@ -221,79 +256,6 @@ function readNativeFeishuCard(payload: { channelData?: Record } return sanitizeNativeFeishuCard(card); } -function mapFeishuButtonType(style: MessagePresentationButton["style"]) { - if (style === "primary" || style === "success") { - return "primary"; - } - if (style === "danger") { - return "danger"; - } - return "default"; -} - -function buildFeishuPayloadButton( - button: MessagePresentationButton, -): Record | undefined { - const rendered: Record = { - tag: "button", - text: { - tag: "plain_text", - content: button.label, - }, - type: mapFeishuButtonType(button.style), - }; - if (button.url) { - const safeUrl = resolveSafeFeishuButtonUrl(button.url); - if (safeUrl) { - rendered.url = safeUrl; - } - } - if (button.value) { - rendered.value = createFeishuCardInteractionEnvelope({ - k: "quick", - a: "feishu.payload.button", - q: button.value, - }); - } - return rendered.url || rendered.value ? rendered : undefined; -} - -function buildFeishuCardElementForBlock( - block: MessagePresentationBlock, -): Record | undefined { - if (block.type === "text") { - return { tag: "markdown", content: escapeFeishuCardMarkdownText(block.text) }; - } - if (block.type === "context") { - return { - tag: "markdown", - content: `${escapeFeishuCardMarkdownText(block.text)}`, - }; - } - if (block.type === "divider") { - return { tag: "hr" }; - } - if (block.type === "buttons") { - const actions = block.buttons - .map((button) => buildFeishuPayloadButton(button)) - .filter((button): button is Record => Boolean(button)); - if (actions.length === 0) { - return undefined; - } - return { - tag: "action", - actions, - }; - } - const labels = block.options.map((option) => `- ${option.label}`).join("\n"); - return { - tag: "markdown", - content: `${escapeFeishuCardMarkdownText( - block.placeholder?.trim() || "Options", - )}:\n${escapeFeishuCardMarkdownText(labels)}`, - }; -} - function buildFeishuPayloadCard(params: { payload: Parameters>[0]["payload"]; text?: string; @@ -316,22 +278,14 @@ function buildFeishuPayloadCard(params: { text: params.text ?? params.payload.text, interactive, }); - const elements: Record[] = []; - if (text?.trim()) { - elements.push({ tag: "markdown", content: escapeFeishuCardMarkdownText(text) }); - } - for (const block of presentation?.blocks ?? []) { - const element = buildFeishuCardElementForBlock(block); - if (element) { - elements.push(element); - } - } - if (elements.length === 0) { - elements.push({ - tag: "markdown", - content: renderMessagePresentationFallbackText({ text, presentation }), - }); - } + const elements = presentation + ? buildFeishuPresentationCardElements({ presentation, fallbackText: text }) + : [ + { + tag: "markdown", + content: renderMessagePresentationFallbackText({ text, presentation }), + }, + ]; const identityTitle = params.identity ? params.identity.emoji diff --git a/extensions/feishu/src/presentation-card.ts b/extensions/feishu/src/presentation-card.ts new file mode 100644 index 00000000000..8e6e1799fcf --- /dev/null +++ b/extensions/feishu/src/presentation-card.ts @@ -0,0 +1,192 @@ +import { + normalizeMessagePresentation, + renderMessagePresentationFallbackText, + type MessagePresentationBlock, + type MessagePresentationButton, +} from "openclaw/plugin-sdk/interactive-runtime"; +import { createFeishuCardInteractionEnvelope } from "./card-interaction.js"; + +type NormalizedMessagePresentation = NonNullable>; + +function escapeFeishuCardMarkdownText(text: string): string { + return text.replace(/[&<>]/g, (char) => { + switch (char) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + default: + return char; + } + }); +} + +function resolveSafeFeishuButtonUrl(url: string | undefined): string | undefined { + const trimmed = 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 resolveFeishuButtonUrl(button: MessagePresentationButton): string | undefined { + return button.url ?? button.webApp?.url ?? button.web_app?.url; +} + +function mapFeishuButtonType(style: MessagePresentationButton["style"]) { + if (style === "primary" || style === "success") { + return "primary"; + } + if (style === "danger") { + return "danger"; + } + return "default"; +} + +function buildFeishuPayloadButton( + button: MessagePresentationButton, +): Record | undefined { + const behaviors: Record[] = []; + const rendered: Record = { + tag: "button", + text: { + tag: "plain_text", + content: button.label, + }, + type: mapFeishuButtonType(button.style), + }; + const url = resolveFeishuButtonUrl(button); + if (url) { + const safeUrl = resolveSafeFeishuButtonUrl(url); + if (safeUrl) { + behaviors.push({ type: "open_url", default_url: safeUrl }); + } + } + if (button.value) { + behaviors.push({ + type: "callback", + value: createFeishuCardInteractionEnvelope({ + k: "quick", + a: "feishu.payload.button", + q: button.value, + }), + }); + } + if (behaviors.length === 0) { + return undefined; + } + rendered.behaviors = behaviors; + return rendered; +} + +export function buildFeishuCardElementsForBlock( + block: MessagePresentationBlock, +): Record[] { + if (block.type === "text") { + return [{ tag: "markdown", content: escapeFeishuCardMarkdownText(block.text) }]; + } + if (block.type === "context") { + return [ + { + tag: "markdown", + content: `${escapeFeishuCardMarkdownText(block.text)}`, + }, + ]; + } + if (block.type === "divider") { + return [{ tag: "hr" }]; + } + if (block.type === "buttons") { + return block.buttons + .map((button) => buildFeishuPayloadButton(button)) + .filter((button): button is Record => Boolean(button)); + } + const labels = block.options.map((option) => `- ${option.label}`).join("\n"); + return [ + { + tag: "markdown", + content: `${escapeFeishuCardMarkdownText( + block.placeholder?.trim() || "Options", + )}:\n${escapeFeishuCardMarkdownText(labels)}`, + }, + ]; +} + +function resolvePresentationHeaderTemplate(tone: NormalizedMessagePresentation["tone"]) { + if (tone === "danger") { + return "red"; + } + if (tone === "warning") { + return "orange"; + } + if (tone === "success") { + return "green"; + } + return "blue"; +} + +export function buildFeishuPresentationCardElements(params: { + presentation: NormalizedMessagePresentation; + fallbackText?: string; +}): Record[] { + const elements: Record[] = []; + const fallbackText = params.fallbackText?.trim(); + if (fallbackText) { + elements.push({ + tag: "markdown", + content: escapeFeishuCardMarkdownText(fallbackText), + }); + } + for (const block of params.presentation.blocks) { + for (const element of buildFeishuCardElementsForBlock(block)) { + elements.push(element); + } + } + if (elements.length > 0) { + return elements; + } + return [ + { + tag: "markdown", + content: renderMessagePresentationFallbackText({ + text: params.fallbackText, + presentation: params.presentation.title + ? { + ...(params.presentation.tone ? { tone: params.presentation.tone } : {}), + blocks: params.presentation.blocks, + } + : params.presentation, + }), + }, + ]; +} + +export function buildFeishuPresentationCard(params: { + presentation: NormalizedMessagePresentation; + fallbackText?: string; +}): Record { + return { + schema: "2.0", + config: { + width_mode: "fill", + }, + ...(params.presentation.title + ? { + header: { + title: { tag: "plain_text", content: params.presentation.title }, + template: resolvePresentationHeaderTemplate(params.presentation.tone), + }, + } + : {}), + body: { + elements: buildFeishuPresentationCardElements(params), + }, + }; +}