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:
1 parent
b1ea5f17e3
commit
d294b3463b
20 files changed
+2800
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user