Skip to content
GitHub

A ticket system

MeoCord 4.2 ยท since 4.1.0

A /ticket form that opens a private thread with the member, and a Close button for them or the staff.

/ticket asks for a subject and details in a form, opens a private thread with the member in it, and posts a Close button that the member or the staff can press. It takes a modal, a cooldown, discord.js threads, and a guard with a rule of its own.

The code

The command shows the form, and the form's submission opens the thread. The Close button's id comes from a route that carries who opened the ticket:

recipes/tickets/tickets.ts
// The Close button carries who opened the ticket, such as `ticket/111/close`
export const closeTicket = route('ticket/{ownerId:snowflake}/close')

@Controller()
export class TicketController {
  // One ticket every ten minutes per member, so the command is not a way to flood the staff
  @Command('ticket', TicketCommandBuilder)
  @Cooldown({ uses: 1, seconds: 600 })
  async open(interaction: ChatInputCommandInteraction) {
    const input = (id: string, label: string, style: TextInputStyle) =>
      new LabelBuilder().setLabel(label).setTextInputComponent(
        new TextInputBuilder()
          .setCustomId(id)
          .setStyle(style)
          .setMaxLength(style === TextInputStyle.Short ? 80 : 1000),
      )
    await respond(interaction).modal(
      new ModalBuilder()
        .setCustomId('ticket/create')
        .setTitle('Open a ticket')
        .addLabelComponents(
          input('subject', 'Subject', TextInputStyle.Short),
          input('details', 'What happened?', TextInputStyle.Paragraph),
        ),
    )
  }

  // The form's fields arrive as params; the ticket is a private thread in the channel it was opened from
  @Command('ticket/create', CommandType.MODAL_SUBMIT)
  async create(interaction: ModalSubmitInteraction, { subject, details }: { subject: string; details: string }) {
    if (!(interaction.channel instanceof TextChannel)) {
      await respond(interaction).send({ content: 'Open a ticket from a text channel.', flags: MessageFlags.Ephemeral })
      return
    }
    const thread = await interaction.channel.threads.create({
      name: `ticket-${interaction.user.username}`,
      type: ChannelType.PrivateThread,
      invitable: false,
    })
    await thread.members.add(interaction.user.id)
    const close = new ButtonBuilder()
      .setCustomId(closeTicket.build({ ownerId: interaction.user.id }))
      .setLabel('Close ticket')
      .setStyle(ButtonStyle.Secondary)
    await thread.send({
      content: `**${subject}**\n${details}`,
      components: [new ActionRowBuilder<ButtonBuilder>().addComponents(close)],
    })
    await respond(interaction).send({ content: `Your ticket is open: ${thread}`, flags: MessageFlags.Ephemeral })
  }

  @Command(closeTicket, CommandType.BUTTON)
  @UseGuard(TicketCloserGuard)
  async close(interaction: ButtonInteraction) {
    await respond(interaction).send({ content: `Closed by ${interaction.user}.`, components: [] })
    if (interaction.channel instanceof ThreadChannel) {
      // Locked, then archived: members keep reading it, and no one can post
      await interaction.channel.setLocked(true)
      await interaction.channel.setArchived(true)
    }
  }
}

The guard lets through the member who opened the ticket, and anyone whose permissions include Manage Threads:

recipes/tickets/tickets.ts
// The member who opened the ticket, or staff who can manage threads, may close it
@Guard()
export class TicketCloserGuard implements GuardInterface {
  canActivate(interaction: ButtonInteraction, { ownerId }: { ownerId: string }): boolean {
    if (interaction.user.id === ownerId) return true
    if (interaction.memberPermissions?.has(PermissionFlagsBits.ManageThreads)) return true
    throw new GuardDeniedError('Only the member who opened this ticket, or the staff, can close it.')
  }
}

How it works

  • The form. respond(interaction).modal() shows it, as the command's first answer. Its fields arrive as the submit handler's params, keyed by their customId. See Modals.
  • The answer to the form. A form opened from a command, not from a button on a message, has no message to update, so send() replies. The confirmation is private.
  • One ticket every ten minutes. @Cooldown({ uses: 1, seconds: 600 }) counts each member's calls apart. A second try in that time is told only to that member when they can try again, as a time Discord shows in their own language and counts down. The call is counted as the form opens, so a form closed without sending still counts.
  • Who can close it. The route carries the id of the member who opened the ticket, and the guard reads it from the params. Anyone else is told privately why they can't, and the handler never runs.
  • Closing. The handler updates the button's message, then locks and archives the thread, so members can still read it and no one can post.

Testing it

recipes/tickets/tickets.spec.ts
describe('TicketController', () => {
  const compile = () => MeoCordTestingModule.create({ controllers: [TicketController] }).compile()
  const ada = createMockInteraction(User, { id: '111111111111111111', username: 'ada' })

  it('opens the form once every ten minutes for each member', async () => {
    const module = compile()
    const open = () => createMockInteraction(ChatInputCommandInteraction, { user: ada })

    await module.invoke(TicketController, 'open', open())

    await expect(module.invoke(TicketController, 'open', open())).rejects.toBeInstanceOf(CooldownError)
  })

  it('opens a private thread with the member in it, and tells them where', async () => {
    // The thread the channel creates, and a channel whose threads manager creates it
    const thread = createMockInteraction(ThreadChannel, { members: { add: createMockFn() } as never })
    const create = createMockFn().mockResolvedValue(thread)
    const channel = createMockInteraction(TextChannel, { threads: { create } as never })
    const interaction = createMockInteraction(ModalSubmitInteraction, {
      customId: 'ticket/create',
      user: ada,
      channel,
      fields: createModalFields({ subject: 'Lost role', details: 'My Member role is gone.' }),
    })

    await compile().invoke(TicketController, 'create', interaction)

    expect(create).toHaveBeenCalledWith(expect.objectContaining({ name: 'ticket-ada', invitable: false }))
    expect(thread.members.add).toHaveBeenCalledWith('111111111111111111')
    expect(thread.send).toHaveBeenCalledWith(
      expect.objectContaining({ content: '**Lost role**\nMy Member role is gone.' }),
    )
    expect(interaction.ephemeral).toBe(true)
  })

  it('lets staff who can manage threads close it, locking and archiving the thread', async () => {
    const thread = createMockInteraction(ThreadChannel)
    const interaction = createMockInteraction(ButtonInteraction, {
      customId: closeTicket.build({ ownerId: '111111111111111111' }),
      user: createMockInteraction(User, { id: '999' }),
      memberPermissions: new PermissionsBitField(PermissionFlagsBits.ManageThreads),
      // discord.js types a button's channel as a public or private thread, not the ThreadChannel class
      channel: thread as never,
    })

    await compile().invoke(TicketController, 'close', interaction)

    expect(getResponse(interaction).calls[0].method).toBe('update')
    expect(thread.setLocked).toHaveBeenCalledWith(true)
    expect(thread.setArchived).toHaveBeenCalledWith(true)
  })

  it('refuses anyone else, privately', async () => {
    const interaction = createMockInteraction(ButtonInteraction, {
      customId: closeTicket.build({ ownerId: '111111111111111111' }),
      user: createMockInteraction(User, { id: '222222222222222222' }),
      memberPermissions: new PermissionsBitField(),
    })

    await expect(compile().invoke(TicketController, 'close', interaction)).rejects.toThrow(GuardDeniedError)
  })
})

Variations

Permissions

The bot needs Create Private Threads and Send Messages in Threads in the channel, and Manage Threads to lock a ticket as it closes.

Staff in the thread

Add a staff role's members with thread.members.add, or mention the role in the first message.

A transcript

Before archiving, fetch the thread's messages with thread.messages.fetch() and post them as a file to a log channel.

One open ticket per member

Keep open tickets in a service, or a database, and point the member to the one they already have.

Next steps