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

+348
View File
@@ -0,0 +1,348 @@
package tickets
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
const (
guild = int64(1)
channel = int64(10)
logCh = int64(20)
staffRl = int64(30)
)
var ctx = context.Background()
func member(userID string, roles ...string) *discordgo.Member {
return &discordgo.Member{User: &discordgo.User{ID: userID, Username: "user" + userID}, Roles: roles}
}
func TestThreadName(t *testing.T) {
for _, c := range []struct{ cat, user, want string }{
{"Support", "Ana", "support-0042-ana"},
{"Ban Appeal!", "João Silva", "ban-appeal-0042-jo-o-silva"},
{"🎫", "〈ぬべ〉", "ticket-0042"},
{strings.Repeat("x", 80), strings.Repeat("y", 80), strings.Repeat("x", 30) + "-0042-" + strings.Repeat("y", 32)},
} {
if got := ThreadName(c.cat, 42, c.user); got != c.want {
t.Errorf("ThreadName(%q, %q) = %q, want %q", c.cat, c.user, got, c.want)
}
}
}
func TestIsStaff(t *testing.T) {
cat := &ticketrepo.Category{StaffRoleID: staffRl}
if !IsStaff(member("1", "30"), 0, cat) {
t.Error("the staff role should count")
}
if IsStaff(member("1", "31"), 0, cat) {
t.Error("another role must not count")
}
for _, p := range []int64{discordgo.PermissionManageThreads, discordgo.PermissionManageGuild, discordgo.PermissionAdministrator} {
if !IsStaff(member("1"), p, nil) {
t.Errorf("permission %d should count, even with the category deleted", p)
}
}
if IsStaff(member("1"), discordgo.PermissionSendMessages, &ticketrepo.Category{}) {
t.Error("a category without a staff role must not make everyone staff")
}
}
func TestMissingPerms(t *testing.T) {
got := MissingPerms(discordgo.PermissionViewChannel|discordgo.PermissionSendMessages, NeedLogChannel)
if strings.Join(got, ",") != "Embed Links,Attach Files" {
t.Errorf("missing = %v", got)
}
if MissingPerms(discordgo.PermissionAdministrator, NeedTicketChannel) != nil {
t.Error("Administrator has everything")
}
}
func TestBuildPanel(t *testing.T) {
s := &ticketrepo.Settings{PanelTitle: "Help", PanelDescription: "Pick one"}
cats := []ticketrepo.Category{
{ID: 1, Name: "Support", Emoji: "🛟", Description: "General help"},
{ID: 2, Name: "Appeal", Emoji: "<a:party:123>"},
}
embed, comps := BuildPanel(s, cats)
row := comps[0].(discordgo.ActionsRow)
b1, b2 := row.Components[0].(discordgo.Button), row.Components[1].(discordgo.Button)
if embed.Title != "Help" || b1.CustomID != "tickets:open:1" || b1.Emoji.Name != "🛟" {
t.Errorf("first button wrong: %+v", b1)
}
if b2.Emoji.ID != "123" || b2.Emoji.Name != "party" || !b2.Emoji.Animated {
t.Errorf("custom emoji not parsed: %+v", b2.Emoji)
}
if len(embed.Fields) != 1 || embed.Fields[0].Value != "General help" {
t.Errorf("descriptions belong in the embed: %+v", embed.Fields)
}
var many []ticketrepo.Category
for i := 1; i <= 7; i++ {
many = append(many, ticketrepo.Category{ID: int64(i), Name: fmt.Sprint("Cat ", i)})
}
_, comps = BuildPanel(s, many)
menu := comps[0].(discordgo.ActionsRow).Components[0].(discordgo.SelectMenu)
if menu.CustomID != "tickets:select" || len(menu.Options) != 7 || menu.Options[6].Value != "7" {
t.Errorf("more than 5 categories should use a dropdown: %+v", menu)
}
}
func TestPostPanel(t *testing.T) {
st, d := newStore(), newDiscord()
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v, want not set up", err)
}
st.configure(guild, channel, logCh)
if _, err := PostPanel(ctx, d, st, guild); !errors.Is(err, ErrNoCategories) {
t.Errorf("err = %v, want no categories", err)
}
st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support"})
msg, err := PostPanel(ctx, d, st, guild)
if err != nil || len(d.sent["10"]) != 1 || st.settings[guild].PanelMessageID != mustID(msg.ID) {
t.Fatalf("first post: err=%v sent=%d saved=%d", err, len(d.sent["10"]), st.settings[guild].PanelMessageID)
}
// A second post updates the same message.
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.edits) != 1 || len(d.sent["10"]) != 1 {
t.Errorf("repost should edit: err=%v edits=%d sent=%d", err, len(d.edits), len(d.sent["10"]))
}
// If the old panel is gone, a new one is posted and remembered.
d.failEdit = errors.New("unknown message")
old := st.settings[guild].PanelMessageID
if _, err := PostPanel(ctx, d, st, guild); err != nil || len(d.sent["10"]) != 2 || st.settings[guild].PanelMessageID == old {
t.Errorf("deleted panel should be replaced: err=%v sent=%d", err, len(d.sent["10"]))
}
}
func setup() (*fakeStore, *fakeDiscord, ticketrepo.Category) {
st, d := newStore(), newDiscord()
st.configure(guild, channel, logCh)
cat := st.addCat(ticketrepo.Category{GuildID: guild, Name: "Support", StaffRoleID: staffRl})
return st, d, cat
}
func open(t *testing.T, st *fakeStore, d *fakeDiscord, cat ticketrepo.Category, userID string) *discordgo.Channel {
t.Helper()
th, err := Open(ctx, d, st, OpenRequest{
GuildID: guild, CategoryID: cat.ID,
User: &discordgo.User{ID: userID, Username: "ana", GlobalName: "Ana"},
Subject: "Can't join voice", Details: "It says no permission",
})
if err != nil {
t.Fatal(err)
}
return th
}
func TestCheckCanOpen(t *testing.T) {
st := newStore()
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrNotSetUp) {
t.Errorf("err = %v", err)
}
st.configure(guild, channel, logCh)
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 99); !errors.Is(err, ErrUnknownCategory) {
t.Errorf("err = %v", err)
}
st.settings[guild].Enabled = false
if _, _, err := CheckCanOpen(ctx, st, guild, 5, 1); !errors.Is(err, ErrDisabled) {
t.Errorf("err = %v", err)
}
}
func TestOpen(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
start := d.threads[0]
if start.Type != discordgo.ChannelTypeGuildPrivateThread || start.Invitable || start.Name != "support-0001-ana" {
t.Errorf("thread wrong: %+v", start)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("opener not added: %v", d.members[th.ID])
}
intro := d.sent[th.ID][0]
if intro.Content != "<@500> <@&30>" || strings.Join(intro.AllowedMentions.Roles, ",") != "30" {
t.Errorf("the staff role must be mentioned so its members join: %+v", intro)
}
if !strings.Contains(intro.Embeds[0].Description, "It says no permission") || intro.Embeds[0].Title != "Ticket #0001 · Support" {
t.Errorf("intro embed wrong: %+v", intro.Embeds[0])
}
tk := st.tickets[0]
if tk.Number != 1 || tk.ThreadID != mustID(th.ID) || tk.OpenerID != 500 || tk.IntroMessage == 0 || tk.Subject != "Can't join voice" {
t.Errorf("ticket row wrong: %+v", tk)
}
// The limit applies to the second ticket.
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "again"})
var ue UserError
if !errors.As(err, &ue) || !strings.Contains(string(ue), "already have an open ticket") {
t.Errorf("err = %v", err)
}
}
func TestOpenCleansUpOnFailure(t *testing.T) {
st, d, cat := setup()
d.failSend = errors.New("missing access")
_, err := Open(ctx, d, st, OpenRequest{GuildID: guild, CategoryID: cat.ID, User: &discordgo.User{ID: "500"}, Subject: "help"})
if err == nil || len(d.deleted) != 1 || len(st.tickets) != 0 {
t.Errorf("a half-made ticket must be removed: err=%v deleted=%v tickets=%d", err, d.deleted, len(st.tickets))
}
}
func TestClaim(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("600")}); !errors.Is(err, ErrNotStaff) {
t.Errorf("non-staff claim: %v", err)
}
if err := Claim(ctx, d, st, th.ID, Actor{Member: member("700", "30")}); err != nil {
t.Fatal(err)
}
if st.tickets[0].ClaimedBy != 700 {
t.Errorf("claim not saved")
}
if len(d.edits) != 1 {
t.Fatalf("the first message should be updated")
}
e := (*d.edits[0].Embeds)[0]
if e.Fields[1].Value != "<@700>" || !strings.Contains(e.Description, "It says no permission") {
t.Errorf("updated intro lost details or claim: %+v", e)
}
if b := (*d.edits[0].Components)[0].(discordgo.ActionsRow).Components[0].(discordgo.Button); !b.Disabled || b.Label != "Claimed" {
t.Errorf("claim button should be disabled: %+v", b)
}
err := Claim(ctx, d, st, th.ID, Actor{Member: member("701"), Perms: discordgo.PermissionManageThreads})
if err == nil || !strings.Contains(err.Error(), "<@700> is already handling") {
t.Errorf("second claim: %v", err)
}
if err := Claim(ctx, d, st, "999", Actor{Member: member("700", "30")}); !errors.Is(err, ErrNotATicket) {
t.Errorf("claim outside a ticket: %v", err)
}
}
func TestClose(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ts := time.Date(2026, 10, 20, 18, 0, 0, 0, time.UTC)
d.history[th.ID] = []*discordgo.Message{ // newest first
{ID: "3", Content: "thanks, works now", Author: &discordgo.User{Username: "ana"}, Timestamp: ts.Add(2 * time.Minute)},
{ID: "2", Content: "try rejoining", Author: &discordgo.User{Username: "mod", GlobalName: "Mod"}, Timestamp: ts.Add(time.Minute),
Attachments: []*discordgo.MessageAttachment{{Filename: "steps.png", URL: "https://cdn/x.png"}}},
{ID: "1", Author: &discordgo.User{Username: "velox", Bot: true}, Embeds: []*discordgo.MessageEmbed{{Title: "Ticket #0001 · Support"}}, Timestamp: ts},
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("600")}, ""); !errors.Is(err, ErrNotInvolved) {
t.Errorf("an outsider must not close: %v", err)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, "Solved"); err != nil {
t.Fatal(err)
}
tk := st.tickets[0]
if tk.Status != ticketrepo.StatusClosed || tk.ClosedBy != 500 || tk.CloseReason != "Solved" {
t.Errorf("ticket not closed: %+v", tk)
}
if e := d.chEdits[th.ID]; e == nil || !*e.Locked || !*e.Archived {
t.Errorf("thread should be locked and archived: %+v", e)
}
logged := d.sent["20"]
if len(logged) != 1 || !strings.Contains(logged[0].Embeds[0].Title, "Ticket #0001 closed") {
t.Fatalf("log channel post missing: %+v", logged)
}
transcript := d.files["ticket-0001.txt"]
i1, i2, i3 := strings.Index(transcript, "[embed] Ticket #0001"), strings.Index(transcript, "try rejoining"), strings.Index(transcript, "thanks, works now")
if i1 < 0 || i2 < i1 || i3 < i2 {
t.Errorf("transcript must be oldest first:\n%s", transcript)
}
if !strings.Contains(transcript, "Mod (mod)") || !strings.Contains(transcript, "[file] steps.png https://cdn/x.png") || !strings.Contains(transcript, "velox [bot]") {
t.Errorf("transcript details missing:\n%s", transcript)
}
if err := Close(ctx, d, st, th.ID, Actor{Member: member("500")}, ""); !errors.Is(err, ErrClosed) {
t.Errorf("closing twice: %v", err)
}
// A closed ticket no longer counts towards the limit.
open(t, st, d, cat, "500")
}
func TestTranscriptPages(t *testing.T) {
d := newDiscord()
var hist []*discordgo.Message
for i := 250; i >= 1; i-- {
hist = append(hist, &discordgo.Message{ID: fmt.Sprint(i), Content: fmt.Sprint("msg ", i), Author: &discordgo.User{Username: "u"}})
}
d.history["5"] = hist
out, n, err := Transcript(d, &ticketrepo.Ticket{ThreadID: 5, Number: 1})
if err != nil || n != 250 {
t.Fatalf("n=%d err=%v", n, err)
}
if strings.Index(string(out), "msg 1\n") > strings.Index(string(out), "msg 250\n") {
t.Error("pages must be stitched oldest first")
}
d.failFetch = errors.New("boom")
out, _, err = Transcript(d, &ticketrepo.Ticket{ThreadID: 5})
if err == nil || !strings.Contains(string(out), "only part of the thread") {
t.Error("a failed read should still produce a transcript that says so")
}
}
func TestAddAndRemoveMember(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("600")}, "601"); !errors.Is(err, ErrNotInvolved) {
t.Errorf("outsider add: %v", err)
}
if err := AddMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); err != nil {
t.Errorf("the opener can add: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("500")}, "601"); !errors.Is(err, ErrNotStaff) {
t.Errorf("only staff remove: %v", err)
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "500"); err == nil {
t.Error("the opener can't be removed")
}
if err := RemoveMember(ctx, d, st, th.ID, Actor{Member: member("700", "30")}, "601"); err != nil {
t.Error(err)
}
if strings.Join(d.members[th.ID], ",") != "500" {
t.Errorf("members = %v", d.members[th.ID])
}
}
func TestThreadDeletedFreesTheLimit(t *testing.T) {
st, d, cat := setup()
th := open(t, st, d, cat, "500")
ThreadDeleted(ctx, st, th.ID)
if st.tickets[0].Status != ticketrepo.StatusClosed || st.tickets[0].CloseReason != "Thread deleted" {
t.Errorf("ticket = %+v", st.tickets[0])
}
open(t, st, d, cat, "500")
}
func TestProcessCommands(t *testing.T) {
st, d, _ := setup()
st.cmds = []ticketrepo.Command{{ID: 1, GuildID: guild, CommandType: "post_panel"}, {ID: 2, GuildID: 99, CommandType: "post_panel"}, {ID: 3, CommandType: "nope"}}
ProcessCommands(ctx, d, st)
if len(d.sent["10"]) != 1 {
t.Errorf("panel not posted")
}
if len(st.done) != 3 {
t.Errorf("every command must be marked done, even failed ones: %v", st.done)
}
}