Skip to content

Your first event

A moderation bot isn’t much use if it can’t see what’s happening. Let’s start with something every mod bot needs — logging when members join the server.

  1. Generate a new event:

    Terminal window
    pnpm warden make:event MemberJoinLogEvent
  2. Open the generated file and fill it in:

    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;
    if (!channelId) return;
    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`)
    .setThumbnail(member.user.displayAvatarURL())
    .addFields(
    {name: 'Account age', value: `${accountAge} days`, inline: true},
    {name: 'Member count', value: `${member.guild.memberCount}`, inline: true},
    )
    .setTimestamp()
    ]});
    }
    }

The @event() decorator works just like @command() — it registers the class and Warden auto-discovers it 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 event listener.

You can follow the same pattern for member leaves, message deletions, or any other Discord event.

Here’s where Warden really shines for moderation bots. Discord’s raw events are often too broad — guildMemberUpdate, for instance, fires for nickname changes, role additions, boosts, timeouts, and more. Without help, you end up writing tedious diffing code:

// You shouldn't have to write this
@event({name: 'MemberUpdate', event: 'guildMemberUpdate'})
export default class MemberUpdateEvent implements Event<'guildMemberUpdate'> {
public async execute(oldMember: GuildMember, newMember: GuildMember): Promise<void> {
if (oldMember.nickname !== newMember.nickname) { /* nickname change */ }
if (oldMember.roles.cache.size !== newMember.roles.cache.size) { /* role change... but which one? */ }
if (!oldMember.communicationDisabledUntil && newMember.communicationDisabledUntil) { /* timeout */ }
}
}

Warden decomposes these coarse events into specific, actionable ones. Instead of the mess above, you simply listen to exactly what you care about:

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

No diffing. No conditionals. One event, one handler, one purpose. Warden ships with over 40 decomposed events covering member updates, message edits, voice state changes, role updates, and more. You’ll find the complete list in the Events reference.

Of course, Warden can’t anticipate every scenario. For application-specific logic, you can define your own decomposed events. Let’s say you want to 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;
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];
}
}

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

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.

You may register multiple handlers for the same 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. The log still works even if the auto-role assignment fails. This makes it safe to separate concerns into focused event handlers without worrying about one breaking another.

Your mod bot can now react to events, take moderation actions, and log everything to a mod channel. Next, let’s look at how to organize all of this: