Skip to content

Components

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.

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.'));
}
}
ParameterTypeRequiredDescription
customIdstringYesStatic string or path-to-regexp pattern
middlewareMiddleware[]NoTyped middleware to run before execute()

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()
);
}
}
ParameterTypeRequiredDescription
customIdstringYesStatic string or path-to-regexp pattern
middlewareMiddleware[]NoTyped middleware to run before execute()

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;
}
}
}
ParameterTypeRequiredDescription
customIdstringYesStatic string or path-to-regexp pattern
middlewareMiddleware[]NoTyped middleware to run before execute()

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'}.

@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.

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 characters

The 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 chars
const 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.

Auto-encoding is enabled by default. If you’d prefer to manage custom IDs yourself, you can disable it globally in your config:

config/warden.ts
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 one

Or use .encode() to force encoding on a specific button when it’s globally disabled:

Btn.danger('Confirm', `action/${longComplexId}`).encode() // encode this one regardless

This gives you three levels of control: global default via config, per-component opt-out with .raw(), and per-component opt-in with .encode().

If you prefer explicit control, the Params also provides encode() and decode() methods:

// Encode complex data into a short token
const 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'}
}
}

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 string
const caseId = params.getOrFail('caseId'); // throws if missing

When @warden/drizzle is installed, the bag also supports model resolution:

const user = params.model('userId', UserSchema); // resolved from DB automatically

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 { ... }

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.