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