Files
Moltbot/src/discord/components.ts

1121 lines
33 KiB
TypeScript

import {
Button,
ChannelSelectMenu,
CheckboxGroup,
Container,
File,
Label,
LinkButton,
MediaGallery,
MentionableSelectMenu,
Modal,
RadioGroup,
RoleSelectMenu,
Row,
Section,
Separator,
StringSelectMenu,
TextDisplay,
TextInput,
Thumbnail,
UserSelectMenu,
parseCustomId,
type ComponentParserResult,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
import crypto from "node:crypto";
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";
export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel";
export type DiscordComponentModalFieldType =
| "text"
| "checkbox"
| "radio"
| "select"
| "role-select"
| "user-select";
export type DiscordComponentButtonSpec = {
label: string;
style?: DiscordComponentButtonStyle;
url?: string;
emoji?: {
name: string;
id?: string;
animated?: boolean;
};
disabled?: boolean;
};
export type DiscordComponentSelectOption = {
label: string;
value: string;
description?: string;
emoji?: {
name: string;
id?: string;
animated?: boolean;
};
default?: boolean;
};
export type DiscordComponentSelectSpec = {
type?: DiscordComponentSelectType;
placeholder?: string;
minValues?: number;
maxValues?: number;
options?: DiscordComponentSelectOption[];
};
export type DiscordComponentSectionAccessory =
| {
type: "thumbnail";
url: string;
}
| {
type: "button";
button: DiscordComponentButtonSpec;
};
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
export type DiscordComponentBlock =
| {
type: "text";
text: string;
}
| {
type: "section";
text?: string;
texts?: string[];
accessory?: DiscordComponentSectionAccessory;
}
| {
type: "separator";
spacing?: DiscordComponentSeparatorSpacing;
divider?: boolean;
}
| {
type: "actions";
buttons?: DiscordComponentButtonSpec[];
select?: DiscordComponentSelectSpec;
}
| {
type: "media-gallery";
items: Array<{ url: string; description?: string; spoiler?: boolean }>;
}
| {
type: "file";
file: `attachment://${string}`;
spoiler?: boolean;
};
export type DiscordModalFieldSpec = {
type: DiscordComponentModalFieldType;
name?: string;
label: string;
description?: string;
placeholder?: string;
required?: boolean;
options?: DiscordComponentSelectOption[];
minValues?: number;
maxValues?: number;
minLength?: number;
maxLength?: number;
style?: "short" | "paragraph";
};
export type DiscordModalSpec = {
title: string;
triggerLabel?: string;
triggerStyle?: DiscordComponentButtonStyle;
fields: DiscordModalFieldSpec[];
};
export type DiscordComponentMessageSpec = {
text?: string;
container?: {
accentColor?: string | number;
spoiler?: boolean;
};
blocks?: DiscordComponentBlock[];
modal?: DiscordModalSpec;
};
export type DiscordComponentEntry = {
id: string;
kind: "button" | "select" | "modal-trigger";
label: string;
selectType?: DiscordComponentSelectType;
options?: Array<{ value: string; label: string }>;
modalId?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
messageId?: string;
createdAt?: number;
expiresAt?: number;
};
export type DiscordModalFieldDefinition = {
id: string;
name: string;
label: string;
type: DiscordComponentModalFieldType;
description?: string;
placeholder?: string;
required?: boolean;
options?: DiscordComponentSelectOption[];
minValues?: number;
maxValues?: number;
minLength?: number;
maxLength?: number;
style?: "short" | "paragraph";
};
export type DiscordModalEntry = {
id: string;
title: string;
fields: DiscordModalFieldDefinition[];
sessionKey?: string;
agentId?: string;
accountId?: string;
messageId?: string;
createdAt?: number;
expiresAt?: number;
};
export type DiscordComponentBuildResult = {
components: TopLevelComponents[];
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
};
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],
["action-row", "actions"],
]);
function createShortId(prefix: string) {
return `${prefix}${crypto.randomBytes(6).toString("base64url")}`;
}
function requireObject(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object`);
}
return value as Record<string, unknown>;
}
function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string {
if (typeof value !== "string") {
throw new Error(`${label} must be a string`);
}
const trimmed = value.trim();
if (!opts?.allowEmpty && !trimmed) {
throw new Error(`${label} cannot be empty`);
}
return opts?.allowEmpty ? value : trimmed;
}
function readOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function readOptionalNumber(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return value;
}
function normalizeModalFieldName(value: string | undefined, index: number) {
const trimmed = value?.trim();
if (trimmed) {
return trimmed;
}
return `field_${index + 1}`;
}
function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error(`${label} must include an attachment filename`);
}
return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`;
}
export function resolveDiscordComponentAttachmentName(value: string): string {
const trimmed = value.trim();
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
throw new Error(
`Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`,
);
}
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
if (!attachmentName) {
throw new Error("Attachment reference must include a filename");
}
return attachmentName;
}
function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle {
switch ((style ?? "primary").toLowerCase()) {
case "secondary":
return ButtonStyle.Secondary;
case "success":
return ButtonStyle.Success;
case "danger":
return ButtonStyle.Danger;
case "link":
return ButtonStyle.Link;
case "primary":
default:
return ButtonStyle.Primary;
}
}
function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) {
return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short;
}
function normalizeBlockType(raw: string) {
const lowered = raw.trim().toLowerCase();
return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]);
}
function parseSelectOptions(
raw: unknown,
label: string,
): DiscordComponentSelectOption[] | undefined {
if (raw === undefined) {
return undefined;
}
if (!Array.isArray(raw)) {
throw new Error(`${label} must be an array`);
}
return raw.map((entry, index) => {
const obj = requireObject(entry, `${label}[${index}]`);
return {
label: readString(obj.label, `${label}[${index}].label`),
value: readString(obj.value, `${label}[${index}].value`),
description: readOptionalString(obj.description),
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString(
(obj.emoji as { name?: unknown }).name,
`${label}[${index}].emoji.name`,
),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
default: typeof obj.default === "boolean" ? obj.default : undefined,
};
});
}
function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec {
const obj = requireObject(raw, label);
const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
const url = readOptionalString(obj.url);
if ((style === "link" || url) && !url) {
throw new Error(`${label}.url is required for link buttons`);
}
return {
label: readString(obj.label, `${label}.label`),
style,
url,
emoji:
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
? {
name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`),
id: readOptionalString((obj.emoji as { id?: unknown }).id),
animated:
typeof (obj.emoji as { animated?: unknown }).animated === "boolean"
? (obj.emoji as { animated?: boolean }).animated
: undefined,
}
: undefined,
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
};
}
function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec {
const obj = requireObject(raw, label);
const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined;
const allowedTypes: DiscordComponentSelectType[] = [
"string",
"user",
"role",
"mentionable",
"channel",
];
if (type && !allowedTypes.includes(type)) {
throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`);
}
return {
type,
placeholder: readOptionalString(obj.placeholder),
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
options: parseSelectOptions(obj.options, `${label}.options`),
};
}
function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec {
const obj = requireObject(raw, label);
const type = readString(
obj.type,
`${label}.type`,
).toLowerCase() as DiscordComponentModalFieldType;
const supported: DiscordComponentModalFieldType[] = [
"text",
"checkbox",
"radio",
"select",
"role-select",
"user-select",
];
if (!supported.includes(type)) {
throw new Error(`${label}.type must be one of ${supported.join(", ")}`);
}
const options = parseSelectOptions(obj.options, `${label}.options`);
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
throw new Error(`${label}.options is required for ${type} fields`);
}
return {
type,
name: normalizeModalFieldName(readOptionalString(obj.name), index),
label: readString(obj.label, `${label}.label`),
description: readOptionalString(obj.description),
placeholder: readOptionalString(obj.placeholder),
required: typeof obj.required === "boolean" ? obj.required : undefined,
options,
minValues: readOptionalNumber(obj.minValues),
maxValues: readOptionalNumber(obj.maxValues),
minLength: readOptionalNumber(obj.minLength),
maxLength: readOptionalNumber(obj.maxLength),
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
};
}
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
const obj = requireObject(raw, label);
const typeRaw = readString(obj.type, `${label}.type`).toLowerCase();
const type = normalizeBlockType(typeRaw);
switch (type) {
case "text":
return {
type: "text",
text: readString(obj.text, `${label}.text`),
};
case "section": {
const text = readOptionalString(obj.text);
const textsRaw = obj.texts;
const texts = Array.isArray(textsRaw)
? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`))
: undefined;
if (!text && (!texts || texts.length === 0)) {
throw new Error(`${label}.text or ${label}.texts is required for section blocks`);
}
let accessory: DiscordComponentSectionAccessory | undefined;
if (obj.accessory !== undefined) {
const accessoryObj = requireObject(obj.accessory, `${label}.accessory`);
const accessoryType = readString(
accessoryObj.type,
`${label}.accessory.type`,
).toLowerCase();
if (accessoryType === "thumbnail") {
accessory = {
type: "thumbnail",
url: readString(accessoryObj.url, `${label}.accessory.url`),
};
} else if (accessoryType === "button") {
accessory = {
type: "button",
button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`),
};
} else {
throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`);
}
}
return {
type: "section",
text,
texts,
accessory,
};
}
case "separator": {
const spacingRaw = obj.spacing;
let spacing: DiscordComponentSeparatorSpacing | undefined;
if (spacingRaw === "small" || spacingRaw === "large") {
spacing = spacingRaw;
} else if (spacingRaw === 1 || spacingRaw === 2) {
spacing = spacingRaw;
} else if (spacingRaw !== undefined) {
throw new Error(`${label}.spacing must be "small", "large", 1, or 2`);
}
const divider = typeof obj.divider === "boolean" ? obj.divider : undefined;
return {
type: "separator",
spacing,
divider,
};
}
case "actions": {
const buttonsRaw = obj.buttons;
const buttons = Array.isArray(buttonsRaw)
? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`))
: undefined;
const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined;
if ((!buttons || buttons.length === 0) && !select) {
throw new Error(`${label} requires buttons or select`);
}
if (buttons && select) {
throw new Error(`${label} cannot include both buttons and select`);
}
return {
type: "actions",
buttons,
select,
};
}
case "media-gallery": {
const itemsRaw = obj.items;
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) {
throw new Error(`${label}.items must be a non-empty array`);
}
const items = itemsRaw.map((entry, idx) => {
const itemObj = requireObject(entry, `${label}.items[${idx}]`);
return {
url: readString(itemObj.url, `${label}.items[${idx}].url`),
description: readOptionalString(itemObj.description),
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
};
});
return {
type: "media-gallery",
items,
};
}
case "file": {
const file = readString(obj.file, `${label}.file`);
return {
type: "file",
file: normalizeAttachmentRef(file, `${label}.file`),
spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined,
};
}
default:
throw new Error(`${label}.type must be a supported component block`);
}
}
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
if (raw === undefined || raw === null) {
return null;
}
const obj = requireObject(raw, "components");
const blocksRaw = obj.blocks;
const blocks = Array.isArray(blocksRaw)
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
: undefined;
const modalRaw = obj.modal;
let modal: DiscordModalSpec | undefined;
if (modalRaw !== undefined) {
const modalObj = requireObject(modalRaw, "components.modal");
const fieldsRaw = modalObj.fields;
if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
throw new Error("components.modal.fields must be a non-empty array");
}
if (fieldsRaw.length > 5) {
throw new Error("components.modal.fields supports up to 5 inputs");
}
const fields = fieldsRaw.map((entry, idx) =>
parseModalField(entry, `components.modal.fields[${idx}]`, idx),
);
modal = {
title: readString(modalObj.title, "components.modal.title"),
triggerLabel: readOptionalString(modalObj.triggerLabel),
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
fields,
};
}
return {
text: readOptionalString(obj.text),
container:
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
? {
accentColor: (obj.container as { accentColor?: unknown }).accentColor as
| string
| number
| undefined,
spoiler:
typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean"
? ((obj.container as { spoiler?: boolean }).spoiler as boolean)
: undefined,
}
: undefined,
blocks,
modal,
};
}
export function buildDiscordComponentCustomId(params: {
componentId: string;
modalId?: string;
}): string {
const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`;
return params.modalId ? `${base};mid=${params.modalId}` : base;
}
export function buildDiscordModalCustomId(modalId: string): string {
return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`;
}
export function parseDiscordComponentCustomId(
id: string,
): { componentId: string; modalId?: string } | null {
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return null;
}
const componentId = parsed.data.cid;
if (typeof componentId !== "string" || !componentId.trim()) {
return null;
}
const modalId = parsed.data.mid;
return {
componentId,
modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined,
};
}
export function parseDiscordModalCustomId(id: string): string | null {
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return null;
}
const modalId = parsed.data.mid;
if (typeof modalId !== "string" || !modalId.trim()) {
return null;
}
return modalId;
}
export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult {
if (id === "*") {
return { key: "*", data: {} };
}
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: parsed.data };
}
export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult {
if (id === "*") {
return { key: "*", data: {} };
}
const parsed = parseCustomId(id);
if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) {
return parsed;
}
return { key: "*", data: parsed.data };
}
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;
}
return { component: new DynamicLinkButton() };
}
const componentId = params.componentId ?? createShortId("btn_");
const customId = buildDiscordComponentCustomId({
componentId,
modalId: params.modalId,
});
class DynamicButton extends Button {
label = params.spec.label;
customId = customId;
style = style;
emoji = params.spec.emoji;
disabled = params.spec.disabled ?? false;
}
return {
component: new DynamicButton(),
entry: {
id: componentId,
kind: params.modalId ? "modal-trigger" : "button",
label: params.spec.label,
modalId: params.modalId,
},
};
}
function createSelectComponent(params: {
spec: DiscordComponentSelectSpec;
componentId?: string;
}): {
component:
| StringSelectMenu
| UserSelectMenu
| RoleSelectMenu
| MentionableSelectMenu
| ChannelSelectMenu;
entry: DiscordComponentEntry;
} {
const type = (params.spec.type ?? "string").toLowerCase() as DiscordComponentSelectType;
const componentId = params.componentId ?? createShortId("sel_");
const customId = buildDiscordComponentCustomId({ componentId });
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;
options = options;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicStringSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "select",
selectType: "string",
options: options.map((option) => ({ value: option.value, label: option.label })),
},
};
}
if (type === "user") {
class DynamicUserSelect extends UserSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicUserSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "user select",
selectType: "user",
},
};
}
if (type === "role") {
class DynamicRoleSelect extends RoleSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicRoleSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "role select",
selectType: "role",
},
};
}
if (type === "mentionable") {
class DynamicMentionableSelect extends MentionableSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicMentionableSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "mentionable select",
selectType: "mentionable",
},
};
}
class DynamicChannelSelect extends ChannelSelectMenu {
customId = customId;
minValues = params.spec.minValues;
maxValues = params.spec.maxValues;
placeholder = params.spec.placeholder;
disabled = false;
}
return {
component: new DynamicChannelSelect(),
entry: {
id: componentId,
kind: "select",
label: params.spec.placeholder ?? "channel select",
selectType: "channel",
},
};
}
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
);
}
function createModalFieldComponent(
field: DiscordModalFieldDefinition,
): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup {
if (field.type === "text") {
class DynamicTextInput extends TextInput {
customId = field.id;
style = mapTextInputStyle(field.style);
placeholder = field.placeholder;
required = field.required;
minLength = field.minLength;
maxLength = field.maxLength;
}
return new DynamicTextInput();
}
if (field.type === "select") {
const options = field.options ?? [];
class DynamicModalSelect extends StringSelectMenu {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalSelect();
}
if (field.type === "role-select") {
class DynamicModalRoleSelect extends RoleSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalRoleSelect();
}
if (field.type === "user-select") {
class DynamicModalUserSelect extends UserSelectMenu {
customId = field.id;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
placeholder = field.placeholder;
}
return new DynamicModalUserSelect();
}
if (field.type === "checkbox") {
const options = field.options ?? [];
class DynamicCheckboxGroup extends CheckboxGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicCheckboxGroup();
}
const options = field.options ?? [];
class DynamicRadioGroup extends RadioGroup {
customId = field.id;
options = options;
required = field.required;
minValues = field.minValues;
maxValues = field.maxValues;
}
return new DynamicRadioGroup();
}
export function buildDiscordComponentMessage(params: {
spec: DiscordComponentMessageSpec;
fallbackText?: string;
sessionKey?: string;
agentId?: string;
accountId?: string;
}): DiscordComponentBuildResult {
const entries: DiscordComponentEntry[] = [];
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) => {
entries.push({
...entry,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
});
};
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,
description: field.description,
placeholder: field.placeholder,
required: field.required,
options: field.options,
minValues: field.minValues,
maxValues: field.maxValues,
minLength: field.minLength,
maxLength: field.maxLength,
style: field.style,
}));
modals.push({
id: modalId,
title: params.spec.modal.title,
fields,
sessionKey: params.sessionKey,
agentId: params.agentId,
accountId: params.accountId,
});
const triggerSpec: DiscordComponentButtonSpec = {
label: params.spec.modal.triggerLabel ?? "Open form",
style: params.spec.modal.triggerStyle ?? "primary",
};
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((entry) => isSelectComponent(entry));
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);
return { components, entries, modals };
}
export function buildDiscordComponentMessageFlags(
components: TopLevelComponents[],
): number | undefined {
const hasV2 = components.some((component) => component.isV2);
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
}
export class DiscordFormModal extends Modal {
title: string;
customId: string;
components: Array<Label | TextDisplay>;
customIdParser = parseDiscordModalCustomIdForCarbon;
constructor(params: { modalId: string; title: string; fields: DiscordModalFieldDefinition[] }) {
super();
this.title = params.title;
this.customId = buildDiscordModalCustomId(params.modalId);
this.components = params.fields.map((field) => {
const component = createModalFieldComponent(field);
class DynamicLabel extends Label {
label = field.label;
description = field.description;
component = component;
customId = field.id;
}
return new DynamicLabel(component);
});
}
async run(): Promise<void> {
throw new Error("Modal handler is not registered for dynamic forms");
}
}
export function createDiscordFormModal(entry: DiscordModalEntry): Modal {
return new DiscordFormModal({
modalId: entry.id,
title: entry.title,
fields: entry.fields,
});
}
export function formatDiscordComponentEventText(params: {
kind: "button" | "select";
label: string;
values?: string[];
}): string {
if (params.kind === "button") {
return `Clicked "${params.label}".`;
}
const values = params.values ?? [];
if (values.length === 0) {
return `Updated "${params.label}".`;
}
return `Selected ${values.join(", ")} from "${params.label}".`;
}