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

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