Skip to content
GitHub

A role picker

MeoCord 4.1 ยท since 4.1.0

A private menu of the roles members may give themselves, which updates their roles to match what they pick.

/roles shows each member a private menu of the roles they may give themselves, with the ones they already have selected. Picking updates their roles to match. It takes a string select menu, whose choices arrive in the handler's params, and a check that keeps the menu to the roles it offers.

The code

recipes/select-menus/roles.ts
// The roles members may give themselves; the bot's role must sit above them
export const SELF_ROLES = [
  { id: '100000000000000001', label: 'Announcements' },
  { id: '100000000000000002', label: 'Events' },
  { id: '100000000000000003', label: 'Game nights' },
]

@Controller()
export class RolesController {
  // A private menu for each member, with the roles they already have selected
  @Command('roles', RolesCommandBuilder)
  async show(interaction: ChatInputCommandInteraction) {
    if (!interaction.inCachedGuild()) return
    const has = (id: string) => interaction.member.roles.cache.has(id)
    const menu = new StringSelectMenuBuilder()
      .setCustomId('roles/pick')
      .setPlaceholder('Pick your roles')
      .setMinValues(0)
      .setMaxValues(SELF_ROLES.length)
      .addOptions(SELF_ROLES.map(role => ({ label: role.label, value: role.id, default: has(role.id) })))
    await respond(interaction).send({
      components: [new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(menu)],
      flags: MessageFlags.Ephemeral,
    })
  }

  // The choice arrives as `values`, and is the whole set: roles picked are added, listed roles left out removed
  @Command('roles/pick', CommandType.SELECT_MENU)
  async pick(interaction: StringSelectMenuInteraction, { values }: { values: string[] }) {
    if (!interaction.inCachedGuild()) return
    // Only the listed roles, whatever values the client sent
    const picked = new Set(values.filter(value => SELF_ROLES.some(role => role.id === value)))
    const roles = interaction.member.roles
    const add = SELF_ROLES.filter(role => picked.has(role.id) && !roles.cache.has(role.id)).map(role => role.id)
    const remove = SELF_ROLES.filter(role => !picked.has(role.id) && roles.cache.has(role.id)).map(role => role.id)
    if (add.length) await roles.add(add)
    if (remove.length) await roles.remove(remove)
    const names = SELF_ROLES.filter(role => picked.has(role.id)).map(role => role.label)
    await respond(interaction).send({
      content: names.length ? `Your roles: ${names.join(', ')}.` : 'No roles picked.',
      components: [],
    })
  }
}

How it works

  • The choice as a param. A select menu's handler receives the chosen options' values as values, beside any params its customId captures. interaction.values holds the same list. See Select menus.
  • The whole set. The menu shows the member's roles as selected, so a submission is their full choice: roles picked are added, and roles on the list left out are removed.
  • Only what was offered. A client can send any value, so the handler keeps only ids on the list. Without the check, a forged submission could hand out any role the bot can manage.
  • Updating in place. The menu is the private reply's own component, so send() updates that reply with the result and removes the menu.
  • Permissions. The bot needs Manage Roles, and its highest role must be above every role on the list.

Testing it

The spec submits an id the menu never offered, and checks that it's ignored:

recipes/select-menus/roles.spec.ts
describe('RolesController', () => {
  const module = MeoCordTestingModule.create({ controllers: [RolesController] }).compile()
  const [announcements, events, gameNights] = SELF_ROLES.map(role => role.id)

  // A member in a server, holding the given roles
  function memberWith(...ids: string[]) {
    const roles = { cache: new Collection(ids.map(id => [id, { id }])), add: createMockFn(), remove: createMockFn() }
    const member = createMockInteraction(GuildMember, { roles: roles as never })
    return { member, roles, inServer: { guildId: '1', guild: createMockGuild(), member } }
  }

  it('shows each member their own menu, with the roles they have selected', async () => {
    const { inServer } = memberWith(events)
    const interaction = createMockInteraction(ChatInputCommandInteraction, inServer)

    await module.invoke(RolesController, 'show', interaction)

    const payload = JSON.parse(JSON.stringify(getResponse(interaction).calls[0].payload))
    const options = payload.components[0].components[0].options as { value: string; default: boolean }[]
    expect(options.filter(option => option.default).map(option => option.value)).toEqual([events])
    expect(interaction.ephemeral).toBe(true)
  })

  it('adds what was picked, removes what was left out, and ignores values it did not offer', async () => {
    // invoke() passes the mock's values to the handler as its `values` param
    const { roles, inServer } = memberWith(events)
    const interaction = createMockInteraction(StringSelectMenuInteraction, {
      customId: 'roles/pick',
      values: [announcements, gameNights, '999999999999999999'],
      ...inServer,
    })

    await module.invoke(RolesController, 'pick', interaction)

    expect(roles.add).toHaveBeenCalledWith([announcements, gameNights])
    expect(roles.remove).toHaveBeenCalledWith([events])
    expect(getResponse(interaction).calls[0].payload).toMatchObject({
      content: 'Your roles: Announcements, Game nights.',
    })
  })
})

Variations

Other menus

The same routing serves the four entity select menus, each with its own CommandType. A user select hands the handler users and members beside values, a role select roles, a channel select channels, and a mentionable select users, members and roles. Check what they send, as the handler here checks a string menu's values, before acting on it.

Next steps