Ticket bot (Go)

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

Go 1.23

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 Go bot built on discordgo. 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.

go.mod

module example.com/ticket-bot-go

go 1.23

require github.com/bwmarrin/discordgo v0.28.1

go.sum

main.go

package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"regexp"
	"slices"
	"strings"
	"syscall"

	"github.com/bwmarrin/discordgo"
)

const closeID = "ticket:close"

var (
	digits        = regexp.MustCompile(`^\d+$`)
	categoryID    = envID("TICKET_CATEGORY_ID")
	supportRoleID = envID("SUPPORT_ROLE_ID")
	noDM          = false
)

var commands = []*discordgo.ApplicationCommand{
	{Name: "ticket", Description: "Open a private support ticket", DMPermission: &noDM,
		Options: []*discordgo.ApplicationCommandOption{{
			Type: discordgo.ApplicationCommandOptionString, Name: "reason",
			Description: "What do you need help with?"}}},
	{Name: "close", Description: "Close this ticket", DMPermission: &noDM},
}

func envID(name string) string {
	v := strings.TrimSpace(os.Getenv(name))
	if digits.MatchString(v) {
		return v
	}
	return ""
}

func ticketName(userID string) string { return "ticket-" + userID }

func reply(s *discordgo.Session, i *discordgo.InteractionCreate, content string, ephemeral bool) error {
	data := &discordgo.InteractionResponseData{Content: content}
	if ephemeral {
		data.Flags = discordgo.MessageFlagsEphemeral
	}
	return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
		Type: discordgo.InteractionResponseChannelMessageWithSource, Data: data})
}

func openTicket(s *discordgo.Session, i *discordgo.InteractionCreate) error {
	user := i.Member.User
	channels, err := s.GuildChannels(i.GuildID)
	if err != nil {
		return err
	}
	parentID := ""
	for _, c := range channels {
		if c.Name == ticketName(user.ID) {
			return reply(s, i, "You already have an open ticket: <#"+c.ID+">", true)
		}
		if c.ID == categoryID && c.Type == discordgo.ChannelTypeGuildCategory {
			parentID = c.ID
		}
	}
	err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
		Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
		Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}})
	if err != nil {
		return err
	}

	var basic int64 = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages |
		discordgo.PermissionReadMessageHistory
	overwrites := []*discordgo.PermissionOverwrite{
		{ID: i.GuildID, Type: discordgo.PermissionOverwriteTypeRole, Deny: discordgo.PermissionViewChannel},
		{ID: user.ID, Type: discordgo.PermissionOverwriteTypeMember, Allow: basic | discordgo.PermissionAttachFiles},
		{ID: s.State.User.ID, Type: discordgo.PermissionOverwriteTypeMember, Allow: basic | discordgo.PermissionManageChannels},
	}
	if supportRoleID != "" {
		if _, err := s.State.Role(i.GuildID, supportRoleID); err == nil {
			overwrites = append(overwrites, &discordgo.PermissionOverwrite{
				ID: supportRoleID, Type: discordgo.PermissionOverwriteTypeRole, Allow: basic})
		}
	}
	channel, err := s.GuildChannelCreateComplex(i.GuildID, discordgo.GuildChannelCreateData{
		Name: ticketName(user.ID), Type: discordgo.ChannelTypeGuildText, ParentID: parentID,
		Topic:                fmt.Sprintf("Ticket for %s (%s)", user.Username, user.ID),
		PermissionOverwrites: overwrites,
	})
	if err != nil {
		return err
	}

	content := fmt.Sprintf("<@%s> opened a ticket.", user.ID)
	for _, opt := range i.ApplicationCommandData().Options {
		if opt.Name == "reason" && opt.StringValue() != "" {
			content += "\nReason: " + opt.StringValue()
		}
	}
	content += "\nSupport will be with you soon. Press the button or use /close when done."
	_, err = s.ChannelMessageSendComplex(channel.ID, &discordgo.MessageSend{
		Content:         content,
		AllowedMentions: &discordgo.MessageAllowedMentions{Users: []string{user.ID}},
		Components: []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
			discordgo.Button{Label: "Close ticket", Style: discordgo.DangerButton, CustomID: closeID},
		}}},
	})
	if err != nil {
		return err
	}
	done := "Your ticket is ready: <#" + channel.ID + ">"
	_, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &done})
	return