diff --git a/CHANGELOG.md b/CHANGELOG.md index f64a0832296..41dd8731259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -167,6 +167,7 @@ Docs: https://docs.openclaw.ai - Mattermost: keep direct-message replies top-level by suppressing reply roots for DM delivery while preserving channel and group thread roots, and derive inbound chat kind from the trusted channel lookup instead of the websocket event channel type. Carries forward #60115, #55186, #72305, and #72659; refs #59758, #59981, #59791, and #57565. Thanks @vincentkoc, @jwchmodx, and @hnykda. - Docker: pre-create `/home/node/.openclaw` with node ownership and private permissions so first-run Docker Compose named volumes no longer fail startup with EACCES. (#48072, #63959; fixes #61279) Thanks @timoxue and @jeanibarz. - CLI/Gateway: treat local restart probe policy closes for connect, exact `device required`, pairing, and auth failures as Gateway reachability proof without accepting empty, broad standalone token/password/scope/role, or pair-substring 1008 close reasons. Fixes #48771; carries forward #48801; related #63491. Thanks @MarsDoge and @genoooool. +- Feishu: send outgoing interactive reply payloads as native cards with clickable buttons while preserving text, media, and document-comment fallbacks. Fixes #13175 and #58298; carries forward #47891. Thanks @Horacehxw. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. - Bonjour/Windows: hide the bundled mDNS advertiser's Windows ARP shell probe so Gateway startup no longer flashes command-prompt windows. Fixes #70238. Thanks @alexandre-leng, @PratikRai0101, @infinitypacific, and @tomerpeled. - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index a6846fd3909..81bda8f4394 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1283,6 +1283,26 @@ export const feishuPlugin: ChannelPlugin { + const runtime = await loadFeishuChannelRuntime(); + const renderPresentation = runtime.feishuOutbound.renderPresentation; + return renderPresentation ? await renderPresentation(ctx) : null; + }, + sendPayload: async (ctx) => { + const runtime = await loadFeishuChannelRuntime(); + const sendPayload = runtime.feishuOutbound.sendPayload; + if (!sendPayload) { + throw new Error("Feishu payload sending is not available."); + } + return await sendPayload(ctx); + }, ...createRuntimeOutboundDelegates({ getRuntime: loadFeishuChannelRuntime, sendText: { resolve: (runtime) => runtime.feishuOutbound.sendText }, diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index db939731277..5ec2bb40ab6 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -1,11 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { MessagePresentation } from "openclaw/plugin-sdk/interactive-runtime"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ClawdbotConfig } from "../runtime-api.js"; const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +const sendCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn()); const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn()); @@ -16,9 +18,28 @@ vi.mock("./media.js", () => ({ })); vi.mock("./send.js", () => ({ + sendCardFeishu: sendCardFeishuMock, sendMessageFeishu: sendMessageFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, sendStructuredCardFeishu: sendStructuredCardFeishuMock, + resolveFeishuCardTemplate: (template?: string) => + new Set([ + "blue", + "green", + "red", + "orange", + "purple", + "indigo", + "wathet", + "turquoise", + "yellow", + "grey", + "carmine", + "violet", + "lime", + ]).has(template ?? "") + ? template + : undefined, })); vi.mock("./runtime.js", () => ({ @@ -57,6 +78,7 @@ const cardRenderConfig: ClawdbotConfig = { function resetOutboundMocks() { vi.clearAllMocks(); sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); + sendCardFeishuMock.mockResolvedValue({ messageId: "native_card_msg" }); sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); @@ -218,6 +240,364 @@ describe("feishuOutbound.sendText local-image auto-convert", () => { }); }); +describe("feishuOutbound.sendPayload native cards", () => { + beforeEach(() => { + resetOutboundMocks(); + }); + + async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-payload-")); + const file = path.join(dir, `sample${ext}`); + await fs.writeFile(file, "image-data"); + return { dir, file }; + } + + it("renders presentation-only payloads into Feishu channelData cards for core delivery", async () => { + const presentation: MessagePresentation = { + title: "Approval", + tone: "success", + blocks: [ + { type: "text", text: "Approve the request?" }, + { + type: "buttons", + buttons: [ + { label: "Approve", value: "/approve req_1 allow-once", style: "success" as const }, + ], + }, + ], + }; + const payload = { presentation }; + const rendered = await feishuOutbound.renderPresentation?.({ + payload, + presentation, + ctx: { + cfg: emptyConfig, + to: "chat_1", + text: "", + accountId: "main", + payload, + }, + }); + + expect(rendered).toEqual( + expect.objectContaining({ + text: "Approval\n\nApprove the request?\n\n- Approve", + channelData: { + feishu: { + card: expect.objectContaining({ + schema: "2.0", + header: { + title: { tag: "plain_text", content: "Approval" }, + template: "green", + }, + body: { + elements: expect.arrayContaining([ + { tag: "markdown", content: "Approve the request?" }, + expect.objectContaining({ tag: "action" }), + ]), + }, + }), + }, + }, + }), + ); + + if (!rendered) { + throw new Error("expected Feishu presentation renderer to return a payload"); + } + const { presentation: _presentation, ...coreRenderedPayload } = rendered; + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: coreRenderedPayload.text ?? "", + accountId: "main", + payload: coreRenderedPayload, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + card: expect.objectContaining({ + header: { + title: { tag: "plain_text", content: "Approval" }, + template: "green", + }, + }), + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }), + ); + }); + + it("sends interactive button payloads as native Feishu cards", async () => { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "Choose an action", + accountId: "main", + payload: { + text: "Choose an action", + interactive: { + blocks: [ + { type: "text", text: "Approve the request?" }, + { + type: "buttons", + buttons: [ + { label: "Approve", value: "/approve req_1 allow-once", style: "success" }, + { label: "Deny", value: "/approve req_1 deny", style: "danger" }, + ], + }, + ], + }, + }, + }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: emptyConfig, + to: "chat_1", + accountId: "main", + }), + ); + const card = sendCardFeishuMock.mock.calls[0][0].card; + expect(card).toEqual( + expect.objectContaining({ + schema: "2.0", + body: { + elements: expect.arrayContaining([ + { tag: "markdown", content: "Choose an action" }, + { tag: "markdown", content: "Approve the request?" }, + expect.objectContaining({ + tag: "action", + actions: [ + expect.objectContaining({ + text: { tag: "plain_text", content: "Approve" }, + type: "primary", + value: expect.objectContaining({ + oc: "ocf1", + k: "quick", + q: "/approve req_1 allow-once", + }), + }), + expect.objectContaining({ + text: { tag: "plain_text", content: "Deny" }, + type: "danger", + value: expect.objectContaining({ + oc: "ocf1", + k: "quick", + q: "/approve req_1 deny", + }), + }), + ], + }), + ]), + }, + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }), + ); + }); + + it("escapes generated markdown card text and drops unsafe button URLs", async () => { + await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "Choose ", + accountId: "main", + payload: { + text: "Choose ", + presentation: { + blocks: [ + { type: "context", text: "Injected" }, + { + type: "buttons", + buttons: [ + { label: "Open", url: "https://example.com/path" }, + { label: "Bad", url: "javascript:alert(1)" }, + ], + }, + ], + }, + }, + }); + + const card = sendCardFeishuMock.mock.calls[0][0].card; + expect(card.body.elements).toEqual( + expect.arrayContaining([ + { tag: "markdown", content: "Choose <at id=\"ou_1\">" }, + { + tag: "markdown", + content: "</font><at id=\"ou_2\">Injected</at>", + }, + { + tag: "action", + actions: [ + expect.objectContaining({ + text: { tag: "plain_text", content: "Open" }, + url: "https://example.com/path", + }), + ], + }, + ]), + ); + expect(JSON.stringify(card)).not.toContain("javascript:"); + }); + + it("normalizes caller-supplied native Feishu cards before sending", async () => { + await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "fallback", + accountId: "main", + payload: { + text: "fallback", + channelData: { + feishu: { + card: { + schema: "2.0", + header: { + title: { tag: "plain_text", content: "Unsafe card" }, + template: "not-a-template", + }, + body: { + elements: [ + { tag: "img", img_key: "image-secret" }, + { tag: "markdown", content: "ping" }, + { + tag: "action", + actions: [ + { + tag: "button", + text: { tag: "plain_text", content: "Bad link" }, + url: "file:///etc/passwd", + }, + { + tag: "button", + text: { tag: "plain_text", content: "Good link" }, + url: "https://example.com", + }, + ], + }, + ], + }, + }, + }, + }, + }, + }); + + const card = sendCardFeishuMock.mock.calls[0][0].card; + expect(card.header.template).toBe("blue"); + expect(card.body.elements).toEqual([ + { tag: "markdown", content: "<at id=\"ou_1\">ping</at>" }, + { + tag: "action", + actions: [ + expect.objectContaining({ + text: { tag: "plain_text", content: "Good link" }, + url: "https://example.com", + }), + ], + }, + ]); + expect(JSON.stringify(card)).not.toContain("file://"); + expect(JSON.stringify(card)).not.toContain("image-secret"); + }); + + it("sends payload media before final native cards", async () => { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: "See attached", + accountId: "main", + mediaLocalRoots: ["/tmp"], + payload: { + text: "See attached", + mediaUrl: "/tmp/image.png", + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Open", url: "https://example.com" }] }], + }, + }, + }); + + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + mediaUrl: "/tmp/image.png", + mediaLocalRoots: ["/tmp"], + accountId: "main", + }), + ); + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + accountId: "main", + }), + ); + expect(result).toEqual( + expect.objectContaining({ channel: "feishu", messageId: "native_card_msg" }), + ); + }); + + it("keeps text/media fallback behavior for non-card payloads, including local image text", async () => { + const { dir, file } = await createTmpImage(); + try { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "chat_1", + text: file, + accountId: "main", + mediaLocalRoots: [dir], + payload: { text: file }, + }); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: "chat_1", + mediaUrl: file, + mediaLocalRoots: [dir], + accountId: "main", + }), + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(result).toEqual( + expect.objectContaining({ channel: "feishu", messageId: "media_msg" }), + ); + } finally { + await fs.rm(dir, { recursive: true, force: true }); + } + }); + + it("falls back to comment-thread text instead of sending native cards to document comments", async () => { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "Review this", + accountId: "main", + payload: { + text: "Review this", + interactive: { + blocks: [{ type: "buttons", buttons: [{ label: "Approve", value: "/approve req_1" }] }], + }, + }, + }); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(deliverCommentThreadTextMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + content: "Review this\n\n- Approve", + }), + ); + expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "reply_msg" })); + }); +}); + describe("feishuOutbound comment-thread routing", () => { beforeEach(() => { resetOutboundMocks(); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index 43493490ccd..7e6a9d0d47f 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,15 +1,41 @@ import fs from "node:fs"; import path from "node:path"; -import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; +import { + interactiveReplyToPresentation, + normalizeInteractiveReply, + normalizeMessagePresentation, + renderMessagePresentationFallbackText, + resolveInteractiveTextFallback, + type MessagePresentationBlock, + type MessagePresentationButton, +} from "openclaw/plugin-sdk/interactive-runtime"; +import { + resolvePayloadMediaUrls, + sendPayloadMediaSequenceAndFinalize, + sendTextMediaPayload, +} from "openclaw/plugin-sdk/reply-payload"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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 } from "./media.js"; import { chunkTextForOutbound, type ChannelOutboundAdapter } from "./outbound-runtime-api.js"; -import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js"; +import { + resolveFeishuCardTemplate, + sendCardFeishu, + sendMarkdownCardFeishu, + sendMessageFeishu, + sendStructuredCardFeishu, +} from "./send.js"; + +const RENDERED_FEISHU_CARD = Symbol("openclaw.renderedFeishuCard"); function normalizePossibleLocalImagePath(text: string | undefined): string | null { const raw = text?.trim(); @@ -62,6 +88,313 @@ function shouldUseCard(text: string): boolean { return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text); } +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 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 markRenderedFeishuCard(card: Record): Record { + Object.defineProperty(card, RENDERED_FEISHU_CARD, { + value: true, + enumerable: false, + }); + return card; +} + +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" ? "primary" : undefined; + const rendered: Record = { + tag: "button", + text: { tag: "plain_text", content: text }, + type: mapFeishuButtonType(style), + }; + 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; +} + +function sanitizeNativeFeishuCardElement(element: unknown): Record | undefined { + if (!isRecord(element) || typeof element.tag !== "string") { + return undefined; + } + 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 === "action" && Array.isArray(element.actions)) { + const actions = element.actions + .map((action) => sanitizeNativeFeishuCardButton(action)) + .filter((action): action is Record => Boolean(action)); + return actions.length > 0 ? { tag: "action", actions } : undefined; + } + return undefined; +} + +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 + .map((element) => sanitizeNativeFeishuCardElement(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)) { + return undefined; + } + const card = feishuData.card ?? feishuData.interactiveCard; + if (!isRecord(card)) { + return undefined; + } + if ((card as { [RENDERED_FEISHU_CARD]?: true })[RENDERED_FEISHU_CARD] === true) { + return card; + } + 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; + identity?: Parameters>[0]["identity"]; +}): Record | undefined { + const nativeCard = readNativeFeishuCard(params.payload); + if (nativeCard) { + return nativeCard; + } + + const interactive = normalizeInteractiveReply(params.payload.interactive); + const presentation = + normalizeMessagePresentation(params.payload.presentation) ?? + (interactive ? interactiveReplyToPresentation(interactive) : undefined); + if (!presentation && !interactive) { + return undefined; + } + + const text = resolveInteractiveTextFallback({ + 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 identityTitle = params.identity + ? params.identity.emoji + ? `${params.identity.emoji} ${params.identity.name ?? ""}`.trim() + : (params.identity.name ?? "") + : ""; + const title = presentation?.title ?? identityTitle; + const template = resolveFeishuCardTemplate( + presentation?.tone === "danger" + ? "red" + : presentation?.tone === "warning" + ? "orange" + : presentation?.tone === "success" + ? "green" + : "blue", + ); + + return markRenderedFeishuCard({ + schema: "2.0", + config: { width_mode: "fill" }, + ...(title + ? { + header: { + title: { tag: "plain_text", content: title }, + template: template ?? "blue", + }, + } + : {}), + body: { elements }, + }); +} + +function renderFeishuPresentationPayload({ + payload, + presentation, + ctx, +}: Parameters>[0]) { + const card = buildFeishuPayloadCard({ + payload, + text: payload.text, + identity: ctx.identity, + }); + if (!card) { + return null; + } + const existingFeishuData = isRecord(payload.channelData?.feishu) + ? payload.channelData.feishu + : undefined; + return { + ...payload, + text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), + channelData: { + ...payload.channelData, + feishu: { + ...existingFeishuData, + card, + }, + }, + }; +} + function resolveReplyToMessageId(params: { replyToId?: string | null; threadId?: string | number | null; @@ -154,6 +487,90 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: 4000, + presentationCapabilities: { + supported: true, + buttons: true, + selects: false, + context: true, + divider: true, + }, + renderPresentation: renderFeishuPresentationPayload, + sendPayload: async (ctx) => { + const card = buildFeishuPayloadCard({ + payload: ctx.payload, + text: ctx.text, + identity: ctx.identity, + }); + if (!card) { + return await sendTextMediaPayload({ + channel: "feishu", + ctx, + adapter: feishuOutbound, + }); + } + + const replyToMessageId = resolveReplyToMessageId({ + replyToId: ctx.replyToId, + threadId: ctx.threadId, + }); + const commentTarget = parseFeishuCommentTarget(ctx.to); + if (commentTarget) { + return await sendTextMediaPayload({ + channel: "feishu", + ctx: { + ...ctx, + payload: { + ...ctx.payload, + text: renderMessagePresentationFallbackText({ + text: ctx.payload.text, + presentation: + normalizeMessagePresentation(ctx.payload.presentation) ?? + (() => { + const interactive = normalizeInteractiveReply(ctx.payload.interactive); + return interactive ? interactiveReplyToPresentation(interactive) : undefined; + })(), + }), + interactive: undefined, + presentation: undefined, + channelData: undefined, + }, + }, + adapter: feishuOutbound, + }); + } + + const mediaUrls = resolvePayloadMediaUrls(ctx.payload) + .map((entry) => entry.trim()) + .filter(Boolean); + return attachChannelToResult( + "feishu", + await sendPayloadMediaSequenceAndFinalize({ + text: ctx.payload.text ?? "", + mediaUrls, + send: async ({ mediaUrl }) => + await sendMediaFeishu({ + cfg: ctx.cfg, + to: ctx.to, + mediaUrl, + accountId: ctx.accountId ?? undefined, + mediaLocalRoots: ctx.mediaLocalRoots, + replyToMessageId, + ...(ctx.payload.audioAsVoice === true || ctx.audioAsVoice === true + ? { audioAsVoice: true } + : {}), + }), + finalize: async () => + await sendCardFeishu({ + cfg: ctx.cfg, + to: ctx.to, + card, + replyToMessageId, + replyInThread: ctx.threadId != null && !ctx.replyToId, + accountId: ctx.accountId ?? undefined, + }), + }), + ); + }, ...createAttachedChannelResultAdapter({ channel: "feishu", sendText: async ({