Add support tickets that work with private threads
Members open a ticket from a panel in the ticket channel: one button per category (a dropdown past five), then a short form. Each ticket is a private thread named after the category, number and opener, with a first message that mentions the category's staff role so its members join, and Claim and Close buttons. - Claim: staff (the category's role, or Manage Threads / Manage Server) take a ticket; the first message shows who has it. - Close: the opener or staff close it, with an optional reason. The thread is locked and archived, and the log channel gets a summary and a .txt transcript of the whole thread. - /ticket add|remove|claim|close inside a ticket; /tickets setup, /tickets category add|remove|list and /tickets panel for admins. - Per-member limit on open tickets (default 1), per-server ticket numbers handed out atomically, a deleted thread closes its ticket. - The dashboard asks the bot to post or refresh the panel through ticket_commands (LISTEN/NOTIFY, like music); the listener reconnects if the database connection drops. - Setup warns about missing bot permissions in the ticket and log channels. The flows live in internal/tickets behind small Discord and store interfaces, with unit tests on fakes; the repo and the listener have integration tests (-tags integration, TICKETS_TEST_DB). Needs the ticket tables (velox-dashboard-api migration 000016_tickets). .gitignore gains the /tldr benchmark rules so private chat exports and benchmark results on disk can't be committed from this branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b1ea5f17e3
commit
d294b3463b
20 files changed
+2800
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
cmdmusic "velox-bot/internal/commands/music"
|
||||
"velox-bot/internal/commands/projects"
|
||||
"velox-bot/internal/commands/schedule"
|
||||
cmdtickets "velox-bot/internal/commands/tickets"
|
||||
"velox-bot/internal/commands/timezone"
|
||||
"velox-bot/internal/music"
|
||||
|
||||
@@ -40,6 +41,8 @@ var AllCommands = []*discordgo.ApplicationCommand{
|
||||
schedule.Schedule,
|
||||
projects.Projects,
|
||||
config.Config,
|
||||
cmdtickets.Tickets,
|
||||
cmdtickets.Ticket,
|
||||
}
|
||||
|
||||
var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
|
||||
@@ -65,6 +68,8 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
|
||||
"volume": cmdmusic.VolumeHandler,
|
||||
"music": cmdmusic.MusicHandler,
|
||||
"config": config.ConfigHandler,
|
||||
"tickets": cmdtickets.TicketsHandler,
|
||||
"ticket": cmdtickets.TicketHandler,
|
||||
}
|
||||
|
||||
func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
@@ -82,6 +87,8 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
schedule.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleComponent(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||
cmdtickets.HandleComponent(s, i)
|
||||
}
|
||||
case discordgo.InteractionModalSubmit:
|
||||
data := i.ModalSubmitData()
|
||||
@@ -90,6 +97,8 @@ func HandleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
schedule.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "projects:"):
|
||||
projects.HandleModalSubmit(s, i)
|
||||
case strings.HasPrefix(data.CustomID, "tickets:"):
|
||||
cmdtickets.HandleModalSubmit(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,15 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
"`/projects create name:\"...\" [description]` — Create a project (**Manage Server**)\n" +
|
||||
"`/projects add-helper project-id:<id> user:@someone` — Add helper (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Tickets",
|
||||
Value: "" +
|
||||
"Open a ticket from the panel: you get a private thread with the staff.\n" +
|
||||
"`/ticket add user:@someone` — Add someone to your ticket\n" +
|
||||
"`/ticket close [reason]` — Close the ticket (opener or staff)\n" +
|
||||
"`/ticket claim`, `/ticket remove user:@someone` — Staff only\n" +
|
||||
"`/tickets setup`, `/tickets category add|remove|list`, `/tickets panel` — Set up (**Manage Server**)",
|
||||
},
|
||||
{
|
||||
Name: "Meetings (**Manage Server**)",
|
||||
Value: "" +
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package tickets
|
||||
|
||||
import (
|
||||
"velox-bot/internal/commands/tickets/public"
|
||||
"velox-bot/internal/db/repos/ticketrepo"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
)
|
||||
|
||||
var (
|
||||
Tickets *discordgo.ApplicationCommand = public.Tickets
|
||||
Ticket *discordgo.ApplicationCommand = public.Ticket
|
||||
)
|
||||
|
||||
func Init(r *ticketrepo.Repo) { public.Init(r) }
|
||||
|
||||
func TicketsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.TicketsHandler(s, i)
|
||||
}
|
||||
|
||||
func TicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.TicketHandler(s, i) }
|
||||
|
||||
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleComponent(s, i)
|
||||
}
|
||||
|
||||
func HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
public.HandleModalSubmit(s, i)
|
||||
}
|
||||
Reference in new issue
Block a user