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 (one button on the panel)", 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 buttons and dropdown 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:"): showOpenForm(s, i, strings.TrimPrefix(data.CustomID, "tickets:open:")) case data.CustomID == "tickets:select" && len(data.Values) > 0: showOpenForm(s, i, data.Values[0]) 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()) } } 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")) } }