Files
velox-bot/internal/commands/tickets/public/tickets.go
T
FernandoJVideiraandClaude Opus 5.5 e10e1f7a86 Use a dropdown and a Create ticket button on the ticket panel, in orange
The panel is now one category dropdown and a Create ticket button
instead of a button per category. Discord doesn't send the dropdown's
value with a button click, so each member's pick is remembered in
memory for 15 minutes (per guild and member, capped). Panels posted
before keep working until they're reposted. Every ticket embed is
orange, closed tickets included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 11:58:19 +01:00

543 lines
18 KiB
Go

package public
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"velox-bot/internal/tickets"
"github.com/bwmarrin/discordgo"
)
// The session must provide everything the ticket flows call.
var _ tickets.Discord = (*discordgo.Session)(nil)
// Repo is the full ticket store, including the admin calls that only the
// slash commands make. Set by Init.
var Repo *ticketrepo.Repo
func Init(r *ticketrepo.Repo) {
Repo = r
tickets.Init(r)
}
var (
manageGuild = int64(discordgo.PermissionManageGuild)
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText}
opTimeout = 2 * time.Minute
genericFailed = "Something went wrong. Try again in a moment, and tell an admin if it keeps happening."
)
// Tickets is the admin command for setting the system up.
var Tickets = &discordgo.ApplicationCommand{
Name: "tickets",
Description: "Set up support tickets",
DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setup",
Description: "Choose where tickets are opened and where closed tickets are logged",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Where the panel goes and ticket threads are created", Required: true, ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionChannel, Name: "log-channel", Description: "Where closed tickets and their transcripts are posted", ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Creates a Support category handled by this role, if there are no categories yet"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommandGroup,
Name: "category",
Description: "Manage ticket categories",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "add",
Description: "Add a category (an option in the panel's dropdown)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "e.g. Support, Report, Appeal", Required: true, MaxLength: 40},
{Type: discordgo.ApplicationCommandOptionRole, Name: "staff-role", Description: "Who handles these tickets"},
{Type: discordgo.ApplicationCommandOptionString, Name: "emoji", Description: "Shown on the button", MaxLength: 64},
{Type: discordgo.ApplicationCommandOptionString, Name: "description", Description: "Shown on the panel", MaxLength: 200},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "remove",
Description: "Remove a category",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "The category's name", Required: true},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "list", Description: "Show the categories"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "panel",
Description: "Post the ticket panel, or update it after changing categories",
},
},
}
// Ticket is used inside a ticket thread.
var Ticket = &discordgo.ApplicationCommand{
Name: "ticket",
Description: "Manage the ticket you're in",
Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Add someone to this ticket",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to add", Required: true}},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Remove someone from this ticket (staff only)",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to remove", Required: true}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "claim", Description: "Take this ticket (staff only)"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", Description: "Close this ticket",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Why it's being closed", MaxLength: 300}},
},
},
}
func reply(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral, AllowedMentions: &discordgo.MessageAllowedMentions{}},
})
}
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
return err == nil
}
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
}
// message turns an error into what the user sees; unexpected ones are logged.
func message(where string, err error) string {
var ue tickets.UserError
if errors.As(err, &ue) {
return string(ue)
}
log.Printf("tickets: %s: %v", where, err)
return genericFailed
}
func actor(i *discordgo.InteractionCreate) tickets.Actor {
a := tickets.Actor{Member: i.Member}
if i.Member != nil {
a.Perms = i.Member.Permissions
}
return a
}
func guildID(i *discordgo.InteractionCreate) int64 {
n, _ := strconv.ParseInt(i.GuildID, 10, 64)
return n
}
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
switch sub.Name {
case "setup":
handleSetup(s, i, sub.Options)
case "panel":
handlePanel(s, i)
case "category":
if len(sub.Options) == 0 {
return
}
switch sub.Options[0].Name {
case "add":
handleCategoryAdd(s, i, sub.Options[0].Options)
case "remove":
handleCategoryRemove(s, i, sub.Options[0].Options)
case "list":
handleCategoryList(s, i)
}
}
}
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, x := range o {
m[x.Name] = x
}
return m
}
// permissionWarning names what the bot is missing in a channel, or "".
func permissionWarning(s *discordgo.Session, channelID string, need int64, what string) string {
have, err := s.UserChannelPermissions(s.State.User.ID, channelID)
if err != nil {
return ""
}
if missing := tickets.MissingPerms(have, need); len(missing) > 0 {
return fmt.Sprintf("\n⚠️ I'm missing these permissions in <#%s> (%s): %s.", channelID, what, strings.Join(missing, ", "))
}
return ""
}
func handleSetup(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
ch := args["channel"].ChannelValue(nil).ID
var logID int64
logMention := "none"
if l, ok := args["log-channel"]; ok {
lc := l.ChannelValue(nil).ID
logID, _ = strconv.ParseInt(lc, 10, 64)
logMention = "<#" + lc + ">"
}
chID, _ := strconv.ParseInt(ch, 10, 64)
if err := Repo.SetChannels(ctx, g, chID, logID); err != nil {
reply(s, i, message("setup", err))
return
}
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("setup", err))
return
}
created := ""
if r, ok := args["staff-role"]; ok && len(cats) == 0 {
role := r.RoleValue(nil, i.GuildID)
roleID, _ := strconv.ParseInt(role.ID, 10, 64)
if _, err := Repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support", Emoji: "🎫", StaffRoleID: roleID}); err != nil {
reply(s, i, message("setup", err))
return
}
created = fmt.Sprintf("\nCreated a **Support** category handled by <@&%s>.", role.ID)
cats = append(cats, ticketrepo.Category{Name: "Support"})
}
msg := fmt.Sprintf("Tickets will open as private threads in <#%s>. Closed tickets are logged to %s.%s", ch, logMention, created)
msg += permissionWarning(s, ch, tickets.NeedTicketChannel, "ticket channel")
if logID != 0 {
msg += permissionWarning(s, strconv.FormatInt(logID, 10), tickets.NeedLogChannel, "log channel")
}
if len(cats) == 0 {
msg += "\nNext: add a category with `/tickets category add`, then post the panel with `/tickets panel`."
} else {
msg += "\nNext: post the panel with `/tickets panel`."
}
msg += "\nTip: give your staff role **Manage Threads** in that channel so they can see every ticket, even ones they weren't pinged in."
reply(s, i, msg)
}
func handlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
msg, err := tickets.PostPanel(ctx, s, Repo, guildID(i))
if err != nil {
edit(s, i, message("panel", err))
return
}
edit(s, i, fmt.Sprintf("The ticket panel is up: https://discord.com/channels/%s/%s/%s", i.GuildID, msg.ChannelID, msg.ID))
}
func handleCategoryAdd(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
args := opts(o)
g := guildID(i)
cats, err := Repo.ListCategories(ctx, g)
if err != nil {
reply(s, i, message("category add", err))
return
}
name := strings.TrimSpace(args["name"].StringValue())
for _, c := range cats {
if strings.EqualFold(c.Name, name) {
reply(s, i, fmt.Sprintf("There's already a category called **%s**.", c.Name))
return
}
}
if len(cats) >= 25 {
reply(s, i, "A panel can hold at most 25 categories.")
return
}
c := ticketrepo.Category{GuildID: g, Name: name}
if r, ok := args["staff-role"]; ok {
c.StaffRoleID, _ = strconv.ParseInt(r.RoleValue(nil, i.GuildID).ID, 10, 64)
}
if e, ok := args["emoji"]; ok {
c.Emoji = strings.TrimSpace(e.StringValue())
}
if d, ok := args["description"]; ok {
c.Description = strings.TrimSpace(d.StringValue())
}
if _, err := Repo.AddCategory(ctx, c); err != nil {
reply(s, i, message("category add", err))
return
}
reply(s, i, fmt.Sprintf("Added **%s**. Run `/tickets panel` to show it on the panel.", name))
}
func handleCategoryRemove(s *discordgo.Session, i *discordgo.InteractionCreate, o []*discordgo.ApplicationCommandInteractionDataOption) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
name := strings.TrimSpace(opts(o)["name"].StringValue())
ok, err := Repo.DeleteCategoryByName(ctx, guildID(i), name)
switch {
case err != nil:
reply(s, i, message("category remove", err))
case !ok:
reply(s, i, fmt.Sprintf("There's no category called **%s**.", name))
default:
reply(s, i, fmt.Sprintf("Removed **%s**. Open tickets keep working. Run `/tickets panel` to update the panel.", name))
}
}
func handleCategoryList(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
cats, err := Repo.ListCategories(ctx, guildID(i))
if err != nil {
reply(s, i, message("category list", err))
return
}
if len(cats) == 0 {
reply(s, i, "No categories yet. Add one with `/tickets category add`.")
return
}
var b strings.Builder
for _, c := range cats {
line := "• "
if c.Emoji != "" {
line += c.Emoji + " "
}
line += "**" + c.Name + "**"
if c.StaffRoleID != 0 {
line += fmt.Sprintf(" → <@&%d>", c.StaffRoleID)
} else {
line += " → no staff role (only Manage Threads)"
}
if c.Description != "" {
line += ": " + c.Description
}
b.WriteString(line + "\n")
}
reply(s, i, b.String())
}
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
switch sub.Name {
case "add", "remove":
user := args["user"].UserValue(nil)
if user == nil {
return
}
if !deferReply(s, i) {
return
}
var err error
if sub.Name == "add" {
err = tickets.AddMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
} else {
err = tickets.RemoveMember(ctx, s, Repo, i.ChannelID, actor(i), user.ID)
}
if err != nil {
edit(s, i, message("ticket "+sub.Name, err))
return
}
edit(s, i, "Done.")
case "claim":
claim(s, i)
case "close":
reason := ""
if r, ok := args["reason"]; ok {
reason = r.StringValue()
}
closeTicket(s, i, reason)
}
}
func claim(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.Claim(ctx, s, Repo, i.ChannelID, actor(i)); err != nil {
edit(s, i, message("claim", err))
return
}
edit(s, i, "You're handling this ticket now.")
}
func closeTicket(s *discordgo.Session, i *discordgo.InteractionCreate, reason string) {
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.Close(ctx, s, Repo, i.ChannelID, actor(i), reason); err != nil {
edit(s, i, message("close", err))
return
}
edit(s, i, "Ticket closed.")
}
// HandleComponent handles the panel dropdown and Create ticket button and the Claim and
// Close buttons in a ticket.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.MessageComponentData()
switch {
case strings.HasPrefix(data.CustomID, "tickets:open:"):
// Panels posted before the dropdown had one button per category.
showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:"))
case data.CustomID == "tickets:select" && len(data.Values) > 0:
rememberPick(s, i, data.Values[0])
case data.CustomID == "tickets:create":
createFromPick(s, i)
case data.CustomID == "tickets:claim":
claim(s, i)
case data.CustomID == "tickets:close":
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := tickets.CheckCanClose(ctx, Repo, i.ChannelID, actor(i)); err != nil {
reply(s, i, message("close", err))
return
}
_ = s.InteractionRespond(i.Interaction, tickets.CloseForm())
}
}
// rememberPick stores the dropdown choice for the Create ticket button and
// acknowledges it without changing the panel, so the choice stays visible.
func rememberPick(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
tickets.RememberPick(guildID(i), userID, catID)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{Type: discordgo.InteractionResponseDeferredMessageUpdate})
}
func createFromPick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Member == nil || i.Member.User == nil {
return
}
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
catID, ok := tickets.PickedCategory(guildID(i), userID)
if !ok {
reply(s, i, "Pick a category from the menu first, then press **Create ticket**.")
return
}
showOpenForm(s, i, strconv.FormatInt(catID, 10))
}
func showOpenForm(s *discordgo.Session, i *discordgo.InteractionCreate, rawCategory string) {
catID, err := strconv.ParseInt(rawCategory, 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
userID, _ := strconv.ParseInt(i.Member.User.ID, 10, 64)
_, cat, err := tickets.CheckCanOpen(ctx, Repo, guildID(i), userID, catID)
if err != nil {
reply(s, i, message("open", err))
return
}
_ = s.InteractionRespond(i.Interaction, tickets.OpenForm(cat))
}
func field(data discordgo.ModalSubmitInteractionData, name string) string {
for _, row := range data.Components {
r, ok := row.(*discordgo.ActionsRow)
if !ok {
continue
}
for _, c := range r.Components {
if in, ok := c.(*discordgo.TextInput); ok && in.CustomID == name {
return strings.TrimSpace(in.Value)
}
}
}
return ""
}
// HandleModalSubmit handles the opening and closing forms.
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
if Repo == nil {
reply(s, i, "Tickets aren't available right now.")
return
}
data := i.ModalSubmitData()
switch {
case strings.HasPrefix(data.CustomID, "tickets:openform:"):
catID, err := strconv.ParseInt(strings.TrimPrefix(data.CustomID, "tickets:openform:"), 10, 64)
if err != nil || i.Member == nil || i.Member.User == nil {
return
}
if !deferReply(s, i) {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
thread, err := tickets.Open(ctx, s, Repo, tickets.OpenRequest{
GuildID: guildID(i), CategoryID: catID, User: i.Member.User,
Subject: field(data, "subject"), Details: field(data, "details"),
})
if err != nil {
edit(s, i, message("open", err))
return
}
edit(s, i, fmt.Sprintf("Your ticket is open: <#%s>. The staff will be with you there.", thread.ID))
case data.CustomID == "tickets:closeform":
closeTicket(s, i, field(data, "reason"))
}
}