Files
velox-bot/internal/tickets/tickets.go
T
FernandoJVideiraandClaude Opus 5.5 d294b3463b 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]>
2026-10-20 19:31:52 +01:00

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
}