mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 11:01:37 +00:00
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
// Discord plugin module implements components.builders behavior.
|
|
import crypto from "node:crypto";
|
|
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import { buildDiscordComponentCustomId as buildDiscordComponentCustomIdImpl } from "./component-custom-id.js";
|
|
import { mapButtonStyle, normalizeModalFieldName } from "./components.parse.js";
|
|
import type {
|
|
DiscordComponentBuildResult,
|
|
DiscordComponentButtonSpec,
|
|
DiscordComponentEntry,
|
|
DiscordComponentMessageSpec,
|
|
DiscordComponentSelectSpec,
|
|
DiscordComponentSelectType,
|
|
DiscordModalEntry,
|
|
} from "./components.types.js";
|
|
import {
|
|
Button,
|
|
ChannelSelectMenu,
|
|
Container,
|
|
File,
|
|
LinkButton,
|
|
MediaGallery,
|
|
MentionableSelectMenu,
|
|
RoleSelectMenu,
|
|
Row,
|
|
Section,
|
|
Separator,
|
|
StringSelectMenu,
|
|
TextDisplay,
|
|
Thumbnail,
|
|
UserSelectMenu,
|
|
type TopLevelComponents,
|
|
} from "./internal/discord.js";
|
|
|
|
function createShortId(prefix: string) {
|
|
return `${prefix}${crypto.randomBytes(6).toString("base64url")}`;
|
|
}
|
|
|
|
function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] {
|
|
if (texts && texts.length > 0) {
|
|
return texts.map((entry) => new TextDisplay(entry));
|
|
}
|
|
if (text) {
|
|
return [new TextDisplay(text)];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function createButtonComponent(params: {
|
|
spec: DiscordComponentButtonSpec;
|
|
componentId?: string;
|
|
modalId?: string;
|
|
}): { component: Button | LinkButton; entry?: DiscordComponentEntry } {
|
|
const style = mapButtonStyle(params.spec.style);
|
|
const isLink = style === ButtonStyle.Link || Boolean(params.spec.url);
|
|
if (isLink) {
|
|
if (!params.spec.url) {
|
|
throw new Error("Link buttons require a url");
|
|
}
|
|
const linkUrl = params.spec.url;
|
|
class DynamicLinkButton extends LinkButton {
|
|
label = params.spec.label;
|
|
url = linkUrl;
|
|
override disabled = params.spec.disabled ?? false;
|
|
}
|
|
return { component: new DynamicLinkButton() };
|
|
}
|
|
const componentId = params.componentId ?? createShortId("btn_");
|
|
const internalCustomId =
|
|
typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim()
|
|
? params.spec.internalCustomId.trim()
|
|
: undefined;
|
|
const customId =
|
|
internalCustomId ??
|
|
buildDiscordComponentCustomIdImpl({
|
|
componentId,
|
|
modalId: params.modalId,
|
|
});
|
|
class DynamicButton extends Button {
|
|
label = params.spec.label;
|
|
customId = customId;
|
|
override style = style;
|
|
override emoji = params.spec.emoji;
|
|
override disabled = params.spec.disabled ?? false;
|
|
}
|
|
if (internalCustomId) {
|
|
return {
|
|
component: new DynamicButton(),
|
|
};
|
|
}
|
|
return {
|
|
component: new DynamicButton(),
|
|
entry: {
|
|
id: componentId,
|
|
kind: params.modalId ? "modal-trigger" : "button",
|
|
label: params.spec.label,
|
|
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
|
|
...(params.spec.callbackDataKind !== undefined
|
|
? { callbackDataKind: params.spec.callbackDataKind }
|
|
: {}),
|
|
...(params.modalId !== undefined ? { modalId: params.modalId } : {}),
|
|
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
|
|
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSelectComponent(params: {
|
|
spec: DiscordComponentSelectSpec;
|
|
componentId?: string;
|
|
}): {
|
|
component:
|
|
| StringSelectMenu
|
|
| UserSelectMenu
|
|
| RoleSelectMenu
|
|
| MentionableSelectMenu
|
|
| ChannelSelectMenu;
|
|
entry: DiscordComponentEntry;
|
|
} {
|
|
const type = normalizeLowercaseStringOrEmpty(
|
|
params.spec.type ?? "string",
|
|
) as DiscordComponentSelectType;
|
|
const componentId = params.componentId ?? createShortId("sel_");
|
|
const customId = buildDiscordComponentCustomIdImpl({ componentId });
|
|
const createEntry = (
|
|
selectType: DiscordComponentSelectType,
|
|
label: string,
|
|
options?: DiscordComponentEntry["options"],
|
|
): DiscordComponentEntry => ({
|
|
id: componentId,
|
|
kind: "select",
|
|
label,
|
|
...(params.spec.callbackData !== undefined ? { callbackData: params.spec.callbackData } : {}),
|
|
...(params.spec.callbackDataKind !== undefined
|
|
? { callbackDataKind: params.spec.callbackDataKind }
|
|
: {}),
|
|
selectType,
|
|
...(options ? { options } : {}),
|
|
...(params.spec.allowedUsers !== undefined ? { allowedUsers: params.spec.allowedUsers } : {}),
|
|
});
|
|
|
|
if (type === "string") {
|
|
const options = params.spec.options ?? [];
|
|
if (options.length === 0) {
|
|
throw new Error("String select menus require options");
|
|
}
|
|
class DynamicStringSelect extends StringSelectMenu {
|
|
customId = customId;
|
|
override options = options;
|
|
override minValues = params.spec.minValues;
|
|
override maxValues = params.spec.maxValues;
|
|
override placeholder = params.spec.placeholder;
|
|
override disabled = false;
|
|
}
|
|
return {
|
|
component: new DynamicStringSelect(),
|
|
entry: createEntry(
|
|
"string",
|
|
params.spec.placeholder ?? "select",
|
|
options.map((option) => ({ value: option.value, label: option.label })),
|
|
),
|
|
};
|
|
}
|
|
if (type === "user") {
|
|
class DynamicUserSelect extends UserSelectMenu {
|
|
customId = customId;
|
|
override minValues = params.spec.minValues;
|
|
override maxValues = params.spec.maxValues;
|
|
override placeholder = params.spec.placeholder;
|
|
override disabled = false;
|
|
}
|
|
return {
|
|
component: new DynamicUserSelect(),
|
|
entry: createEntry("user", params.spec.placeholder ?? "user select"),
|
|
};
|
|
}
|
|
if (type === "role") {
|
|
class DynamicRoleSelect extends RoleSelectMenu {
|
|
customId = customId;
|
|
override minValues = params.spec.minValues;
|
|
override maxValues = params.spec.maxValues;
|
|
override placeholder = params.spec.placeholder;
|
|
override disabled = false;
|
|
}
|
|
return {
|
|
component: new DynamicRoleSelect(),
|
|
entry: createEntry("role", params.spec.placeholder ?? "role select"),
|
|
};
|
|
}
|
|
if (type === "mentionable") {
|
|
class DynamicMentionableSelect extends MentionableSelectMenu {
|
|
customId = customId;
|
|
override minValues = params.spec.minValues;
|
|
override maxValues = params.spec.maxValues;
|
|
override placeholder = params.spec.placeholder;
|
|
override disabled = false;
|
|
}
|
|
return {
|
|
component: new DynamicMentionableSelect(),
|
|
entry: createEntry("mentionable", params.spec.placeholder ?? "mentionable select"),
|
|
};
|
|
}
|
|
class DynamicChannelSelect extends ChannelSelectMenu {
|
|
customId = customId;
|
|
override minValues = params.spec.minValues;
|
|
override maxValues = params.spec.maxValues;
|
|
override placeholder = params.spec.placeholder;
|
|
override disabled = false;
|
|
}
|
|
return {
|
|
component: new DynamicChannelSelect(),
|
|
entry: createEntry("channel", params.spec.placeholder ?? "channel select"),
|
|
};
|
|
}
|
|
|
|
function isSelectComponent(
|
|
component: unknown,
|
|
): component is
|
|
| StringSelectMenu
|
|
| UserSelectMenu
|
|
| RoleSelectMenu
|
|
| MentionableSelectMenu
|
|
| ChannelSelectMenu {
|
|
return (
|
|
component instanceof StringSelectMenu ||
|
|
component instanceof UserSelectMenu ||
|
|
component instanceof RoleSelectMenu ||
|
|
component instanceof MentionableSelectMenu ||
|
|
component instanceof ChannelSelectMenu
|
|
);
|
|
}
|
|
|
|
export function buildDiscordComponentMessage(params: {
|
|
spec: DiscordComponentMessageSpec;
|
|
fallbackText?: string;
|
|
sessionKey?: string;
|
|
agentId?: string;
|
|
accountId?: string;
|
|
}): DiscordComponentBuildResult {
|
|
const entries: DiscordComponentEntry[] = [];
|
|
const consumptionGroupId = createShortId("grp_");
|
|
const modals: DiscordModalEntry[] = [];
|
|
const components: TopLevelComponents[] = [];
|
|
const containerChildren: Array<
|
|
| Row<
|
|
| Button
|
|
| LinkButton
|
|
| StringSelectMenu
|
|
| UserSelectMenu
|
|
| RoleSelectMenu
|
|
| MentionableSelectMenu
|
|
| ChannelSelectMenu
|
|
>
|
|
| TextDisplay
|
|
| Section
|
|
| MediaGallery
|
|
| Separator
|
|
| File
|
|
> = [];
|
|
|
|
const addEntry = (entry: DiscordComponentEntry) => {
|
|
const reusable = entry.reusable ?? params.spec.reusable;
|
|
entries.push({
|
|
...entry,
|
|
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
|
|
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
|
|
...(reusable !== undefined ? { reusable } : {}),
|
|
consumptionGroupId,
|
|
});
|
|
};
|
|
|
|
const text = params.spec.text ?? params.fallbackText;
|
|
if (text) {
|
|
containerChildren.push(new TextDisplay(text));
|
|
}
|
|
|
|
for (const block of params.spec.blocks ?? []) {
|
|
if (block.type === "text") {
|
|
containerChildren.push(new TextDisplay(block.text));
|
|
continue;
|
|
}
|
|
if (block.type === "section") {
|
|
const displays = buildTextDisplays(block.text, block.texts);
|
|
if (displays.length > 3) {
|
|
throw new Error("Section blocks support up to 3 text displays");
|
|
}
|
|
let accessory: Thumbnail | Button | LinkButton | undefined;
|
|
if (block.accessory?.type === "thumbnail") {
|
|
accessory = new Thumbnail(block.accessory.url);
|
|
} else if (block.accessory?.type === "button") {
|
|
const { component, entry } = createButtonComponent({ spec: block.accessory.button });
|
|
accessory = component;
|
|
if (entry) {
|
|
addEntry(entry);
|
|
}
|
|
}
|
|
containerChildren.push(new Section(displays, accessory));
|
|
continue;
|
|
}
|
|
if (block.type === "separator") {
|
|
containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider }));
|
|
continue;
|
|
}
|
|
if (block.type === "media-gallery") {
|
|
containerChildren.push(new MediaGallery(block.items));
|
|
continue;
|
|
}
|
|
if (block.type === "file") {
|
|
containerChildren.push(new File(block.file, block.spoiler));
|
|
continue;
|
|
}
|
|
if (block.type === "actions") {
|
|
const rowComponents: Array<
|
|
| Button
|
|
| LinkButton
|
|
| StringSelectMenu
|
|
| UserSelectMenu
|
|
| RoleSelectMenu
|
|
| MentionableSelectMenu
|
|
| ChannelSelectMenu
|
|
> = [];
|
|
if (block.buttons) {
|
|
if (block.buttons.length > 5) {
|
|
throw new Error("Action rows support up to 5 buttons");
|
|
}
|
|
for (const button of block.buttons) {
|
|
const { component, entry } = createButtonComponent({ spec: button });
|
|
rowComponents.push(component);
|
|
if (entry) {
|
|
addEntry(entry);
|
|
}
|
|
}
|
|
} else if (block.select) {
|
|
const { component, entry } = createSelectComponent({ spec: block.select });
|
|
rowComponents.push(component);
|
|
addEntry(entry);
|
|
}
|
|
containerChildren.push(new Row(rowComponents));
|
|
}
|
|
}
|
|
|
|
if (params.spec.modal) {
|
|
const modalId = createShortId("mdl_");
|
|
const fields = params.spec.modal.fields.map((field, index) => ({
|
|
id: createShortId("fld_"),
|
|
name: normalizeModalFieldName(field.name, index),
|
|
label: field.label,
|
|
type: field.type,
|
|
...(field.description !== undefined ? { description: field.description } : {}),
|
|
...(field.placeholder !== undefined ? { placeholder: field.placeholder } : {}),
|
|
...(field.required !== undefined ? { required: field.required } : {}),
|
|
...(field.options !== undefined ? { options: field.options } : {}),
|
|
...(field.minValues !== undefined ? { minValues: field.minValues } : {}),
|
|
...(field.maxValues !== undefined ? { maxValues: field.maxValues } : {}),
|
|
...(field.minLength !== undefined ? { minLength: field.minLength } : {}),
|
|
...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),
|
|
...(field.style !== undefined ? { style: field.style } : {}),
|
|
}));
|
|
modals.push({
|
|
id: modalId,
|
|
title: params.spec.modal.title,
|
|
fields,
|
|
...(params.spec.modal.callbackData !== undefined
|
|
? { callbackData: params.spec.modal.callbackData }
|
|
: {}),
|
|
...(params.sessionKey !== undefined ? { sessionKey: params.sessionKey } : {}),
|
|
...(params.agentId !== undefined ? { agentId: params.agentId } : {}),
|
|
...(params.accountId !== undefined ? { accountId: params.accountId } : {}),
|
|
...(params.spec.reusable !== undefined ? { reusable: params.spec.reusable } : {}),
|
|
...(params.spec.modal.allowedUsers !== undefined
|
|
? { allowedUsers: params.spec.modal.allowedUsers }
|
|
: {}),
|
|
});
|
|
|
|
const triggerSpec: DiscordComponentButtonSpec = {
|
|
label: params.spec.modal.triggerLabel ?? "Open form",
|
|
style: params.spec.modal.triggerStyle ?? "primary",
|
|
allowedUsers: params.spec.modal.allowedUsers,
|
|
};
|
|
|
|
const { component, entry } = createButtonComponent({
|
|
spec: triggerSpec,
|
|
modalId,
|
|
});
|
|
|
|
if (entry) {
|
|
addEntry(entry);
|
|
}
|
|
|
|
const lastChild = containerChildren.at(-1);
|
|
if (lastChild instanceof Row) {
|
|
const row = lastChild;
|
|
const hasSelect = row.components.some((entryLocal) => isSelectComponent(entryLocal));
|
|
if (row.components.length < 5 && !hasSelect) {
|
|
row.addComponent(component as Button);
|
|
} else {
|
|
containerChildren.push(new Row([component as Button]));
|
|
}
|
|
} else {
|
|
containerChildren.push(new Row([component as Button]));
|
|
}
|
|
}
|
|
|
|
if (containerChildren.length === 0) {
|
|
throw new Error("components must include at least one block, text, or modal trigger");
|
|
}
|
|
|
|
const container = new Container(containerChildren, params.spec.container);
|
|
components.push(container);
|
|
const consumptionGroupEntryIds = entries.map((entry) => entry.id);
|
|
for (const entry of entries) {
|
|
entry.consumptionGroupEntryIds = consumptionGroupEntryIds;
|
|
}
|
|
return { components, entries, modals };
|
|
}
|
|
|
|
export function buildDiscordComponentMessageFlags(
|
|
components: TopLevelComponents[],
|
|
): number | undefined {
|
|
const hasV2 = components.some((component) => component.isV2);
|
|
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
|
|
}
|