Files
openclaw/extensions/discord/src/shared-interactive.ts
100menotu001 0212188cb6 fix(discord): preserve reusable presentation buttons
Preserve `reusable` for portable message presentation buttons and carry it through Discord component registration so repeatable callbacks stay available after a successful interaction.

Also keeps `reusable` through legacy presentation-to-interactive conversion and documents the user-visible change in the changelog.

Verification:
- `pnpm test src/interactive/payload.test.ts extensions/discord/src/shared-interactive.test.ts extensions/discord/src/components.test.ts -- --reporter=verbose`
- `git diff --check`
- `AUTOREVIEW_AUTO_TESTS=0 .agents/skills/autoreview/scripts/autoreview --mode local`
- PR CI at `52f25221b3e01f3255d8df37df73d0357ab7410b`: all completed checks green/skipped/neutral except pending CodeQL `Security High (mcp-process-tool-boundary)` at time auto-merge was armed.

Co-authored-by: OpenClaw Contributor <100menotu001@users.noreply.github.com>
2026-05-21 22:02:16 +01:00

174 lines
5.0 KiB
TypeScript

import { reduceInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import type {
InteractiveButtonStyle,
InteractiveReply,
MessagePresentation,
MessagePresentationButton,
} from "openclaw/plugin-sdk/interactive-runtime";
import type {
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentMessageSpec,
} from "./components.types.js";
function resolveDiscordInteractiveButtonStyle(
style?: InteractiveButtonStyle,
): DiscordComponentButtonStyle | undefined {
return style ?? "secondary";
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
/**
* @deprecated Use buildDiscordPresentationComponents with MessagePresentation.
*/
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply(
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
if (block.type === "text") {
const text = block.text.trim();
if (text) {
state.push({ type: "text", text });
}
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
for (
let index = 0;
index < block.buttons.length;
index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE
) {
state.push({
type: "actions",
buttons: block.buttons
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
.map((button) => {
const spec: DiscordComponentButtonSpec = {
label: button.label,
style: button.url ? "link" : resolveDiscordInteractiveButtonStyle(button.style),
};
if (button.value) {
spec.callbackData = button.value;
}
if (button.url) {
spec.url = button.url;
}
if (button.disabled === true) {
spec.disabled = true;
}
if (button.reusable === true) {
spec.reusable = true;
}
return spec;
}),
});
}
return state;
}
if (block.type === "select" && block.options.length > 0) {
state.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
},
});
}
return state;
},
);
return blocks.length > 0 ? { blocks } : undefined;
}
export function buildDiscordPresentationComponents(
presentation?: MessagePresentation,
): DiscordComponentMessageSpec | undefined {
if (!presentation) {
return undefined;
}
const spec: DiscordComponentMessageSpec = { blocks: [] };
if (presentation.title) {
spec.blocks?.push({ type: "text", text: presentation.title });
}
for (const block of presentation.blocks) {
if (block.type === "text" || block.type === "context") {
const text = block.text.trim();
if (text) {
spec.blocks?.push({
type: "text",
text: block.type === "context" ? `-# ${text}` : text,
});
}
continue;
}
if (block.type === "divider") {
spec.blocks?.push({ type: "separator" });
continue;
}
}
for (const block of presentation.blocks) {
if (block.type === "buttons") {
appendDiscordPresentationButtonBlocks(spec, block.buttons);
continue;
}
if (block.type === "select" && block.options.length > 0) {
spec.blocks?.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
},
});
}
}
return spec.blocks?.length ? spec : undefined;
}
function appendDiscordPresentationButtonBlocks(
spec: DiscordComponentMessageSpec,
buttons: readonly MessagePresentationButton[],
) {
if (buttons.length === 0) {
return;
}
for (let index = 0; index < buttons.length; index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE) {
spec.blocks?.push({
type: "actions",
buttons: buttons.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE).map((button) => {
const component: DiscordComponentButtonSpec = {
label: button.label,
style: button.url ? "link" : resolveDiscordInteractiveButtonStyle(button.style),
};
if (button.value) {
component.callbackData = button.value;
}
if (button.url) {
component.url = button.url;
}
if (button.disabled === true) {
component.disabled = true;
}
if (button.reusable === true) {
component.reusable = true;
}
return component;
}),
});
}
}