Gateway events
Handle any discord.js client event, such as a member joining, with `@On` and `@Once`.
You'll learn
- Handle a client event every time, or once
- Run guards and filters on an event
- Find the intent an event needs, and test the handler
Before this
@On('guildMemberAdd') runs a method every time discord.js emits that event, and @Once the first time only.
The handler's parameters are typed from discord.js's ClientEvents, so guildMemberAdd gives a GuildMember.
When to use it
Use an event handler for what happens in Discord without a command: a member joins, the bot is added to a server, a message is edited or deleted, a role changes.
For commands and components, use their decorators instead: @Command, @MessageHandler and
@ReactionHandler route by name, pattern or emoji. For work when the bot starts and stops, use
lifecycle hooks rather than @Once('clientReady').
Example
import { type Client, type GuildMember } from 'discord.js'
import { Controller, On, Once } from 'meocord/decorator'
@Controller()
export class WelcomeController {
// Every time a member joins; needs the GuildMembers intent
@On('guildMemberAdd')
async greet(member: GuildMember) {
await member.send(`Welcome to ${member.guild.name}!`)
}
// The first time the client is ready, and never again
@Once('clientReady')
async warmCache(client: Client<true>) {
await client.guilds.fetch()
}
}event guildMemberAddOpen in playgroundHow it works
- Where. On any controller or service the app binds: listed in
@MeoCord({ controllers, services }), or injected by one. The handler runs on the class's one instance, the same one the rest of the app injects.@Command,@MessageHandler,@ReactionHandlerand@Autocompleterun only on a controller incontrollers. - Pipeline. An event runs through the same pipeline as a command. Guards and
interceptors on the method or class apply, and the app's global ones. A guard receives the event's
arguments, and
ExecutionContext.getType()is'event'. - Alongside dispatch.
@On('interactionCreate')and@On('messageCreate')run beside MeoCord's own dispatch of those events, not in place of it.
Guards on events
A global guard written for interactions should skip events. Declare which calls it takes:
import { Guard } from 'meocord/decorator'
import { type GuardInterface } from 'meocord/interface'
/** Refuses commands while MAINTENANCE=on. As a global guard it takes interactions only, so events still run. */
@Guard({ types: ['interaction'] })
export class MaintenanceGuard implements GuardInterface {
canActivate() {
return process.env.MAINTENANCE !== 'on'
}
}At startup, in an app with @On or @Once handlers, MeoCord names each global guard or interceptor without
types, since it will also run on events.
Errors
An error a handler throws goes to its exception filters, as a command's does. One no filter handles is logged with the event and the handler's name. It never stops the bot, or the other handlers of that event.
A UserError is answered where there is someone to answer. For an event with a
message, such as messageCreate, its message is sent as a reply to that message, or to the edited one for
messageUpdate. Other events have no one to answer, so nothing is sent.
Intents
Most events need an intent: guildMemberAdd needs GuildMembers, which is privileged. At startup MeoCord warns
once for each intent or partial a handler needs that clientOptions lacks.
A privileged intent is also enabled in the Discord developer portal, under Bot, then Privileged Gateway
Intents. If Discord refuses one at login, the bot names the privileged intents it
requests and where to enable them, and app.start() rejects with an error
isExplainedError(error) recognises, so the generated main.ts doesn't log it twice.
Testing
module.emit(event, ...args) sends an event to the module's handlers through the same pipeline, and resolves to
{ ran }, how many handlers ran:
describe('WelcomeController', () => {
const module = MeoCordTestingModule.create({ controllers: [WelcomeController] }).compile()
it('welcomes a member who joins', async () => {
const guild = createMockGuild()
guild.name = 'Cat Café'
const member = createMockInteraction(GuildMember, { guild })
const { ran } = await module.emit('guildMemberAdd', member)
expect(ran).toBe(1)
expect(member.send).toHaveBeenCalledWith('Welcome to Cat Café!')
})
})emit rejects once every handler has settled if any threw: with that error, or an AggregateError when
several did.
Gotchas
- Nothing runs. Check the startup warnings for a missing intent.
guildMemberAddwithoutGuildMembersis never emitted. - Two classes with one name.
@Oncetells classes apart by name, so the bot refuses to start when two classes share a name and either has a@Oncehandler. Rename one. - A global guard denies every event. It was written for interactions. Give it
types: ['interaction'].
Build it
When the bot joins a server, it tells the server how to leave feedback:
// When the bot joins a server, it says how to leave feedback
@Controller()
export class WelcomeController {
@On('guildCreate')
async welcome(guild: Guild) {
// The message command's own words, which stay as the pattern has them
const example = `\`@${guild.client.user.username} feedback idea Add a dark mode\``
await guild.systemChannel?.send(`Thanks for adding me! Use /feedback, or mention me: ${example}`)
}
}Add WelcomeController to the app's controllers. guildCreate needs only the Guilds intent the bot
already has. systemChannel is null in a server without one, so the bot sends nothing there.
Next steps
- Lifecycle hooks: start and stop work with the bot.
- Guards: decide which calls a guard takes.
- Exception filters: answer an event's errors your own way.