// Package tickets is the support ticket system: a panel message with a // category dropdown and a Create ticket button, a short form, and a private // thread per ticket that staff can claim and anyone involved can close. // Closing locks and archives the thread and posts a transcript to the log // channel. // // The Discord and database calls go through small interfaces so the flows // can be tested without either; the slash command and button handlers in // internal/commands/tickets only translate interactions into these calls. package tickets import ( "context" "fmt" "strings" "unicode" "velox-bot/internal/db/repos/ticketrepo" "github.com/bwmarrin/discordgo" ) // Store is the subset of ticketrepo.Repo the flows use. type Store interface { GetSettings(ctx context.Context, guildID int64) (*ticketrepo.Settings, error) SetPanelMessage(ctx context.Context, guildID, messageID int64) error NextNumber(ctx context.Context, guildID int64) (int, error) ListCategories(ctx context.Context, guildID int64) ([]ticketrepo.Category, error) GetCategory(ctx context.Context, guildID, id int64) (*ticketrepo.Category, error) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) CreateTicket(ctx context.Context, t ticketrepo.Ticket) (int64, error) GetTicketByThread(ctx context.Context, threadID int64) (*ticketrepo.Ticket, error) Claim(ctx context.Context, threadID, staffID int64) (int64, error) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) ListUnprocessedCommands(ctx context.Context) ([]ticketrepo.Command, error) MarkCommandProcessed(ctx context.Context, id int64) error } // Discord is the subset of *discordgo.Session the flows use. type Discord interface { ThreadStartComplex(channelID string, data *discordgo.ThreadStart, options ...discordgo.RequestOption) (*discordgo.Channel, error) ThreadMemberAdd(threadID, memberID string, options ...discordgo.RequestOption) error ThreadMemberRemove(threadID, memberID string, options ...discordgo.RequestOption) error ChannelMessageSendComplex(channelID string, data *discordgo.MessageSend, options ...discordgo.RequestOption) (*discordgo.Message, error) ChannelMessageEditComplex(m *discordgo.MessageEdit, options ...discordgo.RequestOption) (*discordgo.Message, error) ChannelMessage(channelID, messageID string, options ...discordgo.RequestOption) (*discordgo.Message, error) ChannelMessages(channelID string, limit int, beforeID, afterID, aroundID string, options ...discordgo.RequestOption) ([]*discordgo.Message, error) ChannelEditComplex(channelID string, data *discordgo.ChannelEdit, options ...discordgo.RequestOption) (*discordgo.Channel, error) ChannelDelete(channelID string, options ...discordgo.RequestOption) (*discordgo.Channel, error) } // UserError is a problem worth showing the person who clicked, as written. // Anything else is logged and shown as a generic failure. type UserError string func (e UserError) Error() string { return string(e) } const ( ErrNotSetUp = UserError("Tickets aren't set up on this server yet. An admin can run `/tickets setup`.") ErrDisabled = UserError("Tickets are turned off on this server right now.") ErrNoCategories = UserError("There are no ticket categories yet. Add one with `/tickets category add` or on the dashboard.") ErrUnknownCategory = UserError("That ticket category no longer exists. Ask an admin to repost the ticket panel.") ErrNotATicket = UserError("This isn't a ticket thread.") ErrClosed = UserError("This ticket is already closed.") ErrNotStaff = UserError("Only the staff handling this kind of ticket can do that.") ErrNotInvolved = UserError("Only the person who opened this ticket or the staff can do that.") ) var ( defaultStore Store // Every ticket embed (panel, ticket, log) is orange, so support stands // apart from the bot's other messages. accent = 0xf97316 ) func Init(s Store) { defaultStore = s } func Default() Store { return defaultStore } // Limits Discord puts on what the bot sends. const ( maxThreadName = 100 maxSubject = 100 maxDetails = 1000 maxReason = 300 maxCategories = 25 autoArchiveWeek = 10080 ) func id(v int64) string { return fmt.Sprintf("%d", v) } // IsStaff reports whether a member may claim, close and manage tickets of // this category: anyone with the category's staff role, or with Manage // Threads / Manage Server / Administrator (perms is the member's permission // set in the ticket's channel, as Discord sends it with the interaction). func IsStaff(member *discordgo.Member, perms int64, cat *ticketrepo.Category) bool { const staffPerms = discordgo.PermissionAdministrator | discordgo.PermissionManageGuild | discordgo.PermissionManageThreads if perms&staffPerms != 0 { return true } if member == nil || cat == nil || cat.StaffRoleID == 0 { return false } want := id(cat.StaffRoleID) for _, r := range member.Roles { if r == want { return true } } return false } // slug turns a name into lowercase ASCII words joined by dashes, for thread // names that read well in the channel's thread list. func slug(s string, max int) string { var b strings.Builder dash := false for _, r := range strings.ToLower(s) { switch { case r < unicode.MaxASCII && (unicode.IsLetter(r) || unicode.IsDigit(r)): if dash && b.Len() > 0 { b.WriteByte('-') } b.WriteRune(r) dash = false default: dash = true } if b.Len() >= max { break } } out := b.String() if len(out) > max { out = out[:max] } return strings.Trim(out, "-") } // ThreadName is "support-0042-ana": the category, the ticket number and who // opened it. func ThreadName(category string, number int, opener string) string { cat := slug(category, 30) if cat == "" { cat = "ticket" } name := fmt.Sprintf("%s-%04d", cat, number) if u := slug(opener, 32); u != "" { name += "-" + u } if len(name) > maxThreadName { name = name[:maxThreadName] } return name } func clip(s string, n int) string { s = strings.TrimSpace(s) if r := []rune(s); len(r) > n { return string(r[:n-1]) + "…" } return s } // Permissions the bot needs, by channel. const ( NeedTicketChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks | discordgo.PermissionCreatePrivateThreads | discordgo.PermissionSendMessagesInThreads | discordgo.PermissionManageThreads NeedLogChannel = discordgo.PermissionViewChannel | discordgo.PermissionSendMessages | discordgo.PermissionEmbedLinks | discordgo.PermissionAttachFiles ) var permNames = []struct { bit int64 name string }{ {discordgo.PermissionViewChannel, "View Channel"}, {discordgo.PermissionSendMessages, "Send Messages"}, {discordgo.PermissionEmbedLinks, "Embed Links"}, {discordgo.PermissionAttachFiles, "Attach Files"}, {discordgo.PermissionCreatePrivateThreads, "Create Private Threads"}, {discordgo.PermissionSendMessagesInThreads, "Send Messages in Threads"}, {discordgo.PermissionManageThreads, "Manage Threads"}, } // MissingPerms names the permissions in need that have lacks. Administrator // counts as having everything. func MissingPerms(have, need int64) []string { if have&discordgo.PermissionAdministrator != 0 { return nil } var out []string for _, p := range permNames { if need&p.bit != 0 && have&p.bit == 0 { out = append(out, p.name) } } return out }