Events
Introduction
Section titled “Introduction”Events let your bot react to things happening in Discord — members joining, messages being deleted, roles changing, and much more. For a moderation bot, events are essential: they power your audit logs, auto-mod, and real-time notifications.
Warden also ships with over 40 decomposed events that break Discord’s coarse events into specific, actionable ones. Instead of manually diffing guildMemberUpdate to figure out what changed, you simply listen to guildMemberRoleAdd or guildMemberTimeout.
Defining events
Section titled “Defining events”import {event, Event, Embed} from '@warden/core';import {GuildMember, TextChannel} from 'discord.js';
@event({name: 'MemberJoinLog', event: 'guildMemberAdd'})export default class MemberJoinLogEvent implements Event<'guildMemberAdd'> { constructor(private config: Config) {}
public async execute(member: GuildMember): Promise<void> { const channelId = this.config.get('moderation.logChannel') as string; const channel = member.guild.channels.cache.get(channelId) as TextChannel; if (!channel) return;
const accountAge = Math.floor((Date.now() - member.user.createdTimestamp) / 86400000);
await channel.send({embeds: [ Embed.info(`${member.user.username} joined the server`) .addFields( {name: 'Account age', value: `${accountAge} days`, inline: true}, {name: 'Member count', value: `${member.guild.memberCount}`, inline: true}, ) .setThumbnail(member.user.displayAvatarURL()) .setTimestamp() ]}); }}The @event() decorator
Section titled “The @event() decorator”| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique identifier for the event handler |
event | string | Yes | The discord.js event to listen to |
middleware | Middleware[] | No | Typed middleware to run before execute() |
The execute() method
Section titled “The execute() method”Receives the same arguments as a discord.js event listener. The type parameter on Event<T> ensures type safety:
// guildMemberAdd → (member: GuildMember)// guildMemberRemove → (member: GuildMember)// messageCreate → (message: Message)// messageReactionAdd → (reaction: MessageReaction, user: User)// ready → () (no arguments)The @event() decorator works just like @command() — Warden discovers it automatically on boot. The event field maps to a discord.js client event, and execute() receives the same arguments that discord.js would pass to a regular listener.
You can follow this same pattern for any Discord event — member leaves, message deletions, reaction adds, voice state changes, and so on.
Decomposed events
Section titled “Decomposed events”Many of Discord’s raw events are too broad for direct use — guildMemberUpdate fires for nickname changes, role adds, boosts, timeouts, and more, leaving you to diff old and new state by hand. Warden splits each coarse event into specific, actionable ones, so instead of one handler full of conditionals you write one handler per concern.
The recipes below cover the cases moderation bots reach for most often. Skip ahead to the full table for everything Warden ships, or jump to Custom decompositions to define your own.
Logging role changes
Section titled “Logging role changes”@event({name: 'RoleAddedLog', event: 'guildMemberRoleAdd'})export default class RoleAddedLogEvent implements Event<'guildMemberRoleAdd'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, role: Role): Promise<void> { await this.modLog.send(Embed.info(`Role added to ${member.user.username}`) .addFields({name: 'Role', value: role.name}) .setTimestamp() ); }}
@event({name: 'RoleRemovedLog', event: 'guildMemberRoleRemove'})export default class RoleRemovedLogEvent implements Event<'guildMemberRoleRemove'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, role: Role): Promise<void> { await this.modLog.send(Embed.warning(`Role removed from ${member.user.username}`) .addFields({name: 'Role', value: role.name}) .setTimestamp() ); }}No diffing. No conditionals. One event, one handler, one purpose.
Logging timeouts
Section titled “Logging timeouts”@event({name: 'MemberTimedOut', event: 'guildMemberTimeout'})export default class MemberTimedOutEvent implements Event<'guildMemberTimeout'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember): Promise<void> { await this.modLog.send(Embed.error(`${member.user.username} was timed out`) .addFields({name: 'Until', value: member.communicationDisabledUntil?.toISOString() ?? 'Unknown'}) .setTimestamp() ); }}
@event({name: 'MemberUntimed', event: 'guildMemberUntimeout'})export default class MemberUntimedEvent implements Event<'guildMemberUntimeout'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember): Promise<void> { await this.modLog.send(Embed.success(`${member.user.username}'s timeout was removed`) .setTimestamp() ); }}Logging message edits
Section titled “Logging message edits”@event({name: 'MessageEditLog', event: 'messageContentEdit'})export default class MessageEditLogEvent implements Event<'messageContentEdit'> { constructor(private modLog: ModLogService) {}
public async execute(oldMessage: Message, newMessage: Message): Promise<void> { await this.modLog.send(Embed.info(`Message edited by ${newMessage.author.username}`) .addFields( {name: 'Before', value: oldMessage.content || '*empty*'}, {name: 'After', value: newMessage.content || '*empty*'}, {name: 'Channel', value: `<#${newMessage.channelId}>`}, ) .setTimestamp() ); }}Logging voice activity
Section titled “Logging voice activity”@event({name: 'VoiceJoinLog', event: 'voiceChannelJoin'})export default class VoiceJoinLogEvent implements Event<'voiceChannelJoin'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, channel: VoiceChannel): Promise<void> { await this.modLog.send(Embed.info(`${member.user.username} joined ${channel.name}`)); }}
@event({name: 'VoiceLeaveLog', event: 'voiceChannelLeave'})export default class VoiceLeaveLogEvent implements Event<'voiceChannelLeave'> { constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, channel: VoiceChannel): Promise<void> { await this.modLog.send(Embed.warning(`${member.user.username} left ${channel.name}`)); }}Warden ships with over 40 decomposed events. Here’s the full list:
Full decomposition table
Section titled “Full decomposition table”| Source event | Decomposed events |
|---|---|
guildMemberUpdate | guildMemberBoost, guildMemberUnboost, guildMemberRoleAdd, guildMemberRoleRemove, guildMemberNicknameUpdate, guildMemberAvatarUpdate, guildMemberTimeout, guildMemberUntimeout |
messageUpdate | messageContentEdit, messagePin, messageUnpin, messageEmbedAdd |
voiceStateUpdate | voiceChannelJoin, voiceChannelLeave, voiceChannelSwitch, voiceMute, voiceUnmute, voiceDeafen, voiceUndeafen |
guildUpdate | guildNameUpdate, guildIconUpdate, guildOwnerUpdate, guildBoostTierUpdate |
channelUpdate | channelNameUpdate, channelTopicUpdate, channelPermissionUpdate, channelNsfwUpdate |
roleUpdate | roleNameUpdate, roleColorUpdate, rolePermissionUpdate |
presenceUpdate | memberOnline, memberOffline, memberIdle, memberDnd, activityUpdate |
userUpdate | usernameUpdate, userAvatarUpdate |
Custom decompositions
Section titled “Custom decompositions”Of course, Warden can’t anticipate every scenario your bot might need. For application-specific logic, you can define your own decomposed events using the @decompose() decorator. For example, detect when a user edits a message to add a banned link:
import {decompose} from '@warden/core';import {Message} from 'discord.js';
@decompose({ source: 'messageUpdate', emit: 'messageBannedLinkEdit',})export default class BannedLinkEditDecomposer { constructor(private filterService: FilterService) {}
public test(oldMessage: Message, newMessage: Message): boolean { if (!newMessage.content) return false; // only fire if the NEW content has a banned link that the OLD didn't const oldHad = this.filterService.containsBannedLink(oldMessage.content ?? ''); const newHas = this.filterService.containsBannedLink(newMessage.content); return !oldHad && newHas; }
public extract(oldMessage: Message, newMessage: Message): [Message] { return [newMessage]; }}Then listen to it like any built-in event:
@event({name: 'BannedLinkEdit', event: 'messageBannedLinkEdit'})export default class BannedLinkEditEvent implements Event<'messageBannedLinkEdit'> { constructor(private modLog: ModLogService) {}
public async execute(message: Message): Promise<void> { await message.delete(); await this.modLog.send(Embed.error(`${message.author.username} edited a message to include a banned link`) .addFields({name: 'Content', value: message.content}) .setTimestamp() ); }}The test() method decides whether the custom event should fire, and extract() transforms the raw arguments into whatever your listeners need. From there, you listen to your custom event exactly like a built-in one.
Middleware on events
Section titled “Middleware on events”Just like commands, events support middleware. This is especially useful for filtering — you probably don’t want your auto-mod running on bot messages:
@event({ name: 'AutoModFilter', event: 'messageCreate', middleware: [IgnoreBots, EnsureGuildIsAvailable],})export default class AutoModFilterEvent implements Event<'messageCreate'> { constructor(private filterService: FilterService, private modLog: ModLogService) {}
public async execute(message: Message): Promise<void> { if (this.filterService.containsBannedWord(message.content)) { await message.delete(); await message.channel.send({embeds: [ Embed.error(`${message.author.username}, that language is not allowed here.`) ]}); await this.modLog.send(Embed.warning(`Auto-mod: deleted message from ${message.author.username}`) .addFields({name: 'Content', value: message.content}) ); } }}The middleware runs left to right — IgnoreBots filters out bot messages first, then EnsureGuildIsAvailable verifies we’re in a server. Only if both pass does execute() run.
IgnoreBots is built-in middleware that ships with @warden/core — just import it directly, no need to write your own:
import {event, IgnoreBots} from '@warden/core';For a deeper look at middleware, see the Middleware guide.
Multiple listeners
Section titled “Multiple listeners”You may register multiple handlers for the same Discord event. They run independently — if one fails, the others are unaffected:
@event({name: 'MemberJoinLog', event: 'guildMemberAdd'})export default class MemberJoinLogEvent implements Event<'guildMemberAdd'> { ... }
@event({name: 'MemberJoinAutoRole', event: 'guildMemberAdd'})export default class MemberJoinAutoRoleEvent implements Event<'guildMemberAdd'> { ... }
@event({name: 'MemberJoinAgeCheck', event: 'guildMemberAdd'})export default class MemberJoinAgeCheckEvent implements Event<'guildMemberAdd'> { ... }All three fire when a member joins. One failing doesn’t affect the others — the log still works even if the auto-role assignment fails. This makes it safe to separate concerns into focused handlers without worrying about one breaking another.
Lifecycle events
Section titled “Lifecycle events”In addition to Discord events, the framework emits its own lifecycle events during command and handler execution:
CommandPre → CommandDenied / CommandAccepted → CommandRun → CommandSuccess / CommandError → CommandFinishLifecycle events have their own @lifecycle() decorator — they do not use @event(). This keeps the Discord event system clean: @event() is strictly for Discord events and decomposed events, while @lifecycle() handles framework-internal hooks.
import {lifecycle, LifecycleHandler, FinishPayload} from '@warden/core';
@lifecycle({event: 'commandFinish'})export default class ModCommandMetrics implements LifecycleHandler { constructor(private logger: Logger) {}
public async execute(payload: FinishPayload): Promise<void> { if (payload.name.startsWith('mod')) { this.logger.info(`Mod command /${payload.name} completed in ${payload.duration}ms`); } }}