Ticket bot (JavaScript)

Opens a private support channel for each member who runs /ticket, with a button to close it.

Node.js 22

Environment variables you'll need

KeyWhat it is
DISCORD_TOKENYour bot's token from the Discord Developer Portal. Paste it on the Secrets tab; we store it encrypted.
TICKET_CATEGORY_IDOptional. Category the ticket channels are created in
SUPPORT_ROLE_IDOptional. Role that can see and close every ticket

Create an account to use it

Files

README.md

# Ticket bot

A JavaScript bot built on discord.js. Your bot token is set on the Secrets tab as
DISCORD_TOKEN and reaches the bot as an environment variable.

## Commands

- `/ticket [reason]` opens a private channel only you, the bot and the support role can see. One open ticket per member.
- The Close button (or `/close` inside the ticket) deletes the channel.

## Setup

1. No privileged gateway intents are needed. Leave Message Content, Server Members
   and Presence off in the Discord Developer Portal.
2. Invite the bot with the `bot` and `applications.commands` scopes and these
   permissions: View Channels, Manage Channels, Send Messages, Read Message History.
3. Set these values on the Secrets tab. The ones marked optional can stay empty:

- `TICKET_CATEGORY_ID`: Optional. Category the ticket channels are created in.
- `SUPPORT_ROLE_ID`: Optional. Role that can see and close every ticket.

4. Press Deploy. The console shows a line with "Logged in as" once the bot is online.

index.js

const {
  ActionRowBuilder, ButtonBuilder, ButtonStyle, ChannelType, Client, Events,
  GatewayIntentBits, MessageFlags, OverwriteType, PermissionFlagsBits, SlashCommandBuilder,
} = require('discord.js');

const envId = (name) => (/^\d+$/.test((process.env[name] || '').trim()) ? process.env[name].trim() : null);
const CATEGORY_ID = envId('TICKET_CATEGORY_ID');
const SUPPORT_ROLE_ID = envId('SUPPORT_ROLE_ID');
const CLOSE_ID = 'ticket:close';

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

const commands = [
  new SlashCommandBuilder().setName('ticket').setDescription('Open a private support ticket')
    .addStringOption((o) => o.setName('reason').setDescription('What do you need help with?')),
  new SlashCommandBuilder().setName('close').setDescription('Close this ticket'),
].map((c) => c.toJSON());

const ticketName = (userId) => `ticket-${userId}`;

function hasRole(member, roleId) {
  if (!member || !roleId) return false;
  return Array.isArray(member.roles) ? member.roles.includes(roleId) : member.roles.cache.has(roleId);
}

async function openTicket(interaction) {
  const { guild, user } = interaction;
  const name = ticketName(user.id);
  const existing = guild.channels.cache.find((c) => c.name === name);
  if (existing) {
    await interaction.reply({ content: `You already have an open ticket: ${existing}`, flags: MessageFlags.Ephemeral });
    return;
  }
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });

  const member = [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.ReadMessageHistory];
  const overwrites = [
    { id: guild.roles.everyone.id, type: OverwriteType.Role, deny: [PermissionFlagsBits.ViewChannel] },
    { id: user.id, type: OverwriteType.Member, allow: [...member, PermissionFlagsBits.AttachFiles] },
    { id: client.user.id, type: OverwriteType.Member, allow: [...member, PermissionFlagsBits.ManageChannels] },
  ];
  if (SUPPORT_ROLE_ID && guild.roles.cache.has(SUPPORT_ROLE_ID)) {
    overwrites.push({ id: SUPPORT_ROLE_ID, type: OverwriteType.Role, allow: member });
  }
  const category = CATEGORY_ID ? guild.channels.cache.get(CATEGORY_ID) : null;

  const channel = await guild.channels.create({
    name,
    type: ChannelType.GuildText,
    parent: category && category.type === ChannelType.GuildCategory ? category.id : null,
    topic: `Ticket for ${user.tag} (${user.id})`,
    permissionOverwrites: overwrites,
    reason: `Ticket opened by ${user.tag}`,
  });

  const reason = interaction.options.getString('reason');
  const row = new ActionRowBuilder().addComponents(
    new ButtonBuilder().setCustomId(CLOSE_ID).setLabel('Close ticket').setStyle(ButtonStyle.Danger),
  );
  await channel.send({
    content: `${user} opened a ticket.${reason ? `\nReason: ${reason}` : ''}\n`
      + 'Support will be with you soon. Press the button or use /close when done.',
    components: [row],
    allowedMentions: { users: [user.id] },
  });
  await interaction.editReply(`Your ticket is ready: ${channel}`);
}

async function closeTicket(interaction) {
  const { channel, member, user } = interaction;
  if (!channel || !channel.name || !channel.name.startsWith('ticket-')) {
    await interaction.reply({ content: 'This only works inside a ticket channel.', flags: MessageFlags.Ephemeral });
    return;
  }
  const allowed = channel.name === ticketName(user.id)
    || hasRole(member, SUPPORT_ROLE_ID)
    || interaction.memberPermissions.has(PermissionFlagsBits.ManageChannels);
  if (!allowed) {
    await interaction.reply({ content: 'Only the ticket owner or support staff can close this ticket.', flags: MessageFlags.Ephemeral });
    return;
  }
  await interaction.reply('Closing this ticket.');
  await channel.delete(`Ticket closed by ${user.tag}`);
}

client.once(Events.ClientReady, async (c) => {
  console.log(`Logged in as ${c.user.tag} (ready)`);
  try {
    await c.application.commands.set(commands);
    console.log('Slash commands registere

package.json

{
  "name": "ticket-bot-js",
  "private": true,
  "main": "index.js",
  "engines": { "node": ">=22" },
  "dependencies": { "discord.js": "^14.0.0" }
}