mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-04 10:23:41 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user