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]>
198 lines
7.2 KiB
Go
198 lines
7.2 KiB
Go
// Package tickets is the support ticket system: a panel message with one
|
|
// button per category, 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
|
|
accent = 0x2b7bfa
|
|
)
|
|
|
|
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
|
|
maxPanelButtons = 5 // more categories than this switch the panel to a dropdown
|
|
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
|
|
}
|