Skip to content
GitHub

Typed params, flags and lists

MeoCord 4.1 Ā· Messages and events Ā· page 15 of 41 Ā· since 4.1.0

Read numbers, members, lengths of time and options from a message command's words, checked before the handler runs.

You'll learn

  • Give a param a type, and receive the value
  • Let a guard look at a member before anything is fetched
  • Take flags and lists, and add a type of the app's own

Before this

A param with a type, {amount:int}, gives the handler a number instead of the word the user typed. A word that is not a value of its type never reaches the handler: the user gets the command's usage, naming the param and what was wrong with it.

When to use it

Type a param whenever the handler needs more than text: an amount, a member to act on, a length of time. The check happens once, before any guard runs, so the handler doesn't parse or validate the word itself.

For a rule about the value, such as "at most 100", use @Validate on the typed value. For words the command doesn't have in a fixed place, such as --bots, use a flag.

Example

controllers/message/economy.message.controller.ts
// !pay @ana 25 for lunch    !pay 123456789012345678 25
@MessageHandler('pay {to:member} {amount:int} {note...?}')
async pay(message: Message, { to, amount, note }: { to: GuildMember; amount: number; note?: string }) {
  await message.reply(`Paid ${to.displayName} ${amount}${note ? ` ${note}` : ''}`)
}

!pay @ana 25 for lunch runs pay with Ana as a GuildMember, 25 as a number and note as the text after it. !pay @ana lots gets amount: "lots" is not a valid whole number in reply.

How it works

A message command's words are read in two steps around the guards:

  1. Parse. Before the guards, each word is read without asking Discord: numbers, words to choose from, flags, and the shape of each mention or ID. A word of the wrong type gets the usage reply.
  2. Fetch. Once the guards let the call through, each member, user and channel the cache lacks is fetched; a role is read from the cache, or found by its name, in the first step. The handler, @Validate, pipes and @Cooldown({ by }) get the entities themselves.

So a caller the guards refuse costs no request to Discord. Each ID is fetched once, however many messages ask for it at the same time, and members go 100 to a request.

Types

TypeGivesAccepts
none, or stringstringA word, or "quoted words"
int, numbernumber50, -3; number also 2.5
boolbooleanyes, no, true, false, on, off
durationnumber, in milliseconds90s, 10m, 2h30m, 7d, 1w
memberGuildMemberA mention or an ID, of a member of the message's server
userUserA mention or an ID
roleRoleA mention, an ID or the role's name
channelGuildBasedChannelA mention or an ID
words, such as on|off'on' | 'off'One of the words, in any case unless caseSensitive is set
your own, from messages: { types }what its parse returnsWhat its parse accepts

The handler's params are checked against the pattern when the code compiles. A name the pattern doesn't have, a type its value doesn't fit, or an optional param declared as always there is an error in the editor. ParamsOf gives the type a pattern produces, for a helper that takes the same params. An untyped param is text that @Validate or a pipe may change, so only whether it is optional is checked.

Several optional params

A pattern may end in several optional params. Each one that another follows takes a word only if the word fits its type, and is skipped otherwise, so the word goes on to the next:

controllers/message/economy.message.controller.ts
// !ban @ana spamming      gives { target, reason: 'spamming' }
// !ban @ana 7d spamming   gives { target, duration: 604_800_000, reason: 'spamming' }
@MessageHandler('ban {target:member} {duration:duration?} {reason...?}')
@UseGuard(OutranksTargetGuard)
async ban(
  message: Message,
  { target, duration, reason }: { target: GuildMember; duration?: number; reason?: string },
) {
  await target.ban({ reason, deleteMessageSeconds: duration ? Math.min(duration / 1000, 604_800) : undefined })
  await message.reply(`Banned ${target.displayName}`)
}

Whether a word fits is read from the word alone, so an optional param that another follows needs a built-in type or words to choose from. Text, or an app's own type, there stops the bot at startup. The last optional takes any word.

Guards see references

Before the fetch, a guard gets each member, user, role and channel as an EntityRef: its id, the entity as cached when discord.js already has it, and resolve() to fetch it. ParamRefsOf types a guard's params from the pattern:

guards/outranks-target.guard.ts
import { type Message } from 'discord.js'
import { GuardDeniedError } from 'meocord/common'
import { Guard } from 'meocord/decorator'
import { type GuardInterface, type ParamRefsOf } from 'meocord/interface'

/** Lets a moderator act only on a member ranked below them. */
@Guard()
export class OutranksTargetGuard implements GuardInterface {
  async canActivate(
    message: Message,
    { target }: ParamRefsOf<'ban {target:member} {duration:duration?} {reason...?}'>,
  ) {
    // The cheap check first, so a caller without the permission costs no request, and gets no reply
    if (!message.member?.permissions.has('BanMembers')) return false
    const member = target.cached ?? (await target.resolve())
    if (member && member.roles.highest.position >= message.member.roles.highest.position) {
      throw new GuardDeniedError('You can only ban members ranked below you.')
    }
    return true
  }
}

Put the cheap checks first. A caller without the permission is refused here without a single request, and resolve() is only called for a caller who might pass. Whatever resolve() fetched is reused for the handler.

Flags

A flag, {--name}, may be given anywhere after the command's word. Without a type it is true when given and false when not. With one, {--name:type}, it takes a value, --name=value, and is required unless it ends in ?:

controllers/message/moderation.message.controller.ts
// !purge 50 --bots    !purge --from=@ana 20
@MessageHandler('purge {count:int} {--bots} {--from:user?}')
async purge(message: Message, { count, bots, from }: { count: number; bots: boolean; from?: User }) {
  if (!message.channel.isTextBased() || message.channel.isDMBased()) return
  const recent = await message.channel.messages.fetch({ limit: 100 })
  const picked = recent
    .filter(sent => (!bots || sent.author.bot) && (!from || sent.author.id === from.id))
    .first(count)
  await message.channel.bulkDelete(picked, true)
}
  • A value with spaces goes in quotes: --note="buy milk". A flag given twice takes its last value. An untyped flag also takes yes, no, on, off, true or false, so --bots=no gives false.
  • A flag the command doesn't have gets the usage reply: --all is not an option of this command. So does a typed flag left out, --from is missing, or given no value, --from needs a value, such as --from=<from>.
  • A flag before the command's first word, as in !--bots purge 5, isn't read, and the message names no command. A pattern that begins with a param has no command word, so its flags may come anywhere.
  • Words in quotes are never flags, so "--bots" stays text. A command with no flags reads --bots as an ordinary word. A rest takes the message's text without its flags, keeping its own spacing and line breaks.
  • A flag's name starts with a letter, then letters, digits or _. {--9lives} stops the bot as it loads, since a message couldn't give it; see Errors at startup.

Lists

A typed rest, {name:type...}, is a list: each word, or "quoted words", becomes a value of the type:

controllers/message/moderation.message.controller.ts
// !poll "Lunch today?" pizza "fried rice" soup
@MessageHandler('poll {question} {options:string...}')
async poll(message: Message, { question, options }: { question: string; options: string[] }) {
  await message.reply([question, ...options.map((option, i) => `${i + 1}. ${option}`)].join('\n'))
}

// !kick @ana @ben 123456789012345678
@MessageHandler('kick {targets:member...}', { scope: 'guild' })
async kick(message: Message, { targets }: { targets: GuildMember[] }) {
  await Promise.all(targets.map(target => target.kick()))
  await message.reply(`Kicked ${targets.length}`)
}
Dispatches message poll Lunch? pizza soup; message kick <@140000000000000014> <@140000000000000015>

The members a list names are fetched together, in one request. {name...} with no type stays the rest of the message as text, with its own spacing and line breaks.

A type of your own

An app adds types in @MeoCord({ messages: { types } }). A type has a label, the noun the usage reply names it by, and a parse that returns the value, or undefined for a word that isn't one. Declare what it gives in MessageParamTypes so handlers using it are typed:

message-types.ts
import { type MessageParamType } from 'meocord/interface'

/** `#5865F2` as a number, for a pattern's `{accent:color}`. */
export const color: MessageParamType<number> = {
  label: 'hex colour',
  parse: word => (/^#[0-9a-f]{6}$/i.test(word) ? parseInt(word.slice(1), 16) : undefined),
}

// What each of the app's own types gives, so a handler using it is typed
declare module 'meocord/interface' {
  interface MessageParamTypes {
    color: number
  }
}

Then {accent:color} in a pattern gives the handler a number, and a word such as blue gets accent: "blue" is not a valid hex colour in reply.

Testing

module.dispatch(message) reads params as the bot does. Members the message's server caches resolve without a request, so build the guild with them:

controllers/message/economy.message.controller.spec.ts
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.')
  })
})

Gotchas

  • A member param in a DM. A command with a member, role or channel param works in a server only, and a DM is answered that way. The help listing says so too, with no scope needed.
  • A member who left. A member param for someone not in the server is answered only to a caller the guards let through, so a refused caller learns nothing about the server.
  • {amount:int} and @Validate. The type runs first. A schema that expects the word as a string gets a number.

Build it

Members ask the bot where their feedback stands: @Feedback status 3 says whether feedback #3 is open, approved or rejected, and --details quotes what it said.

tutorial/feedback.message.controller.ts
// @Feedback status 3    @Feedback status 3 --details
@MessageHandler('status {id:int} {--details}', {
  description: 'Says where a piece of feedback stands.',
  scope: 'guild',
})
async status(message: Message<true>, { id, details }: { id: number; details: boolean }) {
  try {
    const feedback = this.feedback.get(String(id))
    const said = `Feedback #${feedback.id} is ${feedback.status}.`
    await message.reply(details ? `${said}\n> ${feedback.details}` : said)
  } catch (error) {
    if (!(error instanceof FeedbackNotFoundError)) throw error
    await message.reply(`There is no feedback #${id}.`)
  }
}

{id:int} gives the handler a number, so @Feedback status lots is answered with the usage and "lots" is not a valid whole number. {--details} is true only when the message gives it. Feedback that doesn't exist gets its own answer, from the same FeedbackNotFoundError the review buttons meet.

Next steps