Skip to content

Events

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.

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()
]});
}
}
ParameterTypeRequiredDescription
namestringYesUnique identifier for the event handler
eventstringYesThe discord.js event to listen to
middlewareMiddleware[]NoTyped middleware to run before execute()

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.

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.

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

@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()
);
}
}
@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()
);
}
}
@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:

Source eventDecomposed events
guildMemberUpdateguildMemberBoost, guildMemberUnboost, guildMemberRoleAdd, guildMemberRoleRemove, guildMemberNicknameUpdate, guildMemberAvatarUpdate, guildMemberTimeout, guildMemberUntimeout
messageUpdatemessageContentEdit, messagePin, messageUnpin, messageEmbedAdd
voiceStateUpdatevoiceChannelJoin, voiceChannelLeave, voiceChannelSwitch, voiceMute, voiceUnmute, voiceDeafen, voiceUndeafen
guildUpdateguildNameUpdate, guildIconUpdate, guildOwnerUpdate, guildBoostTierUpdate
channelUpdatechannelNameUpdate, channelTopicUpdate, channelPermissionUpdate, channelNsfwUpdate
roleUpdateroleNameUpdate, roleColorUpdate, rolePermissionUpdate
presenceUpdatememberOnline, memberOffline, memberIdle, memberDnd, activityUpdate
userUpdateusernameUpdate, userAvatarUpdate

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.

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.

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.

In addition to Discord events, the framework emits its own lifecycle events during command and handler execution:

CommandPre → CommandDenied / CommandAccepted → CommandRun → CommandSuccess / CommandError → CommandFinish

Lifecycle 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`);
}
}
}