Message commands
Run a handler when a message matches a pattern, such as `!roll 20`, after the app's prefix or a mention.
You'll learn
- Write a pattern and receive its params
- Set prefixes for the app, a handler, or each server
- Know which handler runs, and what a user is told on a misuse, an error or a cooldown
- Give a command aliases, a scope and a help listing
Before this
A message command is a @MessageHandler with a pattern. A user types !roll 20 in a channel, and the handler
runs with { sides: '20' }. Patterns, prefixes and which handler wins are set in decorators and checked when
the bot starts, so a mistake stops the bot before it logs in rather than surfacing in chat.
When to use it
Use a message command for text a user types in chat: a quick !roll, a moderation command staff type from
habit, or a bot that has always been prefix-driven. Slash commands are usually the better choice for anything
new: Discord shows their options, checks their types and works in every client, so reach for
slash commands unless people will type the command.
For a handler that runs on every message, such as logging or auto-moderation, use @MessageHandler() with no
pattern, covered in Reactions and other messages. Reading a message's text needs the
privileged MessageContent intent, unless every command starts with a mention of the bot or works in direct
messages only.
Example
// !roll 20 for initiative gives { sides: 20, note: 'for initiative' }
@MessageHandler('roll {sides} {note...?}')
@Validate(z.object({ sides: z.coerce.number().int().min(2).max(100), note: z.string().optional() }))
async roll(message: Message, { sides, note }: { sides: number; note?: string }) {
const result = 1 + Math.floor(Math.random() * sides)
await message.reply(note ? `${result} (${note})` : String(result))
}With the app's prefix set to !, !roll 20 for initiative runs roll with sides and note. @Validate
turns sides into a number, and a message such as !roll 1 is answered with the reason, so the handler only
sees a valid roll.
How it works
A message goes through three steps before the handler runs:
- Start. The message must begin with the command's start: a prefix, a mention of the bot when
mentionis on, or nothing at all for an app with no prefix or a handler withprefix: false. A message without one is chat, and no command handles it. - Match. The rest is split into words, and every pattern is matched against them. Only one patterned handler runs: the most specific match, as Which handler runs explains.
- Pipeline. The handler's guards, validation and pipes, cooldowns and filters run as they do for a command, with the params as the handler's second argument.
The handler receives the discord.js Message and answers it with message.reply() or
message.channel.send(). respond() is for interactions.
Patterns
A pattern is matched word by word, with the same {name} params as a component's customId:
| In a pattern | Matches |
|---|---|
roll | The word roll, in any case unless caseSensitive is set |
{name} | One word. Words in quotes, "like this" or ālike thisā, count as one, and the quotes are removed |
{name...} | The rest of the message, as typed. Only last |
{name?} | One word, or nothing. Only optional params follow it; {name...?} is the optional rest |
{name:type} | One word, read as a number, a member and so on |
{--name} | A flag, anywhere after the command word |
A pattern without params, such as 'ping', matches exactly that message, whatever the spacing between its
words. A param with no type keeps the case it was typed in and is a string. Typed params, flags and lists
have their own page.
Prefixes
Set the prefix once, for the whole app, in @MeoCord({ messages }):
import { GatewayIntentBits, Partials } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { WelcomeController } from '@src/controllers/event/welcome.controller'
import { DiceMessageController } from '@src/controllers/message/dice.message.controller'
import { KeywordMessageController } from '@src/controllers/message/keyword.message.controller'
import { StarReactionController } from '@src/controllers/reaction/star.reaction.controller'
import { ReminderScheduler } from '@src/services/reminder.scheduler'
@MeoCord({
controllers: [DiceMessageController, KeywordMessageController, StarReactionController, WelcomeController],
services: [ReminderScheduler],
clientOptions: {
intents: [
GatewayIntentBits.Guilds,
// Messages, and their content, for @MessageHandler
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
// Reactions for @ReactionHandler, with the partials for messages sent before the bot started
GatewayIntentBits.GuildMessageReactions,
// guildMemberAdd, for @On in WelcomeController
GatewayIntentBits.GuildMembers,
],
partials: [Partials.Message, Partials.Reaction],
},
// Patterned message handlers match after a !, or a mention of the bot
messages: { prefix: '!', mention: true },
})
export default class App {}prefixis a string, or a list such as['!', '?']. The longest prefix that fits is used, and a space after it is allowed, so! roll 20works too. Without one, a pattern matches the message as it is.mention: truealso accepts a mention of the bot,@Bot roll 20, in place of the prefix.mention: 'only'starts every command in a server with a mention of the bot, never a prefix. A direct message, addressed to the bot already, starts as usual. Discord sends a message's text without the privilegedMessageContentintent when it mentions the bot, and in direct messages, so a mention-only bot needs no such intent.caseSensitive: truematches the prefix, a pattern's literal words, its choice words and its flag names in the case written. Param values keep the case they were typed in, except a choice word, which the handler receives as the pattern writes it.
A handler can set its own prefix, caseSensitive and mention: 'only'. Its prefix replaces the app's, though a
mention still counts when the app takes one. prefix: '' matches the message with no prefix, and prefix: false
matches it exactly as it is, never after a mention:
// ?coin or ??coin, and @Bot coin: its own prefixes replace the app's
@MessageHandler('coin', { prefix: ['?', '??'] })
async coin(message: Message) {
await message.reply(Math.random() < 0.5 ? 'Heads' : 'Tails')
}
// good morning, as typed, in any case: no prefix at all
@MessageHandler('good morning', { prefix: false })
async greet(message: Message) {
await message.react('āļø')
}A prefix for each server
prefix can also be a function of the message, which returns a prefix or a list and may be async. It is
called for each message a handler needs the app's prefix for, so keep it to a lookup from a cache the bot
fills:
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { DiceMessageController } from '@src/controllers/message/dice.message.controller'
// Each server's prefix, as a settings command stores it; a real bot would load it from its database
export const guildPrefixes = new Map<string, string>()
@MeoCord({
controllers: [DiceMessageController],
clientOptions: {
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
},
messages: {
// Read for every message; it may be async, and may return a list
prefix: message => (message.guildId ? guildPrefixes.get(message.guildId) : undefined) ?? '!',
mention: true,
},
})
export default class GuildPrefixApp {}A prefix function that finds no prefix for a message, returning an empty list, undefined or null, lets no prefix
start a command for it; a mention still does when mention is on. Return '' to take the message as it is. A handler
with its own prefix, or prefix: false, runs for a message both it and the function could start, whatever the order
of your controllers; with mention on, one with its own prefix and the same pattern is refused at startup instead,
since both take a mention.
A prefix function that throws goes to the app's global exception filters, then the built-in fallback, and the handlers for every message still run.
Which handler runs
Only one patterned handler runs for a message: the most specific one that matches, across every controller.
- More literal words win:
roll 20beatsroll {sides}, which beats{anything...}. - Then a fixed number of words beats a rest:
roll {a} {b}beatsroll {rest...}. - Then a pattern without an optional param beats one with it, and fewer params beat more.
- Patterns still equal go to the one whose first differing word is literal:
roll {x}beats{verb} 6.
A handler whose scope fits where the message was sent comes first, so help can have a server handler and a
DM handler. The order is fixed at startup: declaration order and file layout never decide it. Two handlers that can
take the same messages stop the bot at startup, naming both: the same pattern behind starts that overlap, such as one
handler's own '!' and another's ['!', '?'], or one's own '!' beside the app's '!':
A.roll: "roll" and "roll" in B.roll match the same messages, so only one of them could ever answer those. Change one pattern, or give one its own prefix.An app's prefix function gives its prefixes only as each message arrives, so beside it a handler is refused only when
it uses the function too, or when both take a mention because the app's mention is on. Then every @MessageHandler()
without a pattern runs, whether or not a pattern matched.
Usage errors
A message that names a command, after a prefix or mention, but does not fit its pattern gets the command's usage in reply, and the handler does not run:
!pay @ana lots -> Usage: !pay <to> <amount> [noteā¦]
amount: "lots" is not a valid whole numberThe reply doesn't ping the user, and is deleted after 10 seconds; deleteUsageRepliesAfter in
@MeoCord({ messages }) sets another number of seconds, and 0 keeps it. A guard that throws
GuardDeniedError, and input @Validate refuses, are answered the same way, with the reason. The texts are in
the server's language where the app's catalog translates them; see
MeoCord's own texts.
These replies are plain text, so a test that checks them keeps passing when the theme's colours change.
@MeoCord({ messages: { replyEmoji: true } }) begins each one with the call's emojis.warning, from the app's
theme, the handler's @UseTheme, or the server's or user's theme from themeFor. That covers a usage error, a
guard's or validation's reason, a UserError's message, whether a command or an @On handler of a message event
threw it, and the direct messages of dmOnError and dmOnCooldown:
ā ļø Usage: !pay <to> <amount> [noteā¦]
amount: "lots" is not a valid whole numberTo draw these replies and direct messages instead, give the app's presenter a messageError
method: it styles each one as an embed, with any files it attaches, such as an image it drew. A presenter without it
leaves them plain text.
The error is a MessageUsageError, carrying usage and issues. It
reaches the handler's exception filters first, so a filter can answer in the app's own words, and
observers see its outcome as 'invalid'.
Naming only a command's first words
A message that names only a command's leading words, such as !config when config set ⦠and config get ā¦
exist, or an unknown subcommand, such as !config reset, gets the usage of each subcommand, one line per
handler:
!config -> Usage:
!config get <key>
!config set <key> <valueā¦>A handler of its own, config or config {key}, still takes such a message. A subcommand with a guard,
and one whose options say hidden: true, is left out of the list, since the list runs only the app's guards and must
not name what a handler's own guard may refuse; named, it still gets its own usage.
Telling the author privately
Two things a message command meets go unanswered in the channel: an error no filter handled, which is logged, and a cooldown's refusal, which is skipped. A reply in the channel can't be private, so the author never learns why nothing happened. Two options send them a direct message instead, both off by default:
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { DailyMessageController } from '@src/controllers/message/daily.message.controller'
@MeoCord({
controllers: [DailyMessageController],
clientOptions: {
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
},
messages: {
prefix: '!',
// DM the author an error no filter handled, naming the command, channel and server
dmOnError: true,
// DM the author how long a cooldown asks them to wait, once per wait
dmOnCooldown: true,
},
})
export default class App {}dmOnErrortells the author the command failed, naming it, the channel and the server, and why, as the fallback would answer: "!leaderboard in #general on Cat Cafe: An error occurred while executing the command." When the cooldown store is down, the reason is "Cooldowns can't be checked right now: try again shortly." The error is still logged.dmOnCooldowntells the author how long to wait, once per wait: retrying before it ends sends nothing more. The notice is counted in the app's cooldown store, so with a shared store it holds across shards.
Only patterned handlers are answered, and only when no exception filter handled the error.
The app above takes commands in servers only. A bot that also takes them in direct messages, with the
DirectMessages intent and discord.js's Partials.Channel, answers one sent there in that conversation. A member
whose direct messages are closed isn't told, and that's logged at debug level. A usage error, a guard's reason and a
UserError are answered in the channel as before. The texts are meocord.dm.error and meocord.dm.cooldown,
translated like MeoCord's own texts.
@Controller()
export class DailyMessageController {
// !daily once a day per member; retrying before then is refused by the cooldown
@MessageHandler('daily')
@Cooldown({ uses: 1, seconds: 24 * 60 * 60 })
async daily(message: Message) {
await message.reply('You claimed 100 coins. Come back tomorrow.')
}
// !leaderboard reads a store that can fail; the error reaches the fallback
@MessageHandler('leaderboard')
async leaderboard(message: Message) {
const top = await fetchTopMembers()
await message.reply(top.join('\n'))
}
}describe('DailyMessageController', () => {
const module = MeoCordTestingModule.fromApp(App).compile()
it('DMs a member the cooldown refuses, once per wait', async () => {
const ana = createMockUser()
const retry = createMockMessage({ author: ana, content: '!daily' })
await module.dispatch(createMockMessage({ author: ana, content: '!daily' }))
await module.dispatch(retry)
await module.dispatch(createMockMessage({ author: ana, content: '!daily' }))
// The retry is answered privately, with the wait; the one after it, in the same wait, is not
expect(ana.send).toHaveBeenCalledTimes(1)
expect(ana.send).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('!daily in') }))
expect(retry.reply).not.toHaveBeenCalled()
})
it('DMs the author an error no filter handled, and says nothing in the channel', async () => {
const message = createMockMessage({ author: createMockUser(), content: '!leaderboard' })
// The testing module still rejects with the error, after the fallback has answered it
await expect(module.dispatch(message)).rejects.toThrow('The leaderboard store is down')
expect(message.author.send).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringMatching(/^!leaderboard in .+: An error occurred while executing the command\.$/),
}),
)
expect(message.reply).not.toHaveBeenCalled()
})
})Aliases, descriptions and scope
A handler's options say more about its command:
@MessageHandler('mute {target:member} {duration:duration?} {reason...?}', {
aliases: ['m', 'shush'],
description: 'Times a member out, for 10 minutes unless told otherwise.',
scope: 'guild',
})
async mute(
message: Message,
{ target, duration, reason }: { target: GuildMember; duration?: number; reason?: string },
) {
await target.timeout(duration ?? 600_000, reason)
await message.reply(`Muted ${target.displayName}`)
}message m <@140000000000000014> 1hOpen in playgroundaliasesare other words for the command, in place of the words the pattern begins with:!m @ana 1hrunsmute. A misuse is answered with the usage as the user typed it.- An alias can be several words, such as
'cfg set'forconfig set {key} {value...}, and is ranked by its own words. descriptionis what the command does, for the help listing.scopeis where the command works:'guild','dm'or'any', the default. A message only an out-of-scope handler matches is answeredThis command works in a server only.orThis command works in direct messages only.A command with amember,roleorchannelparam works in servers only whatever its scope says, andscope: 'dm'with one stops the bot as it loads; see Errors at startup.hidden: trueleaves the command out of the help listing and a parent's list of subcommands.
A help command
help: true in @MeoCord({ messages }) turns on a built-in !help. It lists the message commands the caller
can use where they asked, one line each with its description, and !help <command> shows one, by its words
or an alias:
import { GatewayIntentBits } from 'discord.js'
import { MeoCord } from 'meocord/decorator'
import { EconomyMessageController } from '@src/controllers/message/economy.message.controller'
import { ModerationMessageController } from '@src/controllers/message/moderation.message.controller'
import { color } from '@src/message-types'
@MeoCord({
controllers: [EconomyMessageController, ModerationMessageController],
clientOptions: {
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
},
messages: {
prefix: '!',
mention: true,
// The app's own param types, used in patterns as {name:color}
types: { color },
// How long a usage reply stays, in seconds; 0 keeps it
deleteUsageRepliesAfter: 10,
// A built-in !help, listing the commands a caller can use
help: true,
},
})
export default class App {}!help -> Commands:
!kick <targetsā¦>
!mute <target> [duration] [reasonā¦] ā Times a member out, for 10 minutes unless told otherwise.
!pay <to> <amount> [noteā¦]
!poll <question> <optionsā¦>
!purge <count> [--bots] [--from=<from>]
Type !help <command> for one command's usage.
!help m -> Usage: !mute <target> [duration] [reasonā¦]
Times a member out, for 10 minutes unless told otherwise.
target: member Ā· duration (optional): length of time, such as 10m Ā· reason (optional): text
Also: !m, !shush
Works in servers only.- It answers only after a prefix or a mention.
help: { command: 'commands', aliases: ['h'] }names other words. - It runs the app's
@MeoCord({ guards })first, as a command does, and so does a parent's list of subcommands: a guard that returnsfalseleaves the message unanswered, and one that throwsGuardDeniedErrorgets its reason as the reply. - The list leaves out a command with a guard, on its method or its controller, and one marked
hidden:!banis missing above, sinceOutranksTargetGuarddecides who may use it. Named, either is shown. A command that works only in servers is left out in a DM. !help config, for words with no handler of their own, lists their subcommands.- An app's own
@MessageHandler('help ā¦')always runs instead, and the bot warns at startup that the built-in never answers the word. - With
replyEmoji, the reply begins with the theme'semojis.info. It isn't deleted, since the caller asked for it, and a reply over 2,000 characters is sent as several.
The reply is plain text, in the server's language where the app's catalog has MeoCord's help texts. To write it
another way, such as in an embed, give the app's presenter a messageHelp(help, message)
method. help is what the built-in found: a list, one command, a parent's subcommands, an unknown
name, or empty:
import { EmbedBuilder } from 'discord.js'
import { Service } from 'meocord/decorator'
import {
type MessageHelp,
type MessageHelpEntry,
type PresentedError,
type ResponseContext,
type ResponsePresenter,
} from 'meocord/interface'
/** Each command's usage, then what it does. */
const usages = (entries: MessageHelpEntry[]) =>
entries.map(entry => `\`${entry.usage}\`\n${entry.description ?? ''}`).join('\n\n')
/** Styles the bot's views, and writes the built-in help as embeds. */
@Service()
export class HelpPresenter implements ResponsePresenter {
loading({ theme }: ResponseContext) {
return { text: 'Working on itā¦', emoji: theme.emojis.loading, color: theme.colors.primary }
}
error({ theme }: ResponseContext, { message, tone }: PresentedError) {
return { title: 'Something went wrong', text: message, color: theme.colors[tone] }
}
messageHelp(help: MessageHelp) {
switch (help.kind) {
case 'list':
return {
embeds: [
new EmbedBuilder()
.setTitle('Commands')
.setDescription(usages(help.commands))
.setFooter({ text: `${help.invocation} <command> shows one` }),
],
}
case 'command':
// One embed for each handler the name reaches, with its params and aliases
return {
embeds: help.commands.map(entry =>
new EmbedBuilder()
.setTitle(entry.usage)
.setDescription(entry.description ?? null)
.addFields(
entry.params.map(({ name, label, optional }) => ({
name: optional ? `${name} (optional)` : name,
value: label,
inline: true,
})),
)
.setFooter(entry.aliases.length > 0 ? { text: `Also: ${entry.aliases.join(', ')}` } : null),
),
}
case 'parent':
return { embeds: [new EmbedBuilder().setTitle('Subcommands').setDescription(usages(help.subcommands))] }
case 'unknown':
return `No command is called ${help.query}. ${help.invocation} lists them.`
case 'empty':
return help.reason === 'server-only' ? 'The commands work in servers only.' : 'No commands to show here.'
}
}
}A help command of the app's own gets the same model from
HandlerRegistry.messageHelp(message, query?), so which commands a caller
can reach, and which guards hide, are not worked out again:
import { type Message } from 'discord.js'
import { HandlerRegistry } from 'meocord/core'
import { Controller, MessageHandler } from 'meocord/decorator'
@Controller()
export class HelpMessageController {
constructor(private readonly handlers: HandlerRegistry) {}
// !help lists the commands in a code block; !help mute, or !help m, shows one
@MessageHandler('help {command...?}', { description: 'Lists the commands, or shows one.' })
async help(message: Message, { command }: { command?: string }) {
const help = await this.handlers.messageHelp(message, command)
if (help.kind === 'list') {
await message.reply(['```', ...help.commands.map(entry => entry.usage), '```'].join('\n'))
} else if (help.kind === 'command') {
await message.reply(
help.commands.map(entry => [entry.usage, entry.description].filter(Boolean).join(': ')).join('\n'),
)
} else if (help.kind === 'parent') {
await message.reply(help.subcommands.map(entry => entry.usage).join('\n'))
} else {
await message.reply(help.kind === 'unknown' ? `There is no ${help.query} command.` : 'Nothing to show here.')
}
}
}Errors at startup
The message routes are built as the bot loads, and a mistake in a pattern stops it before it logs in. The report
begins with the handler and its pattern, such as
DiceMessageController.swap: @MessageHandler('swap {a} {a}'):, followed by the problem on the same line and, in a
built app, the source file below it, and the process exits 1:
| Mistake | What follows the handler |
|---|---|
A rest before another word, '{text...} please' | {text...} takes the rest of the message, so it must be last. |
| A required word after an optional param | {name?} is optional, so only optional params may follow it; ⦠|
| Text, optional, before another optional param | {name?} comes before another optional param, so it needs a type ⦠|
A type nothing adds, '{accent:colour}' | {accent:colour} names no type. The types are ⦠|
A name used twice, 'swap {a} {a}' | {a} appears twice; give each param and flag its own name. |
| A flag's name that doesn't start with a letter | {--9lives}: a flag's name starts with a letter, as a message could not give it otherwise. |
Braces inside a word, 'a{b}' | "a{b}" is not a param: a param is a whole word, ⦠|
scope: 'dm' on a command with a member param | scope is 'dm', but {target:member} is found only in a server. |
Two patterns that match the same messages stop the bot too, naming both handlers: ⦠match the same messages, so only
one of them could ever answer those. Change one pattern, or give one its own prefix. They match alike when they take
the same prefix and differ only in param names or types, as 'roll {sides}' and 'roll {count:int}' do, or only in
case, unless both are case-sensitive. An alias and a pattern count the same way.
Testing
resolveRoute(App, { content }) returns the handler a message reaches, with the params its pattern captures, from
decorator metadata alone. A message that starts with a mention of the bot needs the bot's id, as botId, and dm: true
resolves it as a direct message, where only handlers whose scope fits run: one scoped to servers, or with a member,
role or channel param, isn't returned, as dispatch answers such a message with its usage. resolveRoute can't call
a prefix function, so for an app that has one, pass the prefix the message has, as prefix; it throws a TypeError
without one. module.dispatch(message) sends the message through routing and the pipeline as the bot does, usage
replies and the built-in help included:
describe('EconomyMessageController', () => {
const module = MeoCordTestingModule.create({
app: App,
controllers: [EconomyMessageController, ModerationMessageController],
}).compile()
// A server whose member cache holds Ana, so their mention or ID resolves without a request
const ana = { id: ANA, displayName: 'Ana', user: { id: ANA } } as unknown as GuildMember
const inServer = (content: string) => createMockMessage({ content, guild: createMockGuild({ members: [ana] }) })
it('turns each word into its type before the handler runs', async () => {
const message = inServer(`!pay <@${ANA}> 25 for lunch`)
await module.dispatch(message)
expect(replyTo(message)).toBe('Paid Ana 25 for lunch')
})
it("answers a message that names the command but does not fit it with the command's usage", async () => {
const message = inServer(`!pay <@${ANA}> lots`)
await module.dispatch(message)
expect(replyTo(message)).toBe('Usage: !pay <to> <amount> [noteā¦]\namount: "lots" is not a valid whole number')
})
it('answers a member param sent in a DM that the command works in a server only', async () => {
const message = createMockMessage({ content: `!pay ${ANA} 25`, guild: null })
await module.dispatch(message)
expect(replyTo(message)).toBe('This command works in a server only.')
})
})module.invoke(Controller, 'method', message) runs one handler, and first checks that dispatch would give it
the message: !roll 20 for roll {sides}, in an app that also has a roll 20 handler, rejects naming the
handler that runs. invoke calls an app's prefix function with the message, as the bot does:
describe('a prefix read from a function', () => {
afterEach(() => guildPrefixes.clear())
it('is given to resolveRoute, which cannot run the function', () => {
expect(resolveRoute(GuildPrefixApp, { content: '$roll 6', prefix: '$' })?.params).toEqual({ sides: '6' })
expect(() => resolveRoute(GuildPrefixApp, { content: '$roll 6' })).toThrow(TypeError)
})
it('is read by invoke from the message, as the bot reads it', async () => {
guildPrefixes.set('1234', '$')
const module = MeoCordTestingModule.create({ app: GuildPrefixApp, controllers: [DiceMessageController] }).compile()
const message = Object.assign(createMockMessage({ content: '$roll 6' }), { guildId: '1234' })
await expect(module.invoke(DiceMessageController, 'roll', message)).resolves.toEqual({ ran: true })
expect(message.reply).toHaveBeenCalledWith(expect.stringMatching(/^[1-6]$/))
})
})See Invoke and dispatch for when to use each.
Gotchas
- Nothing runs. The bot needs the
GuildMessagesintent. A command started by a prefix or plain text also needsMessageContent, enabled both inclientOptionsand in the Discord developer portal; the bot warns at startup when it is missing. Messages from bots never reach a handler. - A keyword stops working after adding a prefix. The app's prefix applies to every patterned handler, so
'ping'then needs!ping. Give a handler that should match the bare message{ prefix: false }. - Two handlers, one pattern. Patterns that differ only in param names or types, such as
'roll {sides}'and'roll {count:int}', match the same messages and stop the bot at startup. Change one, or give one its own prefix. - An empty pattern runs for every message.
@MessageHandler(''), often a pattern built from a value that is empty, runs as@MessageHandler()does, and logs a warning naming the handler. It is deprecated, and stops the bot in the next major version (5.0). Write@MessageHandler()for a listener.
Build it
The feedback bot takes feedback from chat too. A member mentions the bot, names the kind of feedback, and writes it:
@Feedback feedback idea Add a dark modeAdd a message controller beside the slash command. It files through the same FeedbackService:
// @Feedback feedback idea Add a dark mode
@MessageHandler('feedback {about:bug|idea|praise} {details...}', {
description: 'Files feedback from chat.',
scope: 'guild',
})
async file(message: Message<true>, { about, details }: { about: 'bug' | 'idea' | 'praise'; details: string }) {
const locale = message.guild.preferredLocale
const feedback = this.feedback.add({ authorId: message.author.id, locale, about, details })
await message.reply(`Filed as feedback #${feedback.id}. Thank you!`)
}{about:bug|idea|praise} takes one of three words, and {details...} the rest of the message. scope: 'guild'
keeps it to servers. In the app, add the controller, the GuildMessages intent that delivers messages in
servers, and messages: { mention: 'only' }, so a command starts with a mention of the bot, never a prefix:
@MeoCord({
controllers: [
// The slash command and its form, and the review buttons
FeedbackController,
ReviewController,
FeedbackMessageController,
],
clientOptions: {
intents: [
// Interactions arrive with Guilds alone, and DMs are sent, not read
GatewayIntentBits.Guilds,
// Messages in servers; a mention of the bot carries its text without MessageContent
GatewayIntentBits.GuildMessages,
],
},
// A message command starts with a mention of the bot, never a prefix
messages: { mention: 'only' },
presenter: FeedbackPresenter,
})
export default class App {}A mention carries its text without the privileged MessageContent intent, so the bot doesn't ask for it.
Try @Feedback feedback wish Add a dark mode: wish isn't one of the three words, so the bot answers with the
command's usage.
Next steps
- Typed params, flags and lists: read members, numbers and options from the words.
- Reactions and other messages: handle every message, and reactions to them.
- Guards: decide who may run a command, and tell them why not.