aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Discord Bots

Discord Ticket Bot: Button-Based Support System

A discord ticket bot lets users open a private channel — visible only to themselves and the support team — with a single button click. General chat stays uncluttered, every request runs in its own isolated channel, and your staff can work through tickets in peace. In this guide we'll build a button-driven support system from scratch with discord.js v14, step by step.

How the system works

The logic has three parts:

  • The panel message: an embed with an "Open Ticket" button, posted to a fixed channel.
  • The button listener: code that catches the interaction when a user clicks and creates the private channel.
  • Closing: a second button that closes the channel once the request is resolved.

The key trick is permission overwrites: when creating the channel we deny the View Channel permission for the @everyone role and grant it only to the requesting user and the staff role.

Required intents and permissions

We don't need access to message content for buttons and channel creation; the Guilds intent alone is enough. In your server settings, give the bot's role these permissions: Manage Channels, View Channels, and Manage Roles so it can set permission overwrites.

const { Client, GatewayIntentBits } = require('discord.js');

const client = new Client({
  intents: [GatewayIntentBits.Guilds],
});

client.once('ready', () => console.log(`Logged in as ${client.user.tag}`));
client.login(process.env.TOKEN);

Sending the panel message

First let's build the panel users will see. You can send it with a simple command or once on startup. The button's customId is critical — we'll use it to identify the interaction.

const {
  ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder,
} = require('discord.js');

const embed = new EmbedBuilder()
  .setTitle('🎫 Support')
  .setDescription('Need help? Click the button below to open your own private support channel.')
  .setColor(0x5865f2);

const row = new ActionRowBuilder().addComponents(
  new ButtonBuilder()
    .setCustomId('ticket_create')
    .setLabel('Open Ticket')
    .setEmoji('🎫')
    .setStyle(ButtonStyle.Primary),
);

await panelChannel.send({ embeds: [embed], components: [row] });

Opening a private channel on click

This is the heart of it. In the interactionCreate event we catch the button, check whether the user already has an open ticket, and create a private channel with permission overwrites. Passing a category ID to parent is a good idea so all tickets sit under one category.

const { Events, ChannelType, PermissionFlagsBits } = require('discord.js');

const STAFF_ROLE_ID = '123456789012345678';
const TICKET_CATEGORY_ID = '987654321098765432';

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isButton()) return;
  if (interaction.customId !== 'ticket_create') return;

  const { guild, user } = interaction;

  // Does this user already have an open ticket?
  const existing = guild.channels.cache.find(
    (c) => c.name === `ticket-${user.id}`,
  );
  if (existing) {
    return interaction.reply({
      content: `You already have an open ticket: ${existing}`,
      ephemeral: true,
    });
  }

  const channel = await guild.channels.create({
    name: `ticket-${user.id}`,
    type: ChannelType.GuildText,
    parent: TICKET_CATEGORY_ID,
    permissionOverwrites: [
      { id: guild.id, deny: [PermissionFlagsBits.ViewChannel] },
      {
        id: user.id,
        allow: [
          PermissionFlagsBits.ViewChannel,
          PermissionFlagsBits.SendMessages,
          PermissionFlagsBits.AttachFiles,
        ],
      },
      {
        id: STAFF_ROLE_ID,
        allow: [
          PermissionFlagsBits.ViewChannel,
          PermissionFlagsBits.SendMessages,
        ],
      },
    ],
  });

  const closeRow = new ActionRowBuilder().addComponents(
    new ButtonBuilder()
      .setCustomId('ticket_close')
      .setLabel('Close Ticket')
      .setEmoji('🔒')
      .setStyle(ButtonStyle.Danger),
  );

  await channel.send({
    content: `${user} hi! The <@&${STAFF_ROLE_ID}> team will be with you shortly. Please describe your issue in detail.`,
    components: [closeRow],
  });

  await interaction.reply({
    content: `Your support channel is ready: ${channel}`,
    ephemeral: true,
  });
});

Using user.id instead of the username in the channel name matters: usernames can change, may contain special characters, and make duplicate checks unreliable. The ID is unique and stable.

Closing a ticket

For the close button we add a second condition inside the same interactionCreate handler. We give the user a short notice and delete the channel a few seconds later.

if (interaction.isButton() && interaction.customId === 'ticket_close') {
  await interaction.reply({
    content: 'Closing this ticket in 5 seconds...',
  });
  setTimeout(() => {
    interaction.channel.delete().catch(() => null);
  }, 5000);
}

In more mature systems you might move the channel to an archive category instead of deleting it, send a transcript to a log channel, or require staff confirmation before closing. Deletion is enough to get started.

Common pitfalls

  • Bot role too low: the bot's role must sit high enough in the role list to manage the permissions it grants, otherwise channel creation fails with Missing Permissions.
  • Interaction timeout: Discord expects a reply within 3 seconds. If channel creation is slow, call interaction.deferReply({ ephemeral: true }) first and finish with interaction.editReply(...).
  • Category channel limit: a category holds at most 50 channels. On busy servers, clean up closed tickets regularly.

Frequently Asked Questions

Do I need to enable the Message Content intent?

No. The Guilds intent is enough for button interactions and channel creation. Only enable the privileged MessageContent intent if you actually need to read message text inside the channel.

How do I stop a user from opening two tickets at once?

Name channels as ticket-${user.id} and check the cache for an existing channel with that name before creating a new one. If it exists, point the user to their current channel.

How do I save a transcript?

Before closing, fetch the channel's messages with channel.messages.fetch() and write them to a text/HTML file, or use a package like discord-html-transcripts and send the file to a log channel.

Want a professional ticket system for your server? I can build a custom solution with transcripts, category-based tickets, staff statistics and a multilingual interface — get in touch.

Devamı için