diff --git a/docs/docs_map.md b/docs/docs_map.md index 5456f11536e..bedb2b923a0 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5664,6 +5664,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Renderer contract - H2: Core render flow - H2: Degradation rules + - H3: Button value fallback visibility - H2: Provider mapping - H2: Presentation vs InteractiveReply - H2: Delivery pin diff --git a/docs/plugins/message-presentation.md b/docs/plugins/message-presentation.md index cea93067383..c19ec573b53 100644 --- a/docs/plugins/message-presentation.md +++ b/docs/plugins/message-presentation.md @@ -344,6 +344,26 @@ Fallback text includes: - button labels, including URLs for link buttons - select option labels +### Button value fallback visibility + +When a channel cannot render interactive controls, button and select values +fall back to plain text. The fallback behavior preserves usability while +keeping opaque callback data private: + +- **`command`-typed actions** render as `label: \`command\`` so users can + copy the command and run it manually in the channel input. +- **`callback`-typed actions** and legacy **`value`** fields render as + label-only. The opaque callback value is not exposed in fallback text. +- **`url` / `webApp`** buttons render the URL text alongside the button + label, since the URL is user-facing. +- **Select options** render as label-only. The underlying option value is not + exposed in fallback text. + +Channel adapters that add manual-command guidance in their fallback UI (e.g. +Feishu document-comment instructions) must derive the command-present check +from the same presentation blocks that the fallback renderer uses, so the +guidance text only appears when a manual command is actually shown. + Unsupported native controls should degrade rather than fail the whole send. Examples: diff --git a/extensions/feishu/src/outbound.test.ts b/extensions/feishu/src/outbound.test.ts index d6e44b25ff6..d8da04a2886 100644 --- a/extensions/feishu/src/outbound.test.ts +++ b/extensions/feishu/src/outbound.test.ts @@ -843,13 +843,128 @@ describe("feishuOutbound.sendPayload native cards", () => { payload: { text: "Review this", interactive: { - blocks: [{ type: "buttons", buttons: [{ label: "Approve", value: "/approve req_1" }] }], + blocks: [ + { + type: "buttons", + buttons: [ + { label: "Approve", action: { type: "command", command: "/approve req_1" } }, + ], + }, + ], }, }, }); expect(sendCardFeishuMock).not.toHaveBeenCalled(); - expect(commentThreadParams()?.content).toBe("Review this\n\n- Approve"); + expect(commentThreadParams()?.content).toBe( + "Review this\n\n- Approve: `/approve req_1`\n\n> Interactive buttons are unavailable in Feishu document comments. You can type the command shown above manually.", + ); + 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, + to: "comment:docx:doxcn123:7623358762119646411", + text: "Review this", + accountId: "main", + payload: { + text: "Review this", + interactive: { + blocks: [ + { + type: "buttons", + buttons: [ + { + label: "Open URL", + url: "https://example.com/action", + action: { type: "command", command: "/approve req_1" }, + }, + ], + }, + ], + }, + }, + }); + + expect(commentThreadParams()?.content).toBe( + "Review this\n\n- Open URL: https://example.com/action", + ); + expectFeishuResult(result, "reply_msg"); + }); + + it("omits command guidance for disabled command buttons", 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: "Disabled Approve", + disabled: true, + action: { type: "command", command: "/approve req_1" }, + }, + ], + }, + ], + }, + }, + }); + + expect(commentThreadParams()?.content).toBe("Review this\n\n- Disabled Approve"); + expectFeishuResult(result, "reply_msg"); + }); + + it("adds command guidance when presentation is stripped but channelData carries the rendered-command marker", async () => { + // Core strips presentation before sendPayload; channelData retains the fact. + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "Review this", + accountId: "main", + payload: { + text: "Review this\n\n- Approve: `/approve req_1`", + channelData: { + feishu: { + card: { body: { elements: [{ tag: "hr" }] } }, + fallbackHasCommand: true, + }, + }, + }, + }); + + expect(sendCardFeishuMock).not.toHaveBeenCalled(); + expect(commentThreadParams()?.content).toBe( + "Review this\n\n- Approve: `/approve req_1`\n\n> Interactive buttons are unavailable in Feishu document comments. You can type the command shown above manually.", + ); + expectFeishuResult(result, "reply_msg"); + }); + + it("ignores non-boolean fallback command markers", async () => { + const result = await feishuOutbound.sendPayload?.({ + cfg: emptyConfig, + to: "comment:docx:doxcn123:7623358762119646411", + text: "Review this", + accountId: "main", + payload: { + text: "Review this", + channelData: { + feishu: { + card: { body: { elements: [{ tag: "hr" }] } }, + fallbackHasCommand: "true", + }, + }, + }, + }); + + expect(commentThreadParams()?.content).toBe("Review this"); expectFeishuResult(result, "reply_msg"); }); }); diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index ac9a95bf9a0..edb96921f40 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -4,6 +4,7 @@ import { attachChannelToResult, createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; +import type { MessagePresentationBlock } from "openclaw/plugin-sdk/interactive-runtime"; import { interactiveReplyToPresentation, normalizeInteractiveReply, @@ -320,6 +321,27 @@ function buildFeishuPayloadCard(params: { }); } +// Keep this aligned with the shared fallback renderer: guidance is valid only +// when the fallback text exposes a command the user can copy. +function hasVisibleFallbackCommand( + blocks: readonly MessagePresentationBlock[] | undefined, +): boolean { + return ( + blocks?.some( + (block) => + block.type === "buttons" && + block.buttons.some( + (button) => + !button.disabled && + button.action?.type === "command" && + !button.url && + !button.webApp?.url && + !button.web_app?.url, + ), + ) ?? false + ); +} + function renderFeishuPresentationPayload({ payload, presentation, @@ -336,6 +358,8 @@ function renderFeishuPresentationPayload({ const existingFeishuData = isRecord(payload.channelData?.feishu) ? payload.channelData.feishu : undefined; + // Core consumes presentation before sendPayload; carry the fallback fact. + const fallbackHasCommand = hasVisibleFallbackCommand(presentation?.blocks); return { ...payload, text: renderMessagePresentationFallbackText({ text: payload.text, presentation }), @@ -344,6 +368,7 @@ function renderFeishuPresentationPayload({ feishu: { ...existingFeishuData, card, + ...(fallbackHasCommand ? { fallbackHasCommand: true } : {}), }, }, }; @@ -505,21 +530,32 @@ export const feishuOutbound: ChannelOutboundAdapter = { }); const commentTarget = parseFeishuCommentTarget(ctx.to); if (commentTarget) { + const normalizedPresentation = + normalizeMessagePresentation(ctx.payload.presentation) ?? + (() => { + const interactive = normalizeInteractiveReply(ctx.payload.interactive); + return interactive ? interactiveReplyToPresentation(interactive) : undefined; + })(); + const presentationFallbackText = renderMessagePresentationFallbackText({ + text: ctx.payload.text, + presentation: normalizedPresentation, + }); + // Direct delivery retains blocks; core-rendered delivery carries the fact. + const fallbackHasCommand = + hasVisibleFallbackCommand(normalizedPresentation?.blocks) || + (isRecord(ctx.payload.channelData?.feishu) && + ctx.payload.channelData.feishu.fallbackHasCommand === true); + const text = fallbackHasCommand + ? `${presentationFallbackText}\n\n> Interactive buttons are unavailable in Feishu document comments. You can type the command shown above manually.` + : presentationFallbackText; + 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; - })(), - }), + text, interactive: undefined, presentation: undefined, channelData: undefined, diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index a3200fb719b..7c01e07de63 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -278,6 +278,39 @@ describe("interactive payload helpers", () => { }); }); + it("preserves command values in button fallback text while keeping callback values private", () => { + const presentation = { + blocks: [ + { + type: "buttons" as const, + buttons: [ + { label: "Approve", value: "/approve req_1 allow-once" }, + { label: "Deny", action: { type: "command" as const, command: "/approve req_1 deny" } }, + { label: "Ignore", action: { type: "callback" as const, value: "ignore_123" } }, + { label: "Docs", url: "https://example.com/docs" }, + { label: "Disabled", disabled: true }, + { + label: "DisabledCmd", + disabled: true, + action: { type: "command" as const, command: "/test" }, + }, + ], + }, + ], + }; + + expect(renderMessagePresentationFallbackText({ presentation })).toBe( + [ + "- Approve", + "- Deny: `/approve req_1 deny`", + "- Ignore", + "- Docs: https://example.com/docs", + "- Disabled", + "- DisabledCmd", + ].join("\n"), + ); + }); + it("keeps divider-only fallback empty unless a send transport fallback is requested", () => { const presentation = { blocks: [{ type: "divider" as const }], diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index d33e4c87d24..7273acef614 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -503,6 +503,21 @@ export function interactiveReplyToPresentation( return blocks.length > 0 ? { blocks } : undefined; } +/** + * Render presentation blocks as plain-text fallback for channels that do not + * support native interactive controls. + * + * Text and context blocks are rendered as-is. Buttons with a `command`-typed + * action render as `label: \`command\`` so the value is copyable. Buttons with + * a `callback` action, legacy `value`, or `select` options render as label-only + * to keep opaque callback values private. Disabled buttons render as label-only + * regardless of action type, since they are not actionable. + * + * Downstream consumers should not claim a manual command is available unless + * they verify one was actually rendered. + * + * Exported through the plugin SDK for channel adapters. + */ export function renderMessagePresentationFallbackText(params: { presentation?: MessagePresentation; emptyFallback?: string | null; @@ -529,7 +544,17 @@ export function renderMessagePresentationFallbackText(params: { const labels = block.buttons .map((button) => { const targetUrl = button.url ?? button.webApp?.url ?? button.web_app?.url; - return targetUrl ? `${button.label}: ${targetUrl}` : button.label; + if (targetUrl) { + return `${button.label}: ${targetUrl}`; + } + const controlValue = + button.action?.type === "command" + ? resolveMessagePresentationControlValue(button) + : undefined; + if (controlValue && !button.disabled) { + return `${button.label}: \`${controlValue}\``; + } + return button.label; }) .filter(Boolean); if (labels.length > 0) {