Skip to content

Custom Event Decomposition

Discord’s events are designed around what happened at the protocol level — a member was updated, a message was updated, a voice state changed. But your bot cares about what happened at the domain level — a user got a new role, a message was edited to add a banned link, a member’s warning count just hit 5.

Warden bridges this gap with event decomposition. The framework ships with 40+ built-in decompositions that cover the most common cases. But your bot’s domain has its own specific events that no framework could anticipate. That’s where custom decompositions come in.

A custom decomposition lets you define your own events by watching a source event, deciding whether your custom event should fire, and transforming the arguments into whatever shape your listeners need. From there, listeners use the normal @event() decorator — they don’t know or care that the event is decomposed.

Let’s say you want to detect when a user edits a message to sneak in a banned link. Without decomposition, you’d listen to messageUpdate and write the detection logic inline:

@event({name: 'MessageUpdateHandler', event: 'messageUpdate'})
export default class MessageUpdateHandler implements Event<'messageUpdate'> {
constructor(private filterService: FilterService, private modLog: ModLogService) {}
public async execute(oldMessage: Message, newMessage: Message): Promise<void> {
// check for banned links added via edit
if (newMessage.content && !this.filterService.containsBannedLink(oldMessage.content ?? '')
&& this.filterService.containsBannedLink(newMessage.content)) {
await newMessage.delete();
await this.modLog.send(/* ... */);
}
// check for removed content warnings
// check for added mentions
// check for embedded scam links
// ... this method keeps growing
}
}

This works, but it doesn’t scale. Every new detection rule adds another branch to the same handler. The logic for “message had a banned link added” is tangled with “message had its content cleared” and “message gained new mentions.” Testing any single behavior means understanding all of them.

Decomposition solves this by extracting each detection rule into its own class, producing its own event, consumed by its own listener. Each piece is independent, testable, and single-purpose.

Before writing your own, check if Warden already covers your use case. The framework ships with decompositions for the most common moderation scenarios:

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

If guildMemberRoleAdd or messageContentEdit is what you need, just listen to it directly — no custom decomposition required. Custom decompositions are for domain-specific logic that goes beyond structural diffing.

A decomposition is a class with three things: a @decompose() decorator, a test() method, and an extract() method:

import {decompose} from '@warden/core';
import {Message} from 'discord.js';
@decompose({
source: 'messageUpdate',
emit: 'messageBannedLinkAdded',
})
export default class BannedLinkAddedDecomposer {
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];
}
}

That’s it. When messageUpdate fires, Warden runs every decomposer registered on that source event. If test() returns true, the framework emits the custom event with the arguments returned by extract(). Listeners pick it up with @event({event: 'messageBannedLinkAdded'}).

ParameterTypeRequiredDescription
sourcestringYesThe Discord event (or another decomposed event) to watch
emitstringYesThe name of the custom event to emit when test() returns true

The source can be any event Warden knows about — a raw Discord event like messageUpdate or guildMemberUpdate, or even another decomposed event like messageContentEdit. This lets you chain decompositions: a built-in decomposition produces messageContentEdit, and your custom decomposition watches that to produce messageBannedLinkAdded.

The emit name is what your listeners use in @event({event: 'messageBannedLinkAdded'}). Choose something descriptive that reads naturally as a past-tense action.

public test(...args): boolean

The test() method receives the same arguments as the source event’s listener and returns a boolean. If it returns true, the custom event fires. If false, nothing happens.

This method should be fast and side-effect-free. It runs on every firing of the source event, so avoid database queries, API calls, or anything that could slow down the event pipeline. If you need data from a service, prefer caching or in-memory checks.

Some guidelines:

  • Return true only for the specific condition you care about
  • Account for null/undefined values (partial messages, uncached data)
  • Keep the logic simple — if your test() method is getting complex, you might need two decompositions instead of one
public extract(...args): [...any]

The extract() method transforms the source event’s arguments into the shape your listeners expect. It receives the same arguments as test() and returns a tuple of values that become the listener’s arguments.

This is where you decide what data your custom event carries. You might pass through the original arguments unchanged, or you might extract and reshape them:

// Pass just the new message
public extract(oldMessage: Message, newMessage: Message): [Message] {
return [newMessage];
}
// Pass both messages
public extract(oldMessage: Message, newMessage: Message): [Message, Message] {
return [oldMessage, newMessage];
}
// Extract specific data
public extract(oldMember: GuildMember, newMember: GuildMember): [GuildMember, number] {
const warningCount = this.getWarningCount(newMember);
return [newMember, warningCount];
}

The return type of extract() determines the argument types of your listener’s execute() method. Keep this in mind when designing your event’s contract.

Once a decomposition is registered, you listen to its events with the standard @event() decorator. There’s nothing special about custom events from the listener’s perspective:

@event({name: 'BannedLinkDetected', event: 'messageBannedLinkAdded'})
export default class BannedLinkDetectedEvent implements Event<'messageBannedLinkAdded'> {
constructor(private modLog: ModLogService) {}
public async execute(message: Message): Promise<void> {
await message.delete();
await message.channel.send({embeds: [
Embed.error(`${message.author.username}, editing messages to add prohibited links is not allowed.`)
]});
await this.modLog.send(
Embed.error(`${message.author.username} edited a message to include a banned link`)
.addFields(
{name: 'Content', value: message.content.slice(0, 1024)},
{name: 'Channel', value: `<#${message.channelId}>`},
)
.setTimestamp(),
);
}
}

You may register multiple listeners for the same custom event, add middleware, and use all the same patterns from the Events guide. Custom events are first-class citizens in the event system.

Decomposition classes have full DI support. The @decompose() decorator registers the class for injection automatically — no @injectable() needed:

@decompose({source: 'messageUpdate', emit: 'messageBannedLinkAdded'})
export default class BannedLinkAddedDecomposer {
constructor(
private filterService: FilterService,
private config: Config,
) {}
public test(oldMessage: Message, newMessage: Message): boolean {
if (!this.config.get('moderation.filterEnabled')) return false;
// ...
}
}

This means your test() logic can use services, configuration, and anything else in your DI container.

The classic use case: a user posts a clean message, waits for the auto-mod to approve it, then edits in a banned link. The built-in messageContentEdit event tells you the message was edited, but not why it matters. This decomposition adds the domain-specific detection:

@decompose({source: 'messageUpdate', emit: 'messageBannedLinkAdded'})
export default class BannedLinkAddedDecomposer {
constructor(private filterService: FilterService) {}
public test(oldMessage: Message, newMessage: Message): boolean {
if (!newMessage.content) return false;
if (newMessage.author?.bot) 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];
}
}
@event({name: 'DeleteBannedLink', event: 'messageBannedLinkAdded'})
export default class DeleteBannedLinkEvent implements Event<'messageBannedLinkAdded'> {
constructor(private caseService: CaseService, private modLog: ModLogService) {}
public async execute(message: Message): Promise<void> {
await message.delete();
const caseNumber = await this.caseService.createAutoModAction(
message.guildId!, message.author.id, 'Edited message to include banned link',
);
await this.modLog.send(
Embed.error(`Auto-mod: banned link detected in edited message`)
.addFields(
{name: 'User', value: `<@${message.author.id}>`, inline: true},
{name: 'Channel', value: `<#${message.channelId}>`, inline: true},
{name: 'Case', value: `#${caseNumber}`, inline: true},
{name: 'Content', value: message.content.slice(0, 1024)},
)
.setTimestamp(),
);
}
}

Detect when a member’s warning count crosses a threshold — for example, to auto-escalate to a mute after 5 warnings. This watches the guildMemberUpdate event and checks the warning count in the database:

@decompose({source: 'guildMemberUpdate', emit: 'warningThresholdExceeded'})
export default class WarningThresholdDecomposer {
constructor(
private caseService: CaseService,
private config: Config,
) {}
// Cache of recently checked members to avoid querying on every update
private recentlyChecked = new Set<string>();
public test(oldMember: GuildMember, newMember: GuildMember): boolean {
// Avoid checking the same member repeatedly in quick succession
const key = `${newMember.guild.id}:${newMember.id}`;
if (this.recentlyChecked.has(key)) return false;
const count = this.caseService.getCachedWarningCount(newMember.guild.id, newMember.id);
const threshold = this.config.get('moderation.warningThreshold', 5) as number;
if (count >= threshold) {
this.recentlyChecked.add(key);
setTimeout(() => this.recentlyChecked.delete(key), 60_000);
return true;
}
return false;
}
public extract(oldMember: GuildMember, newMember: GuildMember): [GuildMember, number] {
const count = this.caseService.getCachedWarningCount(newMember.guild.id, newMember.id);
return [newMember, count];
}
}
@event({name: 'AutoMuteOnThreshold', event: 'warningThresholdExceeded'})
export default class AutoMuteOnThresholdEvent implements Event<'warningThresholdExceeded'> {
constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, warningCount: number): Promise<void> {
// Auto-mute for 1 hour
await member.timeout(60 * 60 * 1000, `Auto-mute: ${warningCount} warnings`);
await this.modLog.send(
Embed.error(`Auto-mute: ${member.user.username} reached ${warningCount} warnings`)
.addFields(
{name: 'User', value: `<@${member.id}>`, inline: true},
{name: 'Warnings', value: String(warningCount), inline: true},
{name: 'Action', value: 'Muted for 1 hour', inline: true},
)
.setTimestamp(),
);
}
}

Detect when a member changes their nickname back to something they were previously warned about — useful for servers that enforce display name rules:

@decompose({source: 'guildMemberNicknameUpdate', emit: 'bannedNicknameDetected'})
export default class BannedNicknameDecomposer {
constructor(private filterService: FilterService) {}
public test(member: GuildMember, oldNickname: string | null, newNickname: string | null): boolean {
if (!newNickname) return false;
return this.filterService.isProhibitedNickname(newNickname);
}
public extract(member: GuildMember, oldNickname: string | null, newNickname: string | null): [GuildMember, string] {
return [member, newNickname!];
}
}

Notice that this decomposition chains on guildMemberNicknameUpdate — a built-in decomposed event, not the raw guildMemberUpdate. The built-in decomposition handles the “did the nickname change?” logic, and this custom decomposition adds the “is the new nickname banned?” check on top. Each layer does one thing.

Detect when a member has roles added and removed rapidly — a potential sign of a compromised bot or admin account:

@decompose({source: 'guildMemberUpdate', emit: 'suspiciousRoleActivity'})
export default class SuspiciousRoleActivityDecomposer {
private roleChanges = new Map<string, {count: number; firstSeen: number}>();
private readonly threshold = 5;
private readonly windowMs = 10_000; // 10 seconds
public test(oldMember: GuildMember, newMember: GuildMember): boolean {
const rolesChanged = oldMember.roles.cache.size !== newMember.roles.cache.size;
if (!rolesChanged) return false;
const key = `${newMember.guild.id}:${newMember.id}`;
const now = Date.now();
const entry = this.roleChanges.get(key);
if (!entry || now - entry.firstSeen > this.windowMs) {
this.roleChanges.set(key, {count: 1, firstSeen: now});
return false;
}
entry.count++;
return entry.count >= this.threshold;
}
public extract(oldMember: GuildMember, newMember: GuildMember): [GuildMember, number] {
const key = `${newMember.guild.id}:${newMember.id}`;
const entry = this.roleChanges.get(key)!;
this.roleChanges.delete(key); // reset after emitting
return [newMember, entry.count];
}
}
@event({name: 'RoleActivityAlert', event: 'suspiciousRoleActivity'})
export default class RoleActivityAlertEvent implements Event<'suspiciousRoleActivity'> {
constructor(private modLog: ModLogService) {}
public async execute(member: GuildMember, changeCount: number): Promise<void> {
await this.modLog.send(
Embed.error(`Suspicious role activity detected`)
.addFields(
{name: 'User', value: `<@${member.id}>`, inline: true},
{name: 'Changes', value: `${changeCount} in 10 seconds`, inline: true},
)
.setTimestamp(),
);
}
}

This pattern — tracking frequency in memory and emitting only when a threshold is crossed — is useful for any “something is happening too fast” detection. The decomposer encapsulates the windowed counting logic, and the listener just handles the alert.