Files
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

248 lines
7.1 KiB
Go

package tickets
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// fakeStore is an in-memory ticketrepo with the same rules as the SQL.
type fakeStore struct {
mu sync.Mutex
settings map[int64]*ticketrepo.Settings
cats []ticketrepo.Category
tickets []*ticketrepo.Ticket
next map[int64]int
cmds []ticketrepo.Command
done []int64
}
func newStore() *fakeStore {
return &fakeStore{settings: map[int64]*ticketrepo.Settings{}, next: map[int64]int{}}
}
func (f *fakeStore) configure(guild, channel, logCh int64) {
f.settings[guild] = &ticketrepo.Settings{
GuildID: guild, Enabled: true, ChannelID: channel, LogChannelID: logCh,
PanelTitle: ticketrepo.DefaultPanelTitle, PanelDescription: ticketrepo.DefaultPanelDescription,
MaxOpenPerUser: 1, Configured: true,
}
}
func (f *fakeStore) addCat(c ticketrepo.Category) ticketrepo.Category {
c.ID = int64(len(f.cats) + 1)
f.cats = append(f.cats, c)
return c
}
func (f *fakeStore) GetSettings(_ context.Context, g int64) (*ticketrepo.Settings, error) {
if s, ok := f.settings[g]; ok {
cp := *s
return &cp, nil
}
return &ticketrepo.Settings{GuildID: g, Enabled: true, PanelTitle: ticketrepo.DefaultPanelTitle, MaxOpenPerUser: 1}, nil
}
func (f *fakeStore) SetPanelMessage(_ context.Context, g, m int64) error {
f.settings[g].PanelMessageID = m
return nil
}
func (f *fakeStore) NextNumber(_ context.Context, g int64) (int, error) {
f.next[g]++
return f.next[g], nil
}
func (f *fakeStore) ListCategories(_ context.Context, g int64) ([]ticketrepo.Category, error) {
var out []ticketrepo.Category
for _, c := range f.cats {
if c.GuildID == g {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) GetCategory(_ context.Context, g, id int64) (*ticketrepo.Category, error) {
for _, c := range f.cats {
if c.GuildID == g && c.ID == id {
cp := c
return &cp, nil
}
}
return nil, nil
}
func (f *fakeStore) CountOpenByOpener(_ context.Context, g, u int64) (int, error) {
n := 0
for _, t := range f.tickets {
if t.GuildID == g && t.OpenerID == u && t.Status == ticketrepo.StatusOpen {
n++
}
}
return n, nil
}
func (f *fakeStore) CreateTicket(_ context.Context, t ticketrepo.Ticket) (int64, error) {
t.ID = int64(len(f.tickets) + 1)
t.Status = ticketrepo.StatusOpen
t.CreatedAt = time.Now().Add(-90 * time.Minute)
f.tickets = append(f.tickets, &t)
return t.ID, nil
}
func (f *fakeStore) byThread(th int64) *ticketrepo.Ticket {
for _, t := range f.tickets {
if t.ThreadID == th {
return t
}
}
return nil
}
func (f *fakeStore) GetTicketByThread(_ context.Context, th int64) (*ticketrepo.Ticket, error) {
if t := f.byThread(th); t != nil {
cp := *t
return &cp, nil
}
return nil, nil
}
func (f *fakeStore) Claim(_ context.Context, th, staff int64) (int64, error) {
t := f.byThread(th)
if t == nil || t.Status != ticketrepo.StatusOpen {
return 0, nil
}
if t.ClaimedBy == 0 {
t.ClaimedBy = staff
}
return t.ClaimedBy, nil
}
func (f *fakeStore) Close(_ context.Context, th, by int64, reason string) (bool, error) {
t := f.byThread(th)
if t == nil || t.Status != ticketrepo.StatusOpen {
return false, nil
}
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, by, reason, time.Now()
return true, nil
}
func (f *fakeStore) ListUnprocessedCommands(context.Context) ([]ticketrepo.Command, error) {
var out []ticketrepo.Command
for _, c := range f.cmds {
done := false
for _, d := range f.done {
done = done || d == c.ID
}
if !done {
out = append(out, c)
}
}
return out, nil
}
func (f *fakeStore) MarkCommandProcessed(_ context.Context, id int64) error {
f.done = append(f.done, id)
return nil
}
// fakeDiscord records what the flows ask Discord to do.
type fakeDiscord struct {
mu sync.Mutex
nextID int
threads []*discordgo.ThreadStart
members map[string][]string // thread -> user IDs
sent map[string][]*discordgo.MessageSend
edits []*discordgo.MessageEdit
chEdits map[string]*discordgo.ChannelEdit
deleted []string
files map[string]string // file name -> contents
history map[string][]*discordgo.Message
messages map[string]*discordgo.Message // by "channel/message"
failSend error
failEdit error
failFetch error
}
func newDiscord() *fakeDiscord {
return &fakeDiscord{
nextID: 1000, members: map[string][]string{}, sent: map[string][]*discordgo.MessageSend{},
chEdits: map[string]*discordgo.ChannelEdit{}, files: map[string]string{},
history: map[string][]*discordgo.Message{}, messages: map[string]*discordgo.Message{},
}
}
func (f *fakeDiscord) newID() string {
f.nextID++
return fmt.Sprint(f.nextID)
}
func (f *fakeDiscord) ThreadStartComplex(ch string, data *discordgo.ThreadStart, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.threads = append(f.threads, data)
return &discordgo.Channel{ID: f.newID(), ParentID: ch, Name: data.Name, Type: data.Type}, nil
}
func (f *fakeDiscord) ThreadMemberAdd(th, u string, _ ...discordgo.RequestOption) error {
f.members[th] = append(f.members[th], u)
return nil
}
func (f *fakeDiscord) ThreadMemberRemove(th, u string, _ ...discordgo.RequestOption) error {
var keep []string
for _, m := range f.members[th] {
if m != u {
keep = append(keep, m)
}
}
f.members[th] = keep
return nil
}
func (f *fakeDiscord) ChannelMessageSendComplex(ch string, data *discordgo.MessageSend, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if f.failSend != nil {
return nil, f.failSend
}
for _, file := range data.Files {
raw, _ := io.ReadAll(file.Reader)
f.files[file.Name] = string(raw)
}
f.sent[ch] = append(f.sent[ch], data)
m := &discordgo.Message{ID: f.newID(), ChannelID: ch, Content: data.Content, Embeds: data.Embeds, Components: data.Components}
f.messages[ch+"/"+m.ID] = m
return m, nil
}
func (f *fakeDiscord) ChannelMessageEditComplex(e *discordgo.MessageEdit, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if f.failEdit != nil {
return nil, f.failEdit
}
f.edits = append(f.edits, e)
return &discordgo.Message{ID: e.ID, ChannelID: e.Channel}, nil
}
func (f *fakeDiscord) ChannelMessage(ch, m string, _ ...discordgo.RequestOption) (*discordgo.Message, error) {
if msg, ok := f.messages[ch+"/"+m]; ok {
return msg, nil
}
return nil, errors.New("unknown message")
}
func (f *fakeDiscord) ChannelMessages(ch string, limit int, before, _, _ string, _ ...discordgo.RequestOption) ([]*discordgo.Message, error) {
if f.failFetch != nil {
return nil, f.failFetch
}
// history is stored newest first, like Discord returns it.
all := f.history[ch]
start := 0
if before != "" {
for i, m := range all {
if m.ID == before {
start = i + 1
}
}
}
end := start + limit
if end > len(all) {
end = len(all)
}
return all[start:end], nil
}
func (f *fakeDiscord) ChannelEditComplex(ch string, data *discordgo.ChannelEdit, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.chEdits[ch] = data
return &discordgo.Channel{ID: ch}, nil
}
func (f *fakeDiscord) ChannelDelete(ch string, _ ...discordgo.RequestOption) (*discordgo.Channel, error) {
f.deleted = append(f.deleted, ch)
return &discordgo.Channel{ID: ch}, nil
}