Files
velox-bot/internal/commands/tickets/public/tickets.go
FernandoJVideiraandClaude Sonnet 5.5 acdb5ead35 Tickets: settings, category edit and ticket list from Discord; NOTIFY triggers in schema.sql
- /tickets settings (on or off, panel title and text, open tickets per member), /tickets category edit, /tickets list
- schema.sql now creates the NOTIFY triggers, so the integration tests run on a database built from it, and two stale expectations (tickets start off) are fixed

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:05:45 +01:00

550 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: append([]*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"},
categoryEditOption(),
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "panel",
Description: "Post the ticket panel, or update it after changing categories",
},
}, ticketSettingsOptions()...),
}
// 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 "settings":
handleSettings(s, i, sub.Options)
case "list":
handleList(s, i, sub.Options)
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)
case "edit":
handleCategoryEdit(s, i, sub.Options[0].Options)
}
}
}
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"))
}
}