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 <steipete@gmail.com>
This commit is contained in:
Peter Lee
2026-07-02 14:42:31 -05:00
committed by GitHub
parent a004e18b4b
commit 2d764dd8fe
6 changed files with 242 additions and 12 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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 }],

View File

@@ -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) {