From 2d764dd8fe9e01ef56b2284bca1bbeeacc4e7bd4 Mon Sep 17 00:00:00 2001
From: Peter Lee
Date: Thu, 2 Jul 2026 14:42:31 -0500
Subject: [PATCH] fix(feishu): preserve button command values in fallback text
and add Feishu comment guidance with callback privacy (#94385)
* fix(interactive): preserve button command values in fallback text for degraded approval UX
* fix(interactive): keep callback values private in fallback text and narrow Feishu interactive detection
- P1: Skip rendering action.type === "callback" values in
renderMessagePresentationFallbackText to avoid leaking opaque
channel/plugin data into user-visible text. Command and legacy
values are still rendered.
- P2: Replace hasMessagePresentationBlocks/hasInteractiveReplyBlocks
with isMessagePresentationInteractiveBlock so Feishu comment
guidance only appears when the presentation actually contains
buttons or selects, not for text-only blocks.
- Update tests: callback button now shows label-only; all 137 tests pass.
* fix(interactive): only render typed command values in fallback text, keep legacy value private
* fix(feishu): gate document-comment command guidance on actual command action
* docs(message-presentation): document command/callback value fallback visibility
* fix(feishu): omit command guidance when URL overrides fallback command text
* docs: regenerate docs_map.md
* fix(interactive): exclude disabled buttons from fallback command rendering and guidance
* fix(interactive): extract hasRenderedCommandAction, exclude disabled buttons from command fallback
* fix(feishu): preserve command guidance marker through core presentation rendering
* fix(feishu): type-narrow channelData.feishu with isRecord before reading rendered-command marker
* fix(feishu): move hasRenderedCommandAction from public SDK into Feishu plugin as local helper
Keep the helper local to the only caller (Feishu outbound) instead of
adding a new public plugin SDK API contract. The shared fallback renderer
in renderMessagePresentationFallbackText already inlines the same
command-visibility logic; a local helper is sufficient for the Feishu
comment-thread guidance gate.
* refactor(feishu): tighten fallback command marker
---------
Co-authored-by: Peter Steinberger
---
docs/docs_map.md | 1 +
docs/plugins/message-presentation.md | 20 +++++
extensions/feishu/src/outbound.test.ts | 119 ++++++++++++++++++++++++-
extensions/feishu/src/outbound.ts | 54 +++++++++--
src/interactive/payload.test.ts | 33 +++++++
src/interactive/payload.ts | 27 +++++-
6 files changed, 242 insertions(+), 12 deletions(-)
diff --git a/docs/docs_map.md b/docs/docs_map.md
index 5456f11536e9..bedb2b923a0b 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 cea93067383c..c19ec573b53d 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 d6e44b25ff67..d8da04a2886d 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 ac9a95bf9a0c..edb96921f40a 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 a3200fb719b6..7c01e07de63c 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 d33e4c87d243..7273acef614c 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) {