Files
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

200 lines
7.3 KiB
Go

// 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
}