mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 21:11: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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user