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]>
This commit is contained in:
FernandoJVideiraandClaude Opus 5.5 committed 2026-10-20 19:31:52 +01:00
1 parent b1ea5f17e3
commit d294b3463b
20 files changed
+2800

No files matched your search

+301
View File
@@ -0,0 +1,301 @@
// Package ticketrepo stores the ticket system's settings, categories, tickets
// and the dashboard's requests to the bot. Discord IDs are int64; 0 means
// "not set" for optional ones.
package ticketrepo
import (
"context"
"database/sql"
"errors"
"time"
)
type Repo struct {
db *sql.DB
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
const (
DefaultPanelTitle = "Need help?"
DefaultPanelDescription = "Pick a category below to open a private ticket with the staff team."
DefaultMaxOpenPerUser = 1
StatusOpen = "open"
StatusClosed = "closed"
)
type Settings struct {
GuildID int64
Enabled bool
ChannelID int64
LogChannelID int64
PanelMessageID int64
PanelTitle string
PanelDescription string
MaxOpenPerUser int
// Configured is false when the guild has no settings row yet; the other
// fields then hold the defaults.
Configured bool
}
type Category struct {
ID int64
GuildID int64
Name string
Emoji string
Description string
StaffRoleID int64
Position int
}
type Ticket struct {
ID int64
GuildID int64
Number int
CategoryID int64
CategoryName string
ThreadID int64
IntroMessage int64
OpenerID int64
Subject string
Status string
ClaimedBy int64
ClosedBy int64
CloseReason string
CreatedAt time.Time
ClosedAt time.Time // zero while open
}
type Command struct {
ID int64
GuildID int64
CommandType string
RequestedBy int64
}
func nullID(v int64) sql.NullInt64 {
return sql.NullInt64{Int64: v, Valid: v != 0}
}
func nullText(v string) sql.NullString {
return sql.NullString{String: v, Valid: v != ""}
}
func (r *Repo) GetSettings(ctx context.Context, guildID int64) (*Settings, error) {
const q = `
SELECT enabled, channel_id, log_channel_id, panel_message_id, panel_title, panel_description, max_open_per_user
FROM ticket_settings WHERE guild_id = $1`
var ch, logCh, panel sql.NullInt64
s := &Settings{GuildID: guildID}
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&s.Enabled, &ch, &logCh, &panel, &s.PanelTitle, &s.PanelDescription, &s.MaxOpenPerUser)
switch {
case errors.Is(err, sql.ErrNoRows):
return &Settings{
GuildID: guildID,
Enabled: true,
PanelTitle: DefaultPanelTitle,
PanelDescription: DefaultPanelDescription,
MaxOpenPerUser: DefaultMaxOpenPerUser,
}, nil
case err != nil:
return nil, err
}
s.ChannelID, s.LogChannelID, s.PanelMessageID = ch.Int64, logCh.Int64, panel.Int64
s.Configured = true
return s, nil
}
// SetChannels saves where tickets live and where transcripts go. A zero log
// channel clears it.
func (r *Repo) SetChannels(ctx context.Context, guildID, channelID, logChannelID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, channel_id, log_channel_id)
VALUES ($1, $2, $3)
ON CONFLICT (guild_id) DO UPDATE SET
channel_id = EXCLUDED.channel_id,
log_channel_id = EXCLUDED.log_channel_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(channelID), nullID(logChannelID))
return err
}
func (r *Repo) SetPanelMessage(ctx context.Context, guildID, messageID int64) error {
const q = `
INSERT INTO ticket_settings (guild_id, panel_message_id) VALUES ($1, $2)
ON CONFLICT (guild_id) DO UPDATE SET panel_message_id = EXCLUDED.panel_message_id`
_, err := r.db.ExecContext(ctx, q, guildID, nullID(messageID))
return err
}
// NextNumber hands out the guild's next ticket number. It is a single
// statement, so two tickets opened at the same moment never share a number.
func (r *Repo) NextNumber(ctx context.Context, guildID int64) (int, error) {
const q = `
INSERT INTO ticket_settings (guild_id, next_number) VALUES ($1, 2)
ON CONFLICT (guild_id) DO UPDATE SET next_number = ticket_settings.next_number + 1
RETURNING next_number - 1`
var n int
err := r.db.QueryRowContext(ctx, q, guildID).Scan(&n)
return n, err
}
const categoryCols = `id, guild_id, name, COALESCE(emoji, ''), description, COALESCE(staff_role_id, 0), position`
func scanCategory(row interface{ Scan(...any) error }) (Category, error) {
var c Category
err := row.Scan(&c.ID, &c.GuildID, &c.Name, &c.Emoji, &c.Description, &c.StaffRoleID, &c.Position)
return c, err
}
func (r *Repo) ListCategories(ctx context.Context, guildID int64) ([]Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 ORDER BY position, id`
rows, err := r.db.QueryContext(ctx, q, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Category
for rows.Next() {
c, err := scanCategory(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// GetCategory returns nil when the category does not exist in that guild.
func (r *Repo) GetCategory(ctx context.Context, guildID, id int64) (*Category, error) {
q := `SELECT ` + categoryCols + ` FROM ticket_categories WHERE guild_id = $1 AND id = $2`
c, err := scanCategory(r.db.QueryRowContext(ctx, q, guildID, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &c, nil
}
// AddCategory appends a category after the existing ones.
func (r *Repo) AddCategory(ctx context.Context, c Category) (*Category, error) {
q := `
INSERT INTO ticket_categories (guild_id, name, emoji, description, staff_role_id, position)
VALUES ($1, $2, $3, $4, $5,
(SELECT COALESCE(MAX(position) + 1, 0) FROM ticket_categories WHERE guild_id = $1))
RETURNING ` + categoryCols
out, err := scanCategory(r.db.QueryRowContext(ctx, q, c.GuildID, c.Name, nullText(c.Emoji), c.Description, nullID(c.StaffRoleID)))
if err != nil {
return nil, err
}
return &out, nil
}
// DeleteCategoryByName removes a category (case-insensitively) and reports
// whether one was found. Tickets keep their copied category name.
func (r *Repo) DeleteCategoryByName(ctx context.Context, guildID int64, name string) (bool, error) {
const q = `DELETE FROM ticket_categories WHERE guild_id = $1 AND lower(name) = lower($2)`
res, err := r.db.ExecContext(ctx, q, guildID, name)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) CountOpenByOpener(ctx context.Context, guildID, openerID int64) (int, error) {
const q = `SELECT COUNT(*) FROM tickets WHERE guild_id = $1 AND opener_id = $2 AND status = 'open'`
var n int
err := r.db.QueryRowContext(ctx, q, guildID, openerID).Scan(&n)
return n, err
}
func (r *Repo) CreateTicket(ctx context.Context, t Ticket) (int64, error) {
const q = `
INSERT INTO tickets (guild_id, number, category_id, category_name, thread_id, intro_message_id, opener_id, subject)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`
var id int64
err := r.db.QueryRowContext(ctx, q, t.GuildID, t.Number, nullID(t.CategoryID), t.CategoryName, t.ThreadID, nullID(t.IntroMessage), t.OpenerID, t.Subject).Scan(&id)
return id, err
}
const ticketCols = `id, guild_id, number, COALESCE(category_id, 0), category_name, thread_id, COALESCE(intro_message_id, 0), opener_id, subject, status,
COALESCE(claimed_by, 0), COALESCE(closed_by, 0), COALESCE(close_reason, ''), created_at, closed_at`
func scanTicket(row interface{ Scan(...any) error }) (Ticket, error) {
var t Ticket
var closedAt sql.NullTime
err := row.Scan(&t.ID, &t.GuildID, &t.Number, &t.CategoryID, &t.CategoryName, &t.ThreadID, &t.IntroMessage, &t.OpenerID, &t.Subject, &t.Status,
&t.ClaimedBy, &t.ClosedBy, &t.CloseReason, &t.CreatedAt, &closedAt)
t.ClosedAt = closedAt.Time
return t, err
}
// GetTicketByThread returns nil when the thread is not a ticket.
func (r *Repo) GetTicketByThread(ctx context.Context, threadID int64) (*Ticket, error) {
q := `SELECT ` + ticketCols + ` FROM tickets WHERE thread_id = $1`
t, err := scanTicket(r.db.QueryRowContext(ctx, q, threadID))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &t, nil
}
// Claim sets the ticket's handler unless someone already has it, and returns
// whoever holds it afterwards (the caller when the claim worked).
func (r *Repo) Claim(ctx context.Context, threadID, staffID int64) (int64, error) {
const q = `
UPDATE tickets SET claimed_by = COALESCE(claimed_by, $2)
WHERE thread_id = $1 AND status = 'open'
RETURNING claimed_by`
var holder int64
err := r.db.QueryRowContext(ctx, q, threadID, staffID).Scan(&holder)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return holder, err
}
// Close marks an open ticket closed and reports whether this call closed it,
// so two people pressing Close at once only produce one transcript.
func (r *Repo) Close(ctx context.Context, threadID, closedBy int64, reason string) (bool, error) {
const q = `
UPDATE tickets SET status = 'closed', closed_by = $2, close_reason = $3, closed_at = NOW()
WHERE thread_id = $1 AND status = 'open'`
res, err := r.db.ExecContext(ctx, q, threadID, nullID(closedBy), nullText(reason))
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}
func (r *Repo) ListUnprocessedCommands(ctx context.Context) ([]Command, error) {
const q = `SELECT id, guild_id, command_type, requested_by FROM ticket_commands WHERE processed_at IS NULL ORDER BY id`
rows, err := r.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Command
for rows.Next() {
var c Command
if err := rows.Scan(&c.ID, &c.GuildID, &c.CommandType, &c.RequestedBy); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *Repo) MarkCommandProcessed(ctx context.Context, id int64) error {
_, err := r.db.ExecContext(ctx, `UPDATE ticket_commands SET processed_at = NOW() WHERE id = $1`, id)
return err
}
@@ -0,0 +1,206 @@
//go:build integration
// Runs against a real Postgres with the dashboard API's migrations applied:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/db/repos/ticketrepo
package ticketrepo
import (
"context"
"database/sql"
"os"
"sync"
"testing"
_ "github.com/jackc/pgx/v5/stdlib"
)
func testRepo(t *testing.T) (*Repo, int64) {
t.Helper()
dsn := os.Getenv("TICKETS_TEST_DB")
if dsn == "" {
t.Skip("TICKETS_TEST_DB not set")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
const guild = 424242
for _, q := range []string{
`DELETE FROM tickets WHERE guild_id = $1`,
`DELETE FROM ticket_categories WHERE guild_id = $1`,
`DELETE FROM ticket_settings WHERE guild_id = $1`,
`DELETE FROM ticket_commands WHERE guild_id = $1`,
} {
if _, err := db.Exec(q, guild); err != nil {
t.Fatal(err)
}
}
return NewRepo(db), guild
}
func TestSettingsDefaultsAndChannels(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
s, err := r.GetSettings(ctx, g)
if err != nil || s.Configured || !s.Enabled || s.PanelTitle != DefaultPanelTitle || s.MaxOpenPerUser != 1 {
t.Fatalf("defaults wrong: %+v %v", s, err)
}
if err := r.SetChannels(ctx, g, 111, 222); err != nil {
t.Fatal(err)
}
if err := r.SetPanelMessage(ctx, g, 333); err != nil {
t.Fatal(err)
}
s, _ = r.GetSettings(ctx, g)
if !s.Configured || s.ChannelID != 111 || s.LogChannelID != 222 || s.PanelMessageID != 333 {
t.Fatalf("saved settings wrong: %+v", s)
}
// Clearing the log channel.
_ = r.SetChannels(ctx, g, 111, 0)
s, _ = r.GetSettings(ctx, g)
if s.LogChannelID != 0 {
t.Errorf("log channel not cleared: %+v", s)
}
}
func TestNextNumberIsUniqueUnderConcurrency(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
var mu sync.Mutex
seen := map[int]bool{}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
n, err := r.NextNumber(ctx, g)
if err != nil {
t.Error(err)
return
}
mu.Lock()
defer mu.Unlock()
if seen[n] {
t.Errorf("number %d handed out twice", n)
}
seen[n] = true
}()
}
wg.Wait()
for n := 1; n <= 20; n++ {
if !seen[n] {
t.Errorf("number %d missing: numbers should be 1..20", n)
}
}
}
func TestCategories(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
a, err := r.AddCategory(ctx, Category{GuildID: g, Name: "Support", Emoji: "🛟", StaffRoleID: 9})
if err != nil {
t.Fatal(err)
}
b, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Appeal", Description: "Ban appeals"})
if a.Position != 0 || b.Position != 1 || b.Emoji != "" || b.StaffRoleID != 0 {
t.Fatalf("positions or defaults wrong: %+v %+v", a, b)
}
list, _ := r.ListCategories(ctx, g)
if len(list) != 2 || list[0].Name != "Support" || list[1].Description != "Ban appeals" {
t.Fatalf("list wrong: %+v", list)
}
if c, _ := r.GetCategory(ctx, g, a.ID); c == nil || c.Emoji != "🛟" {
t.Errorf("get wrong: %+v", c)
}
if c, _ := r.GetCategory(ctx, g+1, a.ID); c != nil {
t.Error("a category must not be readable from another guild")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "support"); !ok {
t.Error("delete should match case-insensitively")
}
if ok, _ := r.DeleteCategoryByName(ctx, g, "nope"); ok {
t.Error("deleting a missing category should report false")
}
}
func TestTicketLifecycle(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
cat, _ := r.AddCategory(ctx, Category{GuildID: g, Name: "Support"})
id, err := r.CreateTicket(ctx, Ticket{GuildID: g, Number: 1, CategoryID: cat.ID, CategoryName: "Support", ThreadID: 5001, IntroMessage: 6001, OpenerID: 77, Subject: "Help"})
if err != nil || id == 0 {
t.Fatal(err)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 1 {
t.Errorf("open count = %d", n)
}
if holder, _ := r.Claim(ctx, 5001, 10); holder != 10 {
t.Errorf("first claim should win, holder = %d", holder)
}
if holder, _ := r.Claim(ctx, 5001, 11); holder != 10 {
t.Errorf("second claim must not take over, holder = %d", holder)
}
if ok, _ := r.Close(ctx, 5001, 10, "solved"); !ok {
t.Error("first close should succeed")
}
if ok, _ := r.Close(ctx, 5001, 11, ""); ok {
t.Error("closing twice must report false")
}
tk, _ := r.GetTicketByThread(ctx, 5001)
if tk == nil || tk.Status != StatusClosed || tk.ClosedBy != 10 || tk.CloseReason != "solved" || tk.ClosedAt.IsZero() || tk.ClaimedBy != 10 || tk.IntroMessage != 6001 {
t.Fatalf("closed ticket wrong: %+v", tk)
}
if n, _ := r.CountOpenByOpener(ctx, g, 77); n != 0 {
t.Errorf("closed ticket still counted as open")
}
if holder, _ := r.Claim(ctx, 5001, 12); holder != 0 {
t.Error("a closed ticket cannot be claimed")
}
// Deleting the category keeps the ticket and its copied name.
_, _ = r.DeleteCategoryByName(ctx, g, "Support")
tk, _ = r.GetTicketByThread(ctx, 5001)
if tk.CategoryID != 0 || tk.CategoryName != "Support" {
t.Errorf("ticket after category delete: %+v", tk)
}
if tk, _ := r.GetTicketByThread(ctx, 999); tk != nil {
t.Error("unknown thread should return nil")
}
}
func TestCommands(t *testing.T) {
r, g := testRepo(t)
ctx := context.Background()
if _, err := r.db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 5)`, g); err != nil {
t.Fatal(err)
}
cmds, err := r.ListUnprocessedCommands(ctx)
if err != nil {
t.Fatal(err)
}
var mine *Command
for i := range cmds {
if cmds[i].GuildID == g {
mine = &cmds[i]
}
}
if mine == nil || mine.CommandType != "post_panel" || mine.RequestedBy != 5 {
t.Fatalf("command not listed: %+v", cmds)
}
_ = r.MarkCommandProcessed(ctx, mine.ID)
cmds, _ = r.ListUnprocessedCommands(ctx)
for _, c := range cmds {
if c.ID == mine.ID {
t.Error("processed command still listed")
}
}
}