// 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; }