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

+247
View File
@@ -0,0 +1,247 @@
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
}
+63
View File
@@ -0,0 +1,63 @@
package tickets
import (
"context"
"errors"
"fmt"
"log"
"github.com/jackc/pgx/v5"
)
// StartCommandListener waits for the dashboard's requests (ticket_commands
// rows, announced with NOTIFY) and carries them out. It returns when the
// connection fails or ctx ends.
func StartCommandListener(ctx context.Context, connString string, d Discord, st Store) error {
conn, err := pgx.Connect(ctx, connString)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer conn.Close(ctx)
if _, err := conn.Exec(ctx, "LISTEN ticket_commands"); err != nil {
return fmt.Errorf("listen: %w", err)
}
// Anything queued while the bot was down.
ProcessCommands(ctx, d, st)
for {
if _, err := conn.WaitForNotification(ctx); err != nil {
return fmt.Errorf("waiting for notifications: %w", err)
}
ProcessCommands(ctx, d, st)
}
}
// ProcessCommands carries out every pending command once. A command that
// fails is still marked done: retrying a panel post that failed for missing
// permissions would only fail again, and the error is logged.
func ProcessCommands(ctx context.Context, d Discord, st Store) {
cmds, err := st.ListUnprocessedCommands(ctx)
if err != nil {
log.Printf("tickets: listing dashboard commands: %v", err)
return
}
for _, c := range cmds {
switch c.CommandType {
case "post_panel":
if _, err := PostPanel(ctx, d, st, c.GuildID); err != nil {
var ue UserError
if errors.As(err, &ue) {
log.Printf("tickets: posting the panel for guild %d from the dashboard: %s", c.GuildID, ue)
} else {
log.Printf("tickets: posting the panel for guild %d from the dashboard: %v", c.GuildID, err)
}
}
default:
log.Printf("tickets: unknown dashboard command %q (%d)", c.CommandType, c.ID)
}
if err := st.MarkCommandProcessed(ctx, c.ID); err != nil {
log.Printf("tickets: marking command %d done: %v", c.ID, err)
}
}
}
@@ -0,0 +1,71 @@
//go:build integration
package tickets
import (
"context"
"database/sql"
"os"
"testing"
"time"
"velox-bot/internal/db/repos/ticketrepo"
_ "github.com/jackc/pgx/v5/stdlib"
)
// The dashboard's request reaches the bot through the real trigger and
// LISTEN/NOTIFY:
//
// TICKETS_TEST_DB=postgres://... go test -tags integration ./internal/tickets
func TestListenerPostsPanelOnNotify(t *testing.T) {
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)
}
defer db.Close()
const g = 515151
for _, q := range []string{`DELETE FROM ticket_commands`, `DELETE FROM ticket_categories WHERE guild_id = $1`, `DELETE FROM ticket_settings WHERE guild_id = $1`} {
if q == `DELETE FROM ticket_commands` {
_, err = db.Exec(q)
} else {
_, err = db.Exec(q, g)
}
if err != nil {
t.Fatal(err)
}
}
repo := ticketrepo.NewRepo(db)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := repo.SetChannels(ctx, g, 10, 0); err != nil {
t.Fatal(err)
}
if _, err := repo.AddCategory(ctx, ticketrepo.Category{GuildID: g, Name: "Support"}); err != nil {
t.Fatal(err)
}
d := newDiscord()
go func() { _ = StartCommandListener(ctx, dsn, d, repo) }()
time.Sleep(500 * time.Millisecond) // let LISTEN start
if _, err := db.Exec(`INSERT INTO ticket_commands (guild_id, command_type, requested_by) VALUES ($1, 'post_panel', 1)`, g); err != nil {
t.Fatal(err)
}
for ctx.Err() == nil {
s, _ := repo.GetSettings(ctx, g)
if s.PanelMessageID != 0 {
cmds, _ := repo.ListUnprocessedCommands(ctx)
if len(cmds) != 0 {
t.Errorf("command not marked processed: %+v", cmds)
}
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("the panel was never posted")
}
+271
View File
@@ -0,0 +1,271 @@
package tickets
import (
"bytes"
"context"
"fmt"
"log"
"time"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Actor is whoever pressed the button or ran the command, with their
// permissions in the ticket thread.
type Actor struct {
Member *discordgo.Member
Perms int64
}
func (a Actor) userID() string {
if a.Member == nil || a.Member.User == nil {
return ""
}
return a.Member.User.ID
}
// load finds the open ticket behind a thread and its category (nil when the
// category was deleted since).
func load(ctx context.Context, st Store, threadID string) (*ticketrepo.Ticket, *ticketrepo.Category, error) {
t, err := st.GetTicketByThread(ctx, mustID(threadID))
if err != nil {
return nil, nil, err
}
if t == nil {
return nil, nil, ErrNotATicket
}
if t.Status != ticketrepo.StatusOpen {
return nil, nil, ErrClosed
}
var cat *ticketrepo.Category
if t.CategoryID != 0 {
if cat, err = st.GetCategory(ctx, t.GuildID, t.CategoryID); err != nil {
return nil, nil, err
}
}
return t, cat, nil
}
// updateIntro edits the ticket's first message to show who claimed it and
// whether it is closed. It keeps the original text (the details are only in
// that message) and is best effort: a deleted first message doesn't stop the
// ticket from being claimed or closed.
func updateIntro(d Discord, t *ticketrepo.Ticket) {
if t.IntroMessage == 0 {
return
}
msg, err := d.ChannelMessage(id(t.ThreadID), id(t.IntroMessage))
if err != nil || len(msg.Embeds) == 0 {
return
}
fresh := IntroEmbed(t, "")
e := *msg.Embeds[0]
e.Color, e.Footer = fresh.Color, fresh.Footer
e.Fields = fresh.Fields
embeds := []*discordgo.MessageEmbed{&e}
comps := IntroComponents(t.ClaimedBy != 0, t.Status == ticketrepo.StatusClosed)
if _, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: id(t.ThreadID), ID: id(t.IntroMessage), Embeds: &embeds, Components: &comps,
}); err != nil {
log.Printf("tickets: updating the first message of ticket %d: %v", t.ID, err)
}
}
// Claim marks the actor as the ticket's handler. Only staff can claim, and a
// claimed ticket stays with its first claimer.
func Claim(ctx context.Context, d Discord, st Store, threadID string, a Actor) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if !IsStaff(a.Member, a.Perms, cat) {
return ErrNotStaff
}
me := mustID(a.userID())
holder, err := st.Claim(ctx, t.ThreadID, me)
if err != nil {
return err
}
if holder == 0 {
return ErrClosed
}
if holder != me {
return UserError(fmt.Sprintf("<@%d> is already handling this ticket.", holder))
}
t.ClaimedBy = me
updateIntro(d, t)
_, err = d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
Content: fmt.Sprintf("<@%d> is handling this ticket.", me),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// CheckCanClose runs before the close form is shown.
func CheckCanClose(ctx context.Context, st Store, threadID string, a Actor) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
return nil
}
// Close closes the ticket: a note in the thread, a summary with the
// transcript in the log channel, the first message's buttons disabled, and
// the thread locked and archived. The opener or staff can close.
func Close(ctx context.Context, d Discord, st Store, threadID string, a Actor, reason string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
me := mustID(a.userID())
if me != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
reason = clip(reason, maxReason)
// The transcript is taken before anything else changes in the thread.
transcript, count, terr := Transcript(d, t)
closed, err := st.Close(ctx, t.ThreadID, me, reason)
if err != nil {
return err
}
if !closed {
return ErrClosed // someone else closed it a moment ago
}
t.Status, t.ClosedBy, t.CloseReason, t.ClosedAt = ticketrepo.StatusClosed, me, reason, time.Now()
note := fmt.Sprintf("🔒 Closed by <@%d>.", me)
if reason != "" {
note += " Reason: " + reason
}
if _, err := d.ChannelMessageSendComplex(id(t.ThreadID), &discordgo.MessageSend{
Content: note, AllowedMentions: &discordgo.MessageAllowedMentions{},
}); err != nil {
log.Printf("tickets: posting the close note in ticket %d: %v", t.ID, err)
}
if terr != nil {
log.Printf("tickets: transcript of ticket %d: %v", t.ID, terr)
}
postLog(ctx, d, st, t, transcript, count)
updateIntro(d, t)
yes := true
if _, err := d.ChannelEditComplex(id(t.ThreadID), &discordgo.ChannelEdit{Locked: &yes, Archived: &yes}); err != nil {
return fmt.Errorf("locking the thread: %w", err)
}
return nil
}
// postLog sends the closed ticket's summary and transcript to the log
// channel, if the guild has one.
func postLog(ctx context.Context, d Discord, st Store, t *ticketrepo.Ticket, transcript []byte, count int) {
s, err := st.GetSettings(ctx, t.GuildID)
if err != nil || s.LogChannelID == 0 {
return
}
claimed := "Nobody"
if t.ClaimedBy != 0 {
claimed = "<@" + id(t.ClaimedBy) + ">"
}
reason := t.CloseReason
if reason == "" {
reason = "No reason given"
}
embed := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d closed · %s", t.Number, clip(t.CategoryName, 60)),
Description: "**" + clip(t.Subject, maxSubject) + "**",
Color: 0x6b7280,
Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true},
{Name: "Closed by", Value: "<@" + id(t.ClosedBy) + ">", Inline: true},
{Name: "Reason", Value: clip(reason, 1024)},
{Name: "Thread", Value: "<#" + id(t.ThreadID) + ">", Inline: true},
{Name: "Messages", Value: fmt.Sprint(count), Inline: true},
{Name: "Open for", Value: humanDuration(t.ClosedAt.Sub(t.CreatedAt)), Inline: true},
},
Timestamp: t.ClosedAt.UTC().Format(time.RFC3339),
}
send := &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{embed}, AllowedMentions: &discordgo.MessageAllowedMentions{}}
if len(transcript) > 0 {
send.Files = []*discordgo.File{{
Name: fmt.Sprintf("ticket-%04d.txt", t.Number),
ContentType: "text/plain; charset=utf-8",
Reader: bytes.NewReader(transcript),
}}
}
if _, err := d.ChannelMessageSendComplex(id(s.LogChannelID), send); err != nil {
log.Printf("tickets: posting ticket %d to the log channel: %v", t.ID, err)
}
}
func humanDuration(d time.Duration) string {
switch {
case d <= 0:
return "-"
case d < time.Hour:
return fmt.Sprintf("%d min", int(d.Minutes()+0.5))
case d < 48*time.Hour:
return fmt.Sprintf("%.1f h", d.Hours())
default:
return fmt.Sprintf("%.1f days", d.Hours()/24)
}
}
// AddMember brings someone into a ticket. The opener and staff can add.
func AddMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if mustID(a.userID()) != t.OpenerID && !IsStaff(a.Member, a.Perms, cat) {
return ErrNotInvolved
}
if err := d.ThreadMemberAdd(threadID, userID); err != nil {
return fmt.Errorf("adding to the thread: %w", err)
}
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
Content: fmt.Sprintf("<@%s> added <@%s> to this ticket.", a.userID(), userID),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// RemoveMember takes someone out of a ticket. Only staff can, and never the
// opener.
func RemoveMember(ctx context.Context, d Discord, st Store, threadID string, a Actor, userID string) error {
t, cat, err := load(ctx, st, threadID)
if err != nil {
return err
}
if !IsStaff(a.Member, a.Perms, cat) {
return ErrNotStaff
}
if mustID(userID) == t.OpenerID {
return UserError("The person who opened the ticket can't be removed. Close the ticket instead.")
}
if err := d.ThreadMemberRemove(threadID, userID); err != nil {
return fmt.Errorf("removing from the thread: %w", err)
}
_, err = d.ChannelMessageSendComplex(threadID, &discordgo.MessageSend{
Content: fmt.Sprintf("<@%s> removed <@%s> from this ticket.", a.userID(), userID),
AllowedMentions: &discordgo.MessageAllowedMentions{},
})
return err
}
// ThreadDeleted closes the ticket of a thread someone deleted by hand, so it
// stops counting towards the opener's limit.
func ThreadDeleted(ctx context.Context, st Store, threadID string) {
if _, err := st.Close(ctx, mustID(threadID), 0, "Thread deleted"); err != nil {
log.Printf("tickets: closing the ticket of deleted thread %s: %v", threadID, err)
}
}
+171
View File
@@ -0,0 +1,171 @@
package tickets
import (
"context"
"fmt"
"log"
"strconv"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// CheckCanOpen runs before the form is shown, so a member who can't open a
// ticket finds out before typing anything.
func CheckCanOpen(ctx context.Context, st Store, guildID, userID, categoryID int64) (*ticketrepo.Settings, *ticketrepo.Category, error) {
s, err := st.GetSettings(ctx, guildID)
if err != nil {
return nil, nil, err
}
if !s.Configured || s.ChannelID == 0 {
return nil, nil, ErrNotSetUp
}
if !s.Enabled {
return nil, nil, ErrDisabled
}
cat, err := st.GetCategory(ctx, guildID, categoryID)
if err != nil {
return nil, nil, err
}
if cat == nil {
return nil, nil, ErrUnknownCategory
}
open, err := st.CountOpenByOpener(ctx, guildID, userID)
if err != nil {
return nil, nil, err
}
if open >= s.MaxOpenPerUser {
if s.MaxOpenPerUser == 1 {
return nil, nil, UserError("You already have an open ticket. Close it before opening another one.")
}
return nil, nil, UserError(fmt.Sprintf("You already have %d open tickets, the most this server allows. Close one before opening another.", open))
}
return s, cat, nil
}
type OpenRequest struct {
GuildID int64
CategoryID int64
User *discordgo.User
Subject string
Details string
}
// IntroComponents are the Claim and Close buttons under a ticket's first
// message, disabled as the ticket moves on.
func IntroComponents(claimed, closed bool) []discordgo.MessageComponent {
claimLabel := "Claim"
if claimed {
claimLabel = "Claimed"
}
return []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: claimLabel, Style: discordgo.SuccessButton, CustomID: idClaim, Disabled: claimed || closed, Emoji: &discordgo.ComponentEmoji{Name: "🙋"}},
discordgo.Button{Label: "Close", Style: discordgo.DangerButton, CustomID: idClose, Disabled: closed, Emoji: &discordgo.ComponentEmoji{Name: "🔒"}},
}}}
}
// IntroEmbed describes the ticket at the top of its thread.
func IntroEmbed(t *ticketrepo.Ticket, details string) *discordgo.MessageEmbed {
desc := "**" + clip(t.Subject, maxSubject) + "**"
if details != "" {
desc += "\n\n" + clip(details, maxDetails)
}
claimed := "Nobody yet"
if t.ClaimedBy != 0 {
claimed = "<@" + id(t.ClaimedBy) + ">"
}
e := &discordgo.MessageEmbed{
Title: fmt.Sprintf("Ticket #%04d · %s", t.Number, clip(t.CategoryName, 60)),
Description: desc,
Color: accent,
Fields: []*discordgo.MessageEmbedField{
{Name: "Opened by", Value: "<@" + id(t.OpenerID) + ">", Inline: true},
{Name: "Claimed by", Value: claimed, Inline: true},
},
Footer: &discordgo.MessageEmbedFooter{Text: "Staff can claim this ticket. Anyone here can close it once it's sorted."},
}
if t.Status == ticketrepo.StatusClosed {
e.Color = 0x6b7280
e.Footer.Text = "This ticket is closed."
}
return e
}
// Open creates the ticket: a private thread in the ticket channel with the
// opener added, a first message that mentions the category's staff role
// (which adds that role's members to the thread), and the database row.
func Open(ctx context.Context, d Discord, st Store, req OpenRequest) (*discordgo.Channel, error) {
s, cat, err := CheckCanOpen(ctx, st, req.GuildID, mustID(req.User.ID), req.CategoryID)
if err != nil {
return nil, err
}
number, err := st.NextNumber(ctx, req.GuildID)
if err != nil {
return nil, err
}
name := req.User.GlobalName
if name == "" {
name = req.User.Username
}
thread, err := d.ThreadStartComplex(id(s.ChannelID), &discordgo.ThreadStart{
Name: ThreadName(cat.Name, number, name),
Type: discordgo.ChannelTypeGuildPrivateThread,
AutoArchiveDuration: autoArchiveWeek,
Invitable: false, // only staff can add people
})
if err != nil {
return nil, fmt.Errorf("creating the thread: %w", err)
}
// From here on a failure leaves an empty thread behind unless it's removed.
fail := func(err error) (*discordgo.Channel, error) {
if _, derr := d.ChannelDelete(thread.ID); derr != nil {
log.Printf("tickets: removing thread %s after a failed open: %v", thread.ID, derr)
}
return nil, err
}
if err := d.ThreadMemberAdd(thread.ID, req.User.ID); err != nil {
return fail(fmt.Errorf("adding the opener: %w", err))
}
t := &ticketrepo.Ticket{
GuildID: req.GuildID,
Number: number,
CategoryID: cat.ID,
CategoryName: cat.Name,
OpenerID: mustID(req.User.ID),
Subject: clip(req.Subject, maxSubject),
Status: ticketrepo.StatusOpen,
}
content := "<@" + req.User.ID + ">"
mentions := &discordgo.MessageAllowedMentions{Users: []string{req.User.ID}}
if cat.StaffRoleID != 0 {
content += " <@&" + id(cat.StaffRoleID) + ">"
mentions.Roles = []string{id(cat.StaffRoleID)}
}
intro, err := d.ChannelMessageSendComplex(thread.ID, &discordgo.MessageSend{
Content: content,
Embeds: []*discordgo.MessageEmbed{IntroEmbed(t, req.Details)},
Components: IntroComponents(false, false),
AllowedMentions: mentions,
})
if err != nil {
return fail(fmt.Errorf("posting the first message: %w", err))
}
t.ThreadID = mustID(thread.ID)
t.IntroMessage = mustID(intro.ID)
if _, err := st.CreateTicket(ctx, *t); err != nil {
return fail(fmt.Errorf("saving the ticket: %w", err))
}
return thread, nil
}
// mustID parses a snowflake that came from Discord; those are always valid.
func mustID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
return n
}
+187
View File
@@ -0,0 +1,187 @@
package tickets
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// Custom IDs. Everything starts with "tickets:" so the interaction router can
// hand it to this package.
const (
idOpenPrefix = "tickets:open:" // panel button, + category ID
idSelect = "tickets:select" // panel dropdown (many categories)
idOpenFormPrefix = "tickets:openform:" // opening form, + category ID
idClaim = "tickets:claim"
idClose = "tickets:close"
idCloseForm = "tickets:closeform"
fieldSubject = "subject"
fieldDetails = "details"
fieldReason = "reason"
)
var customEmojiRe = regexp.MustCompile(`^<(a?):(\w+):(\d+)>$`)
// parseEmoji accepts a Unicode emoji or a custom one written as <:name:id>.
func parseEmoji(s string) *discordgo.ComponentEmoji {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
if m := customEmojiRe.FindStringSubmatch(s); m != nil {
return &discordgo.ComponentEmoji{Name: m[2], ID: m[3], Animated: m[1] == "a"}
}
return &discordgo.ComponentEmoji{Name: s}
}
// BuildPanel is the message members open tickets from: an embed with the
// guild's text and one button per category, or a dropdown once there are
// more categories than fit comfortably in a row.
func BuildPanel(s *ticketrepo.Settings, cats []ticketrepo.Category) (*discordgo.MessageEmbed, []discordgo.MessageComponent) {
embed := &discordgo.MessageEmbed{
Title: clip(s.PanelTitle, 256),
Description: clip(s.PanelDescription, 4000),
Color: accent,
}
if len(cats) > maxCategories {
cats = cats[:maxCategories]
}
if len(cats) <= maxPanelButtons {
var buttons []discordgo.MessageComponent
for _, c := range cats {
buttons = append(buttons, discordgo.Button{
Label: clip(c.Name, 80),
Style: discordgo.PrimaryButton,
CustomID: idOpenPrefix + id(c.ID),
Emoji: parseEmoji(c.Emoji),
})
}
// With few categories their descriptions go in the embed, since a
// button has no room for one.
for _, c := range cats {
if c.Description != "" {
embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{Name: clip(c.Name, 256), Value: clip(c.Description, 1024)})
}
}
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: buttons}}
}
var opts []discordgo.SelectMenuOption
for _, c := range cats {
opts = append(opts, discordgo.SelectMenuOption{
Label: clip(c.Name, 100),
Value: id(c.ID),
Description: clip(c.Description, 100),
Emoji: parseEmoji(c.Emoji),
})
}
return embed, []discordgo.MessageComponent{discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.SelectMenu{CustomID: idSelect, Placeholder: "Choose what you need help with", Options: opts},
}}}
}
// PostPanel posts the panel in the ticket channel, or updates the one it
// posted before, and returns where it ended up.
func PostPanel(ctx context.Context, d Discord, st Store, guildID int64) (*discordgo.Message, error) {
s, err := st.GetSettings(ctx, guildID)
if err != nil {
return nil, err
}
if s.ChannelID == 0 {
return nil, ErrNotSetUp
}
cats, err := st.ListCategories(ctx, guildID)
if err != nil {
return nil, err
}
if len(cats) == 0 {
return nil, ErrNoCategories
}
embed, comps := BuildPanel(s, cats)
if s.PanelMessageID != 0 {
embeds := []*discordgo.MessageEmbed{embed}
msg, err := d.ChannelMessageEditComplex(&discordgo.MessageEdit{
Channel: id(s.ChannelID),
ID: id(s.PanelMessageID),
Embeds: &embeds,
Components: &comps,
})
if err == nil {
return msg, nil
}
// The old panel was deleted or the channel changed: post a new one.
}
msg, err := d.ChannelMessageSendComplex(id(s.ChannelID), &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: comps,
})
if err != nil {
return nil, fmt.Errorf("posting the panel: %w", err)
}
if mid, err := strconv.ParseInt(msg.ID, 10, 64); err == nil {
if err := st.SetPanelMessage(ctx, guildID, mid); err != nil {
return msg, fmt.Errorf("saving the panel message: %w", err)
}
}
return msg, nil
}
// OpenForm is the short form shown after a member picks a category.
func OpenForm(cat *ticketrepo.Category) *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idOpenFormPrefix + id(cat.ID),
Title: clip("Open a ticket: "+cat.Name, 45),
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldSubject,
Label: "What do you need help with?",
Style: discordgo.TextInputShort,
Placeholder: "A short summary",
Required: true,
MinLength: 3,
MaxLength: maxSubject,
}}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldDetails,
Label: "Details",
Style: discordgo.TextInputParagraph,
Placeholder: "Anything the staff should know (optional)",
Required: false,
MaxLength: maxDetails,
}}},
},
},
}
}
// CloseForm asks for an optional reason before closing.
func CloseForm() *discordgo.InteractionResponse {
return &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: idCloseForm,
Title: "Close this ticket",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{discordgo.TextInput{
CustomID: fieldReason,
Label: "Reason (optional)",
Style: discordgo.TextInputParagraph,
Required: false,
MaxLength: maxReason,
}}},
},
},
}
}
+197
View File
@@ -0,0 +1,197 @@
// 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
}
+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)
}
}
+88
View File
@@ -0,0 +1,88 @@
package tickets
import (
"fmt"
"strings"
"velox-bot/internal/db/repos/ticketrepo"
"github.com/bwmarrin/discordgo"
)
// maxTranscriptMessages bounds how far back a transcript reads: 50 pages of
// 100, far beyond any normal ticket.
const maxTranscriptMessages = 5000
// Transcript renders the whole thread as plain text, oldest first, and
// reports how many messages it holds. Attachments and embeds are listed so
// the log keeps their links and titles.
func Transcript(d Discord, t *ticketrepo.Ticket) ([]byte, int, error) {
var msgs []*discordgo.Message
before := ""
var fetchErr error
for len(msgs) < maxTranscriptMessages {
page, err := d.ChannelMessages(id(t.ThreadID), 100, before, "", "")
if err != nil {
fetchErr = err
break
}
msgs = append(msgs, page...)
if len(page) < 100 {
break
}
before = page[len(page)-1].ID
}
var b strings.Builder
fmt.Fprintf(&b, "Ticket #%04d · %s\n", t.Number, t.CategoryName)
fmt.Fprintf(&b, "Subject: %s\n", t.Subject)
fmt.Fprintf(&b, "Opened by user %d on %s\n", t.OpenerID, t.CreatedAt.UTC().Format("2006-01-02 15:04 MST"))
if fetchErr != nil {
fmt.Fprintf(&b, "Note: only part of the thread could be read (%v)\n", fetchErr)
}
b.WriteString(strings.Repeat("-", 60) + "\n\n")
// Discord returns newest first.
for i := len(msgs) - 1; i >= 0; i-- {
writeMessage(&b, msgs[i])
}
return []byte(b.String()), len(msgs), fetchErr
}
func writeMessage(b *strings.Builder, m *discordgo.Message) {
name := "unknown"
if m.Author != nil {
name = m.Author.Username
if m.Author.GlobalName != "" {
name = m.Author.GlobalName + " (" + m.Author.Username + ")"
}
if m.Author.Bot {
name += " [bot]"
}
}
fmt.Fprintf(b, "[%s] %s\n", m.Timestamp.UTC().Format("2006-01-02 15:04"), name)
if content := strings.TrimSpace(m.Content); content != "" {
for _, line := range strings.Split(content, "\n") {
b.WriteString(" " + line + "\n")
}
}
for _, e := range m.Embeds {
if e == nil {
continue
}
title := e.Title
if title == "" {
title = clip(e.Description, 120)
}
if title != "" {
b.WriteString(" [embed] " + strings.ReplaceAll(title, "\n", " ") + "\n")
}
}
for _, a := range m.Attachments {
if a != nil {
b.WriteString(" [file] " + a.Filename + " " + a.URL + "\n")
}
}
b.WriteString("\n")
}