Interactions
Discord interactions allow your module to react to user actions on UI components: buttons, select menus (string, user, role, channel), and modals.
Supported Types
- Message Component Interactions: buttons, select menus
- Modal Submit Interactions: form submissions
File Structure
src/modules/my-module/
├── my-module.module.ts
├── commands/
│ └── my-command.command.ts
└── interactions/
├── confirm.button.ts
├── role-select.select.ts
└── feedback.modal.tsInteractionHandler API
interface InteractionHandler {
customId: string; // Prefix for custom ID matching
access: "admin" | "everyone" | { permissions: bigint }; // Who may trigger it — required
check: (
interaction: CompatibleInteraction,
config: ConfigProvider
) => interaction is SpecificType;
execute: (
interaction: SpecificType,
args: string[],
config: ConfigProvider
) => Promise<void>;
}| Field | Required | Description |
|---|---|---|
customId | Yes | Unique prefix — matched against the start of the interaction's customId |
access | Yes | "admin" restricts the interaction to users with the Administrator permission; { permissions } to users holding those permissions; "everyone" opens it to all |
check | Yes | Type guard — narrows the interaction type for execute |
execute | Yes | Called when the interaction is triggered |
The config parameter is a ConfigProvider giving access to the module's configuration.
Custom ID with Arguments
The system supports passing arguments through the customId using : as a separator:
customId:arg1:arg2:arg3The dispatcher splits the interaction's customId on :, uses the first segment for handler matching, and passes the remaining segments as the args array.
Example: Button with Args
// Command that creates the button
import { ButtonBuilder, ButtonStyle } from "discord.js";
const button = new ButtonBuilder()
.setCustomId(`confirm-action:delete:${userId}`)
.setLabel("Confirm")
.setStyle(ButtonStyle.Danger);// Interaction handler
import { ButtonInteraction, MessageFlags } from "discord.js";
import { declareInteractionHandler } from "#lib/interaction.js";
export default declareInteractionHandler({
customId: "confirm-action",
check: (interaction): interaction is ButtonInteraction =>
interaction.isButton(),
async execute(interaction, [action, targetId]) {
await interaction.reply({
content: `Action "${action}" confirmed for user ${targetId}`,
flags: MessageFlags.Ephemeral,
});
},
});Creating Interactions
Button
// src/modules/greeter/interactions/confirm.button.ts
import { MessageFlags } from "discord.js";
import { declareInteractionHandler } from "#lib/interaction.js";
export default declareInteractionHandler({
customId: "confirm-action",
check: (interaction) => interaction.isButton(),
async execute(interaction, [actionId, userId]) {
await interaction.reply({
content: `Action ${actionId} confirmed for user ${userId}`,
flags: MessageFlags.Ephemeral,
});
},
});Select Menu
// src/modules/greeter/interactions/role-select.select.ts
import { declareInteractionHandler } from "#lib/interaction.js";
export default declareInteractionHandler({
customId: "role-select",
check: (interaction) => interaction.isStringSelectMenu(),
async execute(interaction, [targetUserId]) {
const selectedRoles = interaction.values;
await interaction.reply({
content: `Roles ${selectedRoles.join(", ")} assigned to <@${targetUserId}>`,
flags: MessageFlags.Ephemeral,
});
},
});Modal
// src/modules/greeter/interactions/feedback.modal.ts
import { declareInteractionHandler } from "#lib/interaction.js";
export default declareInteractionHandler({
customId: "feedback-modal",
check: (interaction) => interaction.isModalSubmit(),
async execute(interaction, [category]) {
const feedback = interaction.fields.getTextInputValue("feedback-input");
await interaction.reply({
content: "Thank you for your feedback!",
flags: MessageFlags.Ephemeral,
});
},
});Admin Gating
access has no default: every handler must state who may trigger it, so a new one cannot end up public by omission. Set access: "admin" to restrict an interaction to server administrators:
export default declareInteractionHandler({
customId: "dangerous-action",
access: "admin",
check: (interaction) => interaction.isButton(),
async execute(interaction, args) {
// Only admins can reach this point
await interaction.reply({ content: "Action performed!", ephemeral: true });
},
});The check is enforced centrally by the interaction dispatcher — no need for inline permission checks in your handler.
To open an interaction to moderators rather than administrators only, require the Discord permissions it needs:
export default declareInteractionHandler({
customId: "ban-author",
access: { permissions: PermissionFlagsBits.BanMembers },
check: (interaction) => interaction.isButton(),
// ...
});Registration
// src/modules/greeter/greeter.module.ts
import confirmButton from "./interactions/confirm.button.js";
import roleSelect from "./interactions/role-select.select.js";
export default defineModule({
onLoad(_client, registry) {
registry.register(confirmButton);
registry.register(roleSelect);
},
});Activation Management
Interactions are automatically tied to module activation. When a module is disabled on a guild, its interaction handlers are no-op — the dispatcher skips them. No extra code needed.
Config Message Refresh
When editing configuration via ephemeral select menus, the source config message (public) needs to be updated. The system uses refreshSourceConfigMessage() which threads the source message ID through customId arguments and re-edits the public message after each change.
Localization
Interaction responses can use config.t() for localized strings. Provide translations in your module's i18n/ files:
async execute(interaction, [actionId, userId]) {
await interaction.reply({
content: config.t("actionConfirmed", { action: actionId, user: userId }),
flags: MessageFlags.Ephemeral,
});
}The guild's locale is configured via /config core > locale. See Configuration → Localization for details.
Best Practices
Response Types
// Instant reply
await interaction.reply({ content: "Done!", ephemeral: true });
// Deferred reply (for operations > 3 seconds)
await interaction.deferReply({ ephemeral: true });
await longOperation();
await interaction.editReply({ content: "Finished!" });
// Update the source message (for message components)
await interaction.update({ content: "Updated", components: [] });Argument Validation
async execute(interaction, [userId, action]) {
if (!userId || !action) {
await interaction.reply({
content: "Missing data. Please try again.",
ephemeral: true,
});
return;
}
// Proceed with validated args
}Error Handling
async execute(interaction, [targetId]) {
try {
await performRiskyOperation(targetId);
await interaction.reply({ content: "Success!", ephemeral: true });
} catch (error) {
logger.error("Error:", error);
await interaction.reply({
content: "An error occurred. Please try again.",
ephemeral: true,
});
}
}Next Steps
- Configuration for managing module settings
- Commands for creating slash commands
- Listeners for reacting to Discord events