Skip to content
GitHub

A moderation command

MeoCord 4.2 · since 4.1.0

A /timeout command that asks the moderator to confirm, logs what it did, and explains a missing permission.

/timeout asks the moderator to confirm before it acts, logs what it did, and says so plainly when Discord refuses for a missing permission. It takes a builder with default permissions, a proposal held in a service, buttons only the moderator can press, and an exception filter.

The code

The builder asks Discord to show the command only to members who can time others out, and only in servers:

recipes/moderation/timeout.ts
@CommandBuilder(CommandType.SLASH)
export class TimeoutCommandBuilder {
  build(commandName: string) {
    return (
      new SlashCommandBuilder()
        .setName(commandName)
        .setDescription('Time a member out')
        // Discord shows the command only to members who can time others out, and only in servers
        .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers)
        .setContexts(InteractionContextType.Guild)
        .addUserOption(option => option.setName('member').setDescription('Who').setRequired(true))
        .addIntegerOption(option =>
          option.setName('minutes').setDescription('How long').setRequired(true).setMinValue(1).setMaxValue(10080),
        )
        .addStringOption(option => option.setName('reason').setDescription('Why').setMaxLength(200))
    )
  }
}

A reason can be longer than a customId holds, so the proposal waits in a service, and the buttons carry its id. take() removes it, so a double click, or a click after Cancel, does nothing:

recipes/moderation/timeout.ts
export interface Timeout {
  moderatorId: string
  targetId: string
  minutes: number
  reason: string
}

// Timeouts waiting for their moderator's answer, and the log of those carried out
@Service()
export class ModerationService {
  private readonly pending = new Map<number, Timeout>()
  readonly log: (Timeout & { at: Date })[] = []

  propose(timeout: Timeout): number {
    // Random, so a button from before a restart almost certainly matches no proposal made after it
    let id = randomInt(2 ** 48 - 1)
    while (this.pending.has(id)) id = randomInt(2 ** 48 - 1)
    this.pending.set(id, timeout)
    return id
  }

  /** The proposal, removed so it runs once; undefined when it was already answered. */
  take(id: number): Timeout | undefined {
    const timeout = this.pending.get(id)
    this.pending.delete(id)
    return timeout
  }

  record(timeout: Timeout): void {
    this.log.push({ ...timeout, at: new Date() })
  }
}

A guard lets only the moderator the button names press it:

recipes/moderation/timeout.ts
// Only the member whose id the button carries may press it
@Guard()
export class OwnerGuard implements GuardInterface {
  canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
    if (interaction.user.id !== ownerId) throw new GuardDeniedError('Only the member who opened this can use it.')
    return true
  }
}

The command proposes, privately, and one handler takes both buttons:

recipes/moderation/timeout.ts
// The moderator, the proposal and the answer, such as `timeout/111/1/confirm`
export const timeoutAnswer = route('timeout/{ownerId:snowflake}/{id:int}/{action:confirm|cancel}')

@Controller()
@UseFilter(MissingPermissionsFilter)
export class TimeoutController {
  constructor(private readonly moderation: ModerationService) {}

  // Asks the moderator to confirm, privately; the proposal waits in the service, not in the customId
  @Command('timeout', TimeoutCommandBuilder)
  async propose(
    interaction: ChatInputCommandInteraction,
    { member, minutes, reason }: { member: User; minutes: number; reason?: string },
  ) {
    const ownerId = interaction.user.id
    const id = this.moderation.propose({
      moderatorId: ownerId,
      targetId: member.id,
      minutes,
      reason: reason ?? 'No reason given',
    })
    const button = (action: 'confirm' | 'cancel', label: string, style: ButtonStyle) =>
      new ButtonBuilder().setCustomId(timeoutAnswer.build({ ownerId, id, action })).setLabel(label).setStyle(style)
    await respond(interaction).send({
      content: `Time out ${member} for ${minutes} minutes?`,
      components: [
        new ActionRowBuilder<ButtonBuilder>().addComponents(
          button('confirm', 'Time out', ButtonStyle.Danger),
          button('cancel', 'Cancel', ButtonStyle.Secondary),
        ),
      ],
      flags: MessageFlags.Ephemeral,
    })
  }

  @Command(timeoutAnswer, CommandType.BUTTON)
  @UseGuard(OwnerGuard)
  async answer(
    interaction: ButtonInteraction,
    { id, action }: { ownerId: string; id: number; action: 'confirm' | 'cancel' },
  ) {
    const timeout = this.moderation.take(id)
    if (!timeout || !interaction.inCachedGuild()) {
      await respond(interaction).send({ content: 'This has already been handled.', components: [] })
      return
    }
    if (action === 'cancel') {
      await respond(interaction).send({ content: 'Cancelled.', components: [] })
      return
    }
    const member = await interaction.guild.members.fetch(timeout.targetId)
    // Discord's refusal reaches MissingPermissionsFilter
    await member.timeout(timeout.minutes * 60_000, timeout.reason)
    this.moderation.record(timeout)
    await respond(interaction).send({ content: `Timed out ${member} for ${timeout.minutes} minutes.`, components: [] })
  }
}

A filter on the controller turns Discord's refusal into a message the moderator can act on:

recipes/moderation/timeout.ts
@Catch(DiscordAPIError)
export class MissingPermissionsFilter implements ExceptionFilter<DiscordAPIError> {
  async catch(error: DiscordAPIError, context: ExecutionContext) {
    // The bot's role lacks the permission, or sits below the member's highest role
    const message =
      error.code === RESTJSONErrorCodes.MissingPermissions
        ? 'I can’t do that: my role needs the permission, and must be above the member’s highest role.'
        : undefined
    await context.response?.error(error, { message, visibility: 'private' })
  }
}

How it works

  • Default permissions are a first line only. A server's admins can change who sees a command, so the handler acts through the bot's own permissions, and Discord checks those.
  • One route, two answers. {action:confirm|cancel} takes one of the two words, and the handler receives it typed as 'confirm' | 'cancel'. {id:int} gives it the proposal's id as a number. See Typed params.
  • Once only. The first click takes the proposal, so a second click on either button answers that it was already handled, and times no one out.
  • Only the moderator. The proposal is private, so only the moderator sees its buttons. The guard checks anyway, reading ownerId from the route's params and throwing GuardDeniedError, so any other click is refused privately.
  • When Discord refuses. Discord answers MissingPermissions, 50013, when the bot's role lacks Moderate Members, or sits below the member's highest role. member.timeout() rejects, and the filter answers that in words the moderator can act on, privately. Any other Discord error gets the default message, privately, and isn't logged, since the filter handled it; log it in the filter to keep it.

Testing it

The spec confirms, cancels, and makes timeout() reject as Discord would:

recipes/moderation/timeout.spec.ts
describe('TimeoutController', () => {
  let module: ReturnType<typeof compile>
  const compile = () => MeoCordTestingModule.create({ controllers: [TimeoutController] }).compile()
  beforeEach(() => (module = compile()))

  const moderator = createMockInteraction(User, { id: '111111111111111111' })
  const target = createMockInteraction(User, { id: '999' })

  // A click in a server whose member fetch resolves the target, whose timeout() the test controls
  const click = (
    action: 'confirm' | 'cancel',
    id: number,
    member = createMockInteraction(GuildMember, { id: '999' }),
  ) => {
    const guild = createMockGuild()
    guild.members.fetch.mockResolvedValue(member as never)
    const customId = timeoutAnswer.build({ ownerId: '111111111111111111', id, action })
    const interaction = createMockInteraction(ButtonInteraction, { customId, user: moderator, guildId: '1', guild })
    return { interaction, member }
  }

  async function propose() {
    const interaction = createMockInteraction(ChatInputCommandInteraction, {
      user: moderator,
      options: createChatInputOptions({ member: target, minutes: 10, reason: 'Spam' }),
    })
    await module.invoke(TimeoutController, 'propose', interaction)
    const payload = JSON.parse(JSON.stringify(getResponse(interaction).calls[0].payload))
    const customIds: string[] = payload.components[0].components.map(
      (button: { custom_id: string }) => button.custom_id,
    )
    // The proposal's id, the third segment of its buttons' customIds
    return { interaction, customIds, id: Number(customIds[0].split('/')[2]) }
  }

  it('asks the moderator to confirm, privately, with buttons naming the proposal', async () => {
    const { interaction, customIds, id } = await propose()

    expect(customIds).toEqual([`timeout/111111111111111111/${id}/confirm`, `timeout/111111111111111111/${id}/cancel`])
    expect(interaction.ephemeral).toBe(true)
  })

  it('times the member out on confirmation, once, and logs it', async () => {
    const { id } = await propose()
    const { interaction, member } = click('confirm', id)

    await module.invoke(TimeoutController, 'answer', interaction)

    expect(member.timeout).toHaveBeenCalledWith(600_000, 'Spam')
    expect(module.get(ModerationService).log).toMatchObject([{ targetId: '999', minutes: 10, reason: 'Spam' }])

    const again = click('confirm', id)
    await module.invoke(TimeoutController, 'answer', again.interaction)
    expect(again.member.timeout).not.toHaveBeenCalled()
  })

  it('does nothing to the member on Cancel', async () => {
    const { id } = await propose()
    const { interaction, member } = click('cancel', id)

    await module.invoke(TimeoutController, 'answer', interaction)

    expect(member.timeout).not.toHaveBeenCalled()
    expect(getResponse(interaction).calls[0].payload).toMatchObject({ content: 'Cancelled.' })
  })

  it('tells the moderator when Discord refuses for missing permissions', async () => {
    const { id } = await propose()
    const { interaction, member } = click('confirm', id)
    member.timeout.mockRejectedValue(createDiscordError(RESTJSONErrorCodes.MissingPermissions))

    const { error } = await module.invoke(TimeoutController, 'answer', interaction)

    expect(error).toMatchObject({ code: RESTJSONErrorCodes.MissingPermissions })
    expect(getResponse(interaction).sent).toBe(true)
    expect(module.get(ModerationService).log).toEqual([])
  })
})

Variations

Roles as well as permissions

To require a role too, apply the RequireRoles decorator built in Guards to the command. Pass role IDs, not names: the guard checks the member's roles by ID. Read it from the environment, such as @RequireRoles(process.env.MODERATOR_ROLE_ID ?? ''): the same MODERATOR_ROLE_ID the Guards page uses, so you set it once. An unset ID denies every call.

A lasting log

Keep record() in a database, as in A database, and post each entry to a log channel.

Restarts

Proposals live in memory, so a restart forgets them, and their buttons then answer that the action was already handled. Their ids are random, so a button from before a restart almost certainly matches no proposal made after it. Keep proposals in a database to survive one.

Expiring proposals

Remove proposals older than a few minutes, from a timer started in onReady and cleared in onShutdown. See Scheduled tasks.

Next steps