Medusa/developer documentation/Buttons & Select Menus

Buttons & Select Menus

Exhaustive reference for Buttons, Select Menus, Components V2, and Medusa's internal Discord UI API.

Medusa provides a unified internal API for building, resolving, tokenizing, and dispatching Discord message components. Components are built with the this.medusa.components builder and resolved and dispatched through the Embeds service (this.medusa.embeds). The builder produces plain component trees with named methods so module code never hardcodes Discord's raw component type integers.

Never use numeric integers for button styles (such as style: 2 or style: 1). Medusa requires string literals ("primary", "secondary", "success", "danger", "link"). Medusa's internal component resolver automatically normalizes string literals to Discord's underlying numeric codes during dispatch.

Official modules use gray "secondary" buttons (or "link" for URLs) everywhere. Only user-configured button colour options pick other styles.


Component Architecture

Discord components in Medusa operate in two modes:

  1. Standard Components (V1): Up to 5 Action Rows (type: 1), each holding up to 5 buttons (type: 2) or 1 select menu (type: 3).
  2. Components V2 (MessageFlags.IsComponentsV2): Modern Discord card layouts utilizing Containers (type: 17), Sections (type: 9), Text Displays (type: 10), Separators (type: 14), Thumbnails (type: 11), and Media Galleries (type: 12), in addition to Action Rows and Section button accessories.

Build trees with this.medusa.components (see Builder API), then let the Embeds service resolve them. Every tree passes through this.medusa.embeds.resolveComponent(comp, tokens) (or this.medusa.components.resolve / payload / send, which call it) to ensure token replacement, custom emoji translation, style conversion, and accent color injection. this.medusa.embeds.buildComponents(buttons, selectMenu, rawComponents) remains available for assembling standard button rows.


Builder API (this.medusa.components)

Every building block (handlers, commands, events, routes) receives this.medusa.components, a builder that returns plain component trees. Each method maps to a Discord component type so module code never writes raw integers like type: 17 or type: 10. The trees flow unchanged into resolve, payload, send, or this.medusa.embeds.send(...), where tokens, styles, emojis, and accent colors are resolved.

Builder Reference

MethodSignatureProduces
container({ accent?, components?, spoiler?, id? }) => objectContainer (type: 17). accent is accepted for design purposes, but the resolver always applies the global ACCENT_COLOUR.
section({ components?, accessory? }) => objectSection (type: 9). String components are wrapped as text displays; accessory is a button or thumbnail.
text(content: string) => objectText Display (type: 10).
separator({ divider?, spacing? }) => objectSeparator (type: 14). Defaults to divider: true, spacing: 1.
thumbnail(url: string, { description?, spoiler? }) => objectThumbnail (type: 11), used as a section accessory.
gallery(items: Array<string | object>) => objectMedia Gallery (type: 12). Items are URL strings or { url, description?, spoiler? }.
file(url: string, { spoiler? }) => objectFile (type: 13).
row(components: Array) => objectAction Row (type: 1) holding buttons or a select menu.
button({ style?, label?, custom_id?, url?, emoji?, disabled? }) => objectButton (type: 2) with a string style.
select({ custom_id?, placeholder?, options?, disabled?, min?, max? }) => objectString Select Menu (type: 3).
resolve(components, tokens?) => ArrayResolves one tree or an array of trees through the Embeds resolver, returning the dispatch-ready array.
payload(components, { tokens?, content?, silent? }) => objectResolves and wraps a tree into a send-ready message object with MessageFlags.IsComponentsV2 applied.
send(channelId, components, options?) => Promise<Message>Builds a payload and posts it to a channel.

Building a V2 Card

const banner = this.medusa.components.container({
    accent: "#ef4444",
    components: [
        this.medusa.components.section({
            components: [`### ${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerTitle")}\n\n${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerBody")}`],
            accessory: this.medusa.components.thumbnail("%brand_icon%")
        }),
        this.medusa.components.separator(),
        this.medusa.components.row([
            this.medusa.components.button({
                style: "success",
                label: this.medusa.modules.lang.phrase("example", "messages", "confirm"),
                emoji: "%custom_emoji_check%",
                custom_id: "medusa:button:anti-scam:confirm"
            })
        ])
    ]
});

await channel.send(this.medusa.components.payload(banner));

payload applies MessageFlags.IsComponentsV2 and resolves the whole tree, so handlers no longer set the flag or call resolveComponent by hand. Pass { silent: true } to suppress notifications, { content } for a leading message, and { tokens } for custom %token% substitutions.


Buttons (type: 2)

Buttons allow users to trigger bot workflows, confirm actions, or open external links.

Button Schema

type ButtonStyle = "primary" | "secondary" | "success" | "danger" | "link";

interface ButtonComponent {
    type: 2;
    style: ButtonStyle;
    label?: string;
    custom_id?: string;
    url?: string;
    emoji?: string | { id?: string; name: string; animated?: boolean };
    disabled?: boolean;
}
FieldTypeDescription
type2Discord component type for Buttons.
stylestringRequired. String literal style: "primary", "secondary", "success", "danger", or "link".
labelstringDisplay text on the button (max 80 characters). Automatically trimmed. Defaults to URL or Button N if omitted.
custom_idstringInteraction identifier returned when the button is clicked. Required unless style: "link".
urlstringTarget HTTP/HTTPS destination URL. Required when style: "link". Must match /^https?:\/\//i.
emojistring | objectCustom bot emoji token (e.g. %custom_emoji_hammer%), unicode emoji, or { id, name, animated }.
disabledbooleanWhen true, disables user interaction and renders the button visually dimmed.

Button Styles

Style LiteralUnderlying Discord CodeAppearanceUsage & Requirements
"primary"1Blurple / AccentPrimary call-to-action button. Requires custom_id.
"secondary"2Neutral GreyDefault secondary button or neutral navigation. Requires custom_id.
"success"3GreenPositive confirmations, approvals, and submits. Requires custom_id.
"danger"4RedDestructive actions, cancellations, and kicks/bans. Requires custom_id.
"link"5Grey with External IconExternal web navigation. Requires valid url; custom_id is forbidden.

Button Definitions in Modules

Action Buttons

const confirmButton = {
    type: 2,
    style: "success",
    label: this.medusa.modules.lang.phrase("example", "messages", "confirm"),
    emoji: "%custom_emoji_check%",
    custom_id: "medusa:button:core:confirm"
};

const cancelButton = {
    type: 2,
    style: "secondary",
    label: this.medusa.modules.lang.phrase("example", "messages", "cancel"),
    emoji: "%custom_emoji_cross%",
    custom_id: "medusa:button:core:cancel"
};
const websiteButton = {
    type: 2,
    style: "link",
    url: "https://zerodev.ca",
    label: "Zero Development",
    emoji: "%custom_emoji_external%"
};

Metric / Counter Button (Disabled)

const counterButton = {
    type: 2,
    style: "secondary",
    label: this.medusa.modules.lang.phrase("anti-scam", "messages", "bannedButton", { count: String(bannedTotal) }),
    emoji: "%custom_emoji_hammer%",
    disabled: true,
    custom_id: "honeypot_banned_display"
};

Select Menus (type: 3)

String Select Menus provide an interactive dropdown list allowing members to pick an item from up to 25 choices.

Select Menu Schema

interface SelectMenuOption {
    label: string;
    value: string;
    description?: string;
    emoji?: string | { id?: string; name: string; animated?: boolean };
    default?: boolean;
}

interface SelectMenuComponent {
    type: 3;
    custom_id?: string;
    placeholder?: string;
    disabled?: boolean;
    options: SelectMenuOption[];
}
FieldTypeDescription
type3Discord component type for String Select Menus.
custom_idstringEvent identifier. Defaults to "select_menu" if omitted.
placeholderstringDescriptive prompt displayed before an option is selected (max 150 characters).
disabledbooleanWhen true, prevents dropdown expansion and interactions.
optionsArrayList of 1 to 25 select menu options.

Select Menu Option Schema

FieldTypeDescription
labelstringRequired. Item title (max 100 characters). Auto-falls back to Option N.
valuestringRequired. Unique value returned upon selection (max 100 characters). Auto-falls back to opt_N.
descriptionstringOptional secondary description text displayed below the title (max 100 characters).
emojistring | objectCustom bot emoji token (e.g. %custom_emoji_bell%), unicode character, or emoji object.
defaultbooleanWhen true, this option is pre-selected by default.

Select Menu Example

const categoryMenu = {
    type: 3,
    custom_id: "medusa:select:help:category",
    placeholder: this.medusa.modules.lang.phrase("example", "messages", "selectCategory"),
    options: [
        {
            label: "Moderation",
            value: "moderation",
            description: "Server moderation and security utilities",
            emoji: "%custom_emoji_gavel%",
            default: false
        },
        {
            label: "Economy",
            value: "economy",
            description: "Currency, banking, and marketplace features",
            emoji: "%custom_emoji_coin%",
            default: false
        }
    ]
};

Internal API (this.medusa.embeds)

Every building block (handlers, commands, events, routes) receives access to Medusa's internal Embeds service through this.medusa.embeds.

Method Reference

MethodSignatureDescription
resolveComponent(comp: object, tokens?: object) => objectRecursively resolves an entire component tree: converts style string literals to Discord numbers, resolves %custom_emoji_<name>% placeholders, performs token string substitution, and normalizes hex accent colors ("#ef4444").
buildComponents(buttons?: Array, selectMenu?: object, rawComponents?: Array) => ArrayAssembles button arrays into Action Rows in groups of 5 (max 5 rows) and appends select menus in dedicated rows.
buttonStyleNum(style: string | number) => numberConverts style literals ("link" $\to 5$, "secondary" $\to 2$, "success" $\to 3$, "danger" $\to 4$, default $\to 1$).
parseButtonEmoji(token: any) => object | nullResolves custom emoji tags, %custom_emoji_<name>% tokens, and Unicode characters into { id, name, animated } or { name }.
isComponentsV2(components: Array) => booleanChecks whether any component in an array uses V2 component types (17, 9, 10, 12, 13, 14, 15).
send(channelId: string, content?: string, template?: object, tokens?: object, buttons?: Array, selectMenu?: object, components?: Array, flags?: number, embeds?: Array) => Promise<Message>Dispatches a complete Discord message with automatic V1/V2 component resolution, token interpolation, and notification suppression.
saveMessage(message: object) => Promise<object>Persists a message template with buttons and components into settings storage ("MESSAGES").
deleteMessage(id: string) => Promise<Array<object>>Deletes a saved message template from storage by ID.
messages() => Array<object>Retrieves all saved message templates from settings storage.
resolve(template: object, tokens?: object, options?: object) => EmbedBuilderFills tokens and custom emojis in an embed template and returns a decorated EmbedBuilder. A token whose value is an array is a list token: template.lists[token] is filled once per array entry (each entry is its own token object) and the joined result replaces %token%. See Embed Fields.
fill(value: any, tokens?: object) => anyRecursively interpolates %token% and <token> values and custom emojis across strings, arrays, and nested objects.

resolveComponent(comp, tokens)

The primary resolution pipeline for all components. It recursively handles all Discord component types:

  1. Type 17 & 15 (Container / Spacer):
    • Recursively resolves all child components.
    • Forces accent_color to the global ACCENT_COLOUR setting as a 24-bit decimal integer, ignoring any value set on the container.
  2. Type 9 (Section):
    • Recursively resolves child components.
    • Normalizes and resolves accessory components (Button type: 2 or Thumbnail type: 11).
  3. Type 10 (Text Display):
    • Interpolates tokens inside content.
  4. Type 11 (Thumbnail):
    • Fills tokens in media.url and description.
  5. Type 12 (Media Gallery):
    • Iterates through items, resolving media URLs and descriptions.
  6. Type 14 (Separator):
    • Preserves divider and spacing layout rules.
  7. Type 1 (Action Row):
    • Resolves all child buttons (type: 2) by converting style via buttonStyleNum, resolving emoji, and substituting tokens in label and url.
    • Resolves all child select menus (type: 3) by filling placeholder and option label, description, and emoji.
const resolvedContainer = this.medusa.embeds.resolveComponent({
    type: 17,
    accent_color: "#22c55e",
    components: [
        {
            type: 10,
            content: "Welcome to **%server_name%**!"
        },
        {
            type: 14,
            divider: true,
            spacing: 1
        },
        {
            type: 1,
            components: [
                {
                    type: 2,
                    style: "primary",
                    label: "Get Started",
                    emoji: "%custom_emoji_rocket%",
                    custom_id: "medusa:button:welcome:start"
                }
            ]
        }
    ]
}, { server_name: guild.name });

buildComponents(buttons, selectMenu, rawComponents)

When constructing standard messages without manual row grouping, buildComponents packages button arrays automatically:

const rows = this.medusa.embeds.buildComponents(
    [
        { type: 2, style: "secondary", label: "Previous", custom_id: "medusa:button:page:prev" },
        { type: 2, style: "secondary", label: "Next", custom_id: "medusa:button:page:next" }
    ],
    {
        custom_id: "medusa:select:page:jump",
        placeholder: "Jump to page...",
        options: [
            { label: "Page 1", value: "1" },
            { label: "Page 2", value: "2" }
        ]
    }
);
  • Groups buttons into blocks of 5 per Action Row (up to 25 buttons total).
  • Places the select menu into its own row following the button rows.
  • Filters out broken link buttons lacking a valid URL.
  • Auto-generates fallback custom_id values (btn_1, btn_2, etc.) if omitted on action buttons.

Components V2 Layouts

Medusa natively supports modern Discord Components V2 cards. Build the card with this.medusa.components and dispatch it with payload, which applies MessageFlags.IsComponentsV2 and resolves the tree.

export class HoneypotHandler extends Handlers {
    button(bannedCount) {
        return this.medusa.components.button({
            style: "secondary",
            label: this.medusa.modules.lang.phrase("anti-scam", "messages", "bannedButton", { count: String(bannedCount) }),
            emoji: "%custom_emoji_hammer%",
            disabled: true,
            custom_id: "honeypot_banned_display"
        });
    }

    banner(bannedCount) {
        return this.medusa.components.container({
            accent: "#ef4444",
            components: [
                this.medusa.components.text(`### ${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerTitle")}\n\n${this.medusa.modules.lang.phrase("anti-scam", "messages", "bannerBody")}`),
                this.medusa.components.separator(),
                this.medusa.components.row([this.button(bannedCount)])
            ]
        });
    }

    async postBanner(channel, guild) {
        const bannedTotal = await this.count(guild.id);
        return channel.send(this.medusa.components.payload(this.banner(bannedTotal)));
    }
}

Interaction Routing & Custom IDs

To prevent naming collisions between modules, Medusa enforces a namespaced pattern for all interaction custom IDs:

medusa:<component_type>:<module_name>:<action>[:parameters]
  • Buttons: medusa:button:<module>:<action>[:params] (e.g. medusa:button:emojilocker:menu:lock, medusa:button:ticket:claim:12345).
  • Select Menus: medusa:select:<module>:<action> (e.g. medusa:select:roles:assign).
  • Modals: medusa:modal:<module>:<action> (e.g. medusa:modal:ticket:reason).

Interaction Listener in Handlers

export class TicketHandler extends Handlers {
    async handleInteraction(interaction) {
        if (!interaction.isButton() && !interaction.isStringSelectMenu()) return;

        const parts = interaction.customId.split(":");
        const scope = parts[0];
        const type = parts[1];
        const module = parts[2];
        const action = parts[3];

        if (scope !== "medusa" || module !== "ticket") return;

        switch (action) {
            case "close": {
                await interaction.update({
                    embeds: [this.medusa.embeds.warn(this.medusa.modules.lang.phrase("ticket", "messages", "ticketClosing"))],
                    components: []
                });
                break;
            }
            case "claim": {
                const ticketId = parts[4];
                await interaction.reply({
                    embeds: [this.medusa.embeds.success(this.medusa.modules.lang.phrase("ticket", "messages", "ticketClaimed", { id: ticketId }))],
                    flags: MessageFlags.Ephemeral
                });
                break;
            }
            default:
                break;
        }
    }
}

Global Placeholders in Components

When components are resolved via this.medusa.embeds.resolveComponent(comp, tokens) or this.medusa.embeds.send(...), the following tokens are automatically populated inside button labels, URLs, select menu options, and text contents:

TokenReplacement Value
%server% / %server_name%Current Discord server name.
%server_icon% / %guild_icon%Server icon URL.
%user_avatar%Interaction user or bot avatar URL.
%bot_avatar%Discord application bot avatar URL.
%brand_icon%Configured brand icon URL (CUSTOM_ICON_URL).
%name%Configured dashboard branding name (DASHBOARD_NAME).
%version%Active Medusa bot version.
%custom_emoji_<name>%Custom emoji string tag (e.g. <:check:123456789>).
<custom_token>Any custom key provided in the tokens object parameter.