Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(interactive): preserve button command values in fallback text for…
… degraded approval UX
  • Loading branch information
xialonglee authored and steipete committed Jul 2, 2026
commit 7fe9048e8d5cb1b2a652946579a54edb6498bf13
8 changes: 6 additions & 2 deletions extensions/feishu/src/outbound.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,9 @@ describe("feishuOutbound.sendPayload native cards", () => {
if (!rendered) {
throw new Error("expected Feishu presentation renderer to return a payload");
}
expect(rendered.text).toBe("Approval\n\nApprove the request?\n\n- Approve");
expect(rendered.text).toBe(
"Approval\n\nApprove the request?\n\n- Approve: `/approve req_1 allow-once`",
);
const renderedChannelData = rendered.channelData as
| { feishu?: { card?: Record<string, any> } }
| undefined;
Expand Down Expand Up @@ -849,7 +851,9 @@ describe("feishuOutbound.sendPayload native cards", () => {
});

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");
});
});
Expand Down
28 changes: 19 additions & 9 deletions extensions/feishu/src/outbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import {
hasInteractiveReplyBlocks,
hasMessagePresentationBlocks,
interactiveReplyToPresentation,
normalizeInteractiveReply,
normalizeMessagePresentation,
Expand Down Expand Up @@ -505,21 +507,29 @@ export const feishuOutbound: ChannelOutboundAdapter = {
});
const commentTarget = parseFeishuCommentTarget(ctx.to);
if (commentTarget) {
const presentationFallbackText = renderMessagePresentationFallbackText({
text: ctx.payload.text,
presentation:
normalizeMessagePresentation(ctx.payload.presentation) ??
(() => {
const interactive = normalizeInteractiveReply(ctx.payload.interactive);
return interactive ? interactiveReplyToPresentation(interactive) : undefined;
})(),
});
const hasInteractiveContent =
hasMessagePresentationBlocks(ctx.payload.presentation) ||
hasInteractiveReplyBlocks(ctx.payload.interactive);
const text = hasInteractiveContent
? `${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,
Expand Down
4 changes: 2 additions & 2 deletions extensions/telegram/src/action-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1441,7 +1441,7 @@ describe("handleTelegramAction", () => {

const call = mockCall(sendMessageTelegram, 0, "button-only fallback");
expect(call[0]).toBe("123456");
expect(call[1]).toBe("- Approve");
expect(call[1]).toBe("- Approve: `approve`");
expect(requireRecord(call[2], "button-only fallback options").buttons).toEqual([
[{ text: "Approve", callback_data: "approve" }],
]);
Expand Down Expand Up @@ -1753,7 +1753,7 @@ describe("handleTelegramAction", () => {
);
const call = mockCall(sendMessageTelegram, 0, "interactive button fallback");
expect(call[0]).toBe("@testchannel");
expect(call[1]).toBe("- Retry");
expect(call[1]).toBe("- Retry: `cmd:retry`");
expect(requireRecord(call[2], "interactive button fallback options").buttons).toEqual([
[{ text: "Retry", callback_data: "cmd:retry" }],
]);
Expand Down
27 changes: 27 additions & 0 deletions src/interactive/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,33 @@ describe("interactive payload helpers", () => {
});
});

it("preserves command/callback values in button fallback text for manual execution", () => {
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 },
],
},
],
};

expect(renderMessagePresentationFallbackText({ presentation })).toBe(
[
"- Approve: `/approve req_1 allow-once`",
"- Deny: `/approve req_1 deny`",
"- Ignore: `ignore_123`",
"- Docs: https://example.com/docs",
"- Disabled",
].join("\n"),
);
});

it("keeps divider-only fallback empty unless a send transport fallback is requested", () => {
const presentation = {
blocks: [{ type: "divider" as const }],
Expand Down
9 changes: 8 additions & 1 deletion src/interactive/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,14 @@ 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 = resolveMessagePresentationControlValue(button);
if (controlValue) {
return `${button.label}: \`${controlValue}\``;
}
return button.label;
})
.filter(Boolean);
if (labels.length > 0) {
Expand Down