mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-30 11:21:04 +00:00
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>
174 lines
5.0 KiB
TypeScript
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;
|
|
}),
|
|
});
|
|
}
|
|
}
|