Components
Introduction
Section titled “Introduction”Components are the interactive elements in Discord messages — buttons, modals (popup forms), and select menus. In a moderation bot, these power confirmation dialogs (“Are you sure you want to ban?”), reason forms (“Why are you warning this user?”), and action menus (“Choose: warn, mute, or ban”).
Each component type gets its own decorator and class, following the same pattern you’ve already learned with commands and events. Warden also supports dynamic custom IDs via path-to-regexp, so you can encode contextual data (like which user to ban) directly in the button’s ID.
Buttons
Section titled “Buttons”Let’s start with a classic — a “Confirm Ban” button:
import {button, Button, Params, reply, Embed} from '@warden/core';import {ButtonInteraction} from 'discord.js';
@button({customId: 'confirm-ban'})export default class ConfirmBanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { await reply(interaction, Embed.success('User has been banned.')); }}The @button() decorator
Section titled “The @button() decorator”| Parameter | Type | Required | Description |
|---|---|---|---|
customId | string | Yes | Static string or path-to-regexp pattern |
middleware | Middleware[] | No | Typed middleware to run before execute() |
Modals
Section titled “Modals”A “Warn Reason” form that pops up when moderators right-click a user:
import {modal, Modal, Params, reply, Embed} from '@warden/core';import {ModalSubmitInteraction} from 'discord.js';
@modal({customId: 'warn-reason'})export default class WarnReasonModal implements Modal { constructor(private caseService: CaseService) {}
public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> { const reason = interaction.fields.getTextInputValue('reason'); const severity = interaction.fields.getTextInputValue('severity');
const caseNumber = await this.caseService.createWarning( interaction.guildId!, params.get('userId')!, reason, interaction.user.id, );
await reply(interaction, Embed.success(`Warning issued — case #${caseNumber}`) .addFields( {name: 'Reason', value: reason}, {name: 'Severity', value: severity}, ) .setTimestamp() ); }}The @modal() decorator
Section titled “The @modal() decorator”| Parameter | Type | Required | Description |
|---|---|---|---|
customId | string | Yes | Static string or path-to-regexp pattern |
middleware | Middleware[] | No | Typed middleware to run before execute() |
Select menus
Section titled “Select menus”A menu for picking which moderation action to take:
import {menu, SelectMenu, Params, reply} from '@warden/core';import {StringSelectMenuInteraction} from 'discord.js';
@menu({customId: 'mod-action-select'})export default class ModActionSelectMenu implements SelectMenu { public async execute(interaction: StringSelectMenuInteraction, params: Params): Promise<void> { const action = interaction.values[0]; // 'warn', 'mute', 'ban'
switch (action) { case 'warn': // show warn modal... break; case 'mute': // show mute duration picker... break; case 'ban': // show ban confirmation... break; } }}The @menu() decorator
Section titled “The @menu() decorator”| Parameter | Type | Required | Description |
|---|---|---|---|
customId | string | Yes | Static string or path-to-regexp pattern |
middleware | Middleware[] | No | Typed middleware to run before execute() |
Dynamic custom IDs
Section titled “Dynamic custom IDs”Static custom IDs like confirm-ban work fine for generic actions. But in a moderation bot, you often need to know which user to ban or which case to review. Rather than storing this in a temporary map or database, you can encode it directly in the custom ID using path-to-regexp patterns:
@button({customId: 'ban/:userId'})export default class BanButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const userId = params.get('userId')!; const member = await interaction.guild?.members.fetch(userId);
if (!member) { await reply(interaction, Embed.error('User not found.')); return; }
await member.ban({reason: 'Confirmed by moderator'}); await reply(interaction, Embed.success(`${member.user.username} has been banned.`)); }}When creating the button in your command, use the Btn and Row builders:
import {Row, Btn} from '@warden/core';
const row = Row( Btn.danger('Confirm Ban', `ban/${target.id}`), Btn.secondary('Cancel', 'cancel'),);
await reply(interaction, {embeds: [embed], components: [row]});The framework matches ban/123456789 against the pattern ban/:userId and populates the Params with {userId: '123456789'}.
Multiple parameters
Section titled “Multiple parameters”@button({customId: 'case/:caseId/action/:action'})export default class CaseActionButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const caseId = params.getNumber('caseId')!; const action = params.get('action')!; // 'resolve', 'escalate', 'dismiss' // ... }}Dynamic IDs work on buttons, modals, and select menus.
Custom ID encoding
Section titled “Custom ID encoding”There’s one thing to be aware of: Discord limits custom IDs to 100 characters. A single snowflake ID is 18+ digits, so encoding multiple parameters can get tight:
`case/${caseId}/action/${action}/mod/${moderatorId}/guild/${guildId}`// easily exceeds 100 charactersThe good news is that Warden handles this transparently. When you use the Btn, Select, or ModalForm builders and the custom ID exceeds 100 characters, the framework auto-encodes it into a short token:
// This just works — even though the full ID would be ~120 charsconst row = Row( Btn.danger('Confirm', `action/${userId}/${caseId}/${modId}/${guildId}`),);// The actual customId sent to Discord: 'action/x7kQ2m'Your handler doesn’t need to know whether encoding happened. params.get('userId') works either way — the framework decodes transparently:
@button({customId: 'action/:userId/:caseId/:modId/:guildId'})export default class ActionButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const userId = params.get('userId')!; // works whether encoded or not const caseId = params.get('caseId')!; // ... }}Under the hood, encoded tokens are stored in Redis when @warden/cache is installed (surviving restarts), or in-memory otherwise. They expire after a configurable TTL that defaults to 15 minutes — matching Discord’s own component interaction timeout.
Controlling auto-encoding
Section titled “Controlling auto-encoding”Auto-encoding is enabled by default. If you’d prefer to manage custom IDs yourself, you can disable it globally in your config:
export default { components: { autoEncode: false, },};You may also control it per component. Use .raw() to skip encoding on a specific button when auto-encoding is globally enabled:
Btn.danger('Confirm', `ban/${userId}`).raw() // never encode this oneOr use .encode() to force encoding on a specific button when it’s globally disabled:
Btn.danger('Confirm', `action/${longComplexId}`).encode() // encode this one regardlessThis gives you three levels of control: global default via config, per-component opt-out with .raw(), and per-component opt-in with .encode().
Manual encoding
Section titled “Manual encoding”If you prefer explicit control, the Params also provides encode() and decode() methods:
// Encode complex data into a short tokenconst token = await params.encode({ userId: target.id, caseId: String(caseNumber), action: 'ban', moderatorId: interaction.user.id,});
const row = Row( Btn.danger('Confirm', `mod-action/${token}`),);// Decode in the handler@button({customId: 'mod-action/:token'})export default class ModActionButton implements Button { public async execute(interaction: ButtonInteraction, params: Params): Promise<void> { const data = await params.decode('token'); // {userId: '123456789012345678', caseId: '42', action: 'ban', moderatorId: '987654321012345678'} }}Params
Section titled “Params”Every component handler receives a Params as its second argument — even for static custom IDs (where it’s empty).
class Params { get(key: string): string | undefined; // get a parameter getOrFail(key: string): string; // get or throw if missing getNumber(key: string): number | undefined; // parse as number getBoolean(key: string): boolean | undefined; // parse as boolean has(key: string): boolean; // check existence all(): Record<string, string>; // get everything keys(): string[]; // list all keys encode(data: Record<string, string>): Promise<string>; // encode data into a short token decode(key: string): Promise<Record<string, string>>; // decode a token back to data}Path parameters are always strings (they come from the custom ID). Use the typed getters for convenience:
const page = params.getNumber('page'); // number, not stringconst caseId = params.getOrFail('caseId'); // throws if missingModel binding
Section titled “Model binding”When @warden/drizzle is installed, the bag also supports model resolution:
const user = params.model('userId', UserSchema); // resolved from DB automaticallyMiddleware, cooldowns & permissions
Section titled “Middleware, cooldowns & permissions”Components support the same cross-cutting decorators as commands. You may restrict who can click a button, add rate limiting, or require Discord permissions:
@button({ customId: 'ban/:userId', middleware: [EnsureGuildIsAvailable],})@permissions({user: ['BanMembers'], bot: ['BanMembers']})export default class BanButton implements Button { ... }You may also prevent spam-clicking with cooldowns:
@button({customId: 'report-user/:userId'})@cooldown({duration: 60, scope: 'user', message: 'You can only report once per minute.'})export default class ReportUserButton implements Button { ... }A complete moderation flow
Section titled “A complete moderation flow”Here’s how buttons, modals, and commands work together in a typical mod flow:
1. Moderator runs /mod warn @user:
@command({parent: 'mod', name: 'warn', description: 'Warn a user'})@permissions({user: ['ModerateMembers']})export default class ModWarnCommand extends Subcommand { public shouldDeferReply = false;
public async execute(interaction: CommandInteraction, params: Params): Promise<void> { const target = interaction.options.getUser('user')!;
// Show a modal asking for the reason await interaction.showModal( ModalForm(`warn-reason/${target.id}`, `Warn ${target.username}`) .addField(TextInput.paragraph('reason', 'Reason').required()) ); }}2. Modal submission handler creates the case:
@modal({customId: 'warn-reason/:userId'})export default class WarnReasonModal implements Modal { constructor(private caseService: CaseService, private modLog: ModLogService) {}
public async execute(interaction: ModalSubmitInteraction, params: Params): Promise<void> { const userId = params.get('userId')!; const reason = interaction.fields.getTextInputValue('reason');
const caseNumber = await this.caseService.createWarning( interaction.guildId!, userId, reason, interaction.user.id, );
await reply(interaction, Embed.success(`Warning issued — case #${caseNumber}`)); await this.modLog.send(Embed.warning(`${interaction.user.username} warned <@${userId}>`) .addFields({name: 'Reason', value: reason}, {name: 'Case', value: `#${caseNumber}`}) ); }}The data flows through the custom ID: command → modal (warn-reason/:userId) → handler. No temporary storage needed.