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]>
248 lines
7.1 KiB
Go
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
|
|
}
|