diff --git a/.gitignore b/.gitignore index 74a96ab..5f726e1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ data/ transcripts/ config.yml +docs/ diff --git a/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md b/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md deleted file mode 100644 index 19fabc5..0000000 --- a/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md +++ /dev/null @@ -1,2397 +0,0 @@ -# Discord Ticket Bot (Phase 1) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a Discord ticket management bot in Go with claim system, hot-reload config, SQLite persistence, HTML transcripts, and full role-based authorization. - -**Architecture:** Layered Go monolith — config layer feeds the domain (tickets, claim, transcript), which is consumed by a thin Discord handler layer. A central AuthService is the single point for all permission checks. The claim manager owns all re-up goroutines with context-based cancellation. - -**Tech Stack:** Go 1.22+, bwmarrin/discordgo, modernc.org/sqlite (no CGO), gopkg.in/yaml.v3, github.com/fsnotify/fsnotify, log/slog (stdlib) - ---- - -## File Structure - -``` -ticketbot/ -├── cmd/bot/main.go # Entry point, wiring, graceful shutdown -├── internal/ -│ ├── config/ -│ │ ├── config.go # Config structs + Load/Validate -│ │ ├── watcher.go # Hot reload with fsnotify + debounce -│ │ └── config_test.go -│ ├── db/ -│ │ ├── db.go # Open, migrations, schema_version -│ │ ├── tickets.go # TicketRepo: CRUD + counters -│ │ ├── claim_messages.go # ClaimMessageRepo: persist message IDs -│ │ └── db_test.go -│ ├── tickets/ -│ │ ├── service.go # Open, Close, Claim, Add/Remove/Rename -│ │ ├── auth.go # AuthService + Can(action, ticket) -│ │ └── auth_test.go -│ ├── claim/ -│ │ └── manager.go # ClaimManager: post, re-up loop, stop -│ ├── transcript/ -│ │ ├── generator.go # HTML generator using html/template -│ │ ├── template.go # Embedded HTML template -│ │ └── generator_test.go -│ ├── discord/ -│ │ ├── bot.go # Session init, register commands, event routing -│ │ ├── commands/ -│ │ │ ├── panel.go # /panel_send -│ │ │ ├── ticket.go # /ticket close|add|remove|rename|transcript|reclaim -│ │ │ └── convocation.go # /convocation -│ │ ├── components/ -│ │ │ ├── panel.go # panel:open:: handler -│ │ │ ├── claim.go # claim:take: handler -│ │ │ └── ticket.go # ticket:delete:confirm: + yes/cancel -│ │ └── events/ -│ │ ├── ready.go # Register commands, resume re-up loops -│ │ └── channel_delete.go # Mark ticket closed on manual channel delete -│ └── logger/ -│ └── discord.go # LogService: send embeds to logs_channel -├── config.yaml.example -├── .env.example -├── .gitignore -├── transcripts/ # (gitignore) -├── data/ # (gitignore) -├── Dockerfile -├── docker-compose.yml -├── go.mod -└── README.md -``` - ---- - -## Task 1: Project Setup - -**Files:** -- Create: `ticketbot/go.mod` -- Create: `ticketbot/cmd/bot/main.go` (skeleton) -- Create: `ticketbot/.env.example` -- Create: `ticketbot/.gitignore` -- Create: `ticketbot/config.yaml.example` - -- [ ] **Step 1: Init module and create directory structure** - -```powershell -New-Item -ItemType Directory -Force ticketbot/cmd/bot -New-Item -ItemType Directory -Force ticketbot/internal/config -New-Item -ItemType Directory -Force ticketbot/internal/db -New-Item -ItemType Directory -Force ticketbot/internal/tickets -New-Item -ItemType Directory -Force ticketbot/internal/claim -New-Item -ItemType Directory -Force ticketbot/internal/transcript -New-Item -ItemType Directory -Force ticketbot/internal/discord/commands -New-Item -ItemType Directory -Force ticketbot/internal/discord/components -New-Item -ItemType Directory -Force ticketbot/internal/discord/events -New-Item -ItemType Directory -Force ticketbot/internal/logger -New-Item -ItemType Directory -Force ticketbot/transcripts -New-Item -ItemType Directory -Force ticketbot/data -Set-Location ticketbot -go mod init github.com/leolionad58/ticketbot -``` - -- [ ] **Step 2: Add dependencies** - -```powershell -go get github.com/bwmarrin/discordgo -go get modernc.org/sqlite -go get gopkg.in/yaml.v3 -go get github.com/fsnotify/fsnotify -go get github.com/joho/godotenv -``` - -- [ ] **Step 3: Write skeleton main.go** - -```go -package main - -import ( - "log/slog" - "os" -) - -func main() { - slog.Info("ticketbot starting") - os.Exit(0) -} -``` - -- [ ] **Step 4: Write .gitignore** - -``` -.env -data/ -transcripts/ -ticketbot -*.db -``` - -- [ ] **Step 5: Verify build** - -```powershell -go build ./... -go vet ./... -``` - -Expected: no errors - ---- - -## Task 2: Config — Structs, Parsing, Hot Reload - -**Files:** -- Create: `internal/config/config.go` -- Create: `internal/config/watcher.go` -- Create: `internal/config/config_test.go` -- Create: `config.yaml.example` - -- [ ] **Step 1: Write config structs and Load function** - -`internal/config/config.go`: -```go -package config - -import ( - "fmt" - "os" - "sync" - "sync/atomic" - - "gopkg.in/yaml.v3" -) - -type ButtonColor string - -const ( - ColorPrimary ButtonColor = "primary" - ColorSecondary ButtonColor = "secondary" - ColorSuccess ButtonColor = "success" - ColorDanger ButtonColor = "danger" -) - -type TicketType struct { - ButtonLabel string `yaml:"button_label"` - ButtonColor ButtonColor `yaml:"button_color"` - ButtonEmoji string `yaml:"button_emoji"` - EmbedColor string `yaml:"embed_color"` - EmbedTitle string `yaml:"embed_title"` - EmbedText string `yaml:"embed_text"` - StaffRole string `yaml:"staff_role"` - Category string `yaml:"category"` -} - -type Panel struct { - EmbedTitle string `yaml:"embed_title"` - EmbedDescription string `yaml:"embed_description"` - EmbedColor string `yaml:"embed_color"` - Types map[string]TicketType `yaml:"types"` -} - -type Bot struct { - AdminRole string `yaml:"admin_role"` - LogsChannel string `yaml:"logs_channel"` - ClaimChannel string `yaml:"claim_channel"` - ConvocationCategory string `yaml:"convocation_category"` - ClaimReupMinutes int `yaml:"claim_reup_minutes"` -} - -type Config struct { - Bot Bot `yaml:"bot"` - Panels map[string]Panel `yaml:"panels"` -} - -func (c *Config) Validate() error { - if c.Bot.AdminRole == "" { - return fmt.Errorf("bot.admin_role is required") - } - if c.Bot.ClaimReupMinutes <= 0 { - c.Bot.ClaimReupMinutes = 30 - } - return nil -} - -func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parse config: %w", err) - } - if err := cfg.Validate(); err != nil { - return nil, err - } - return &cfg, nil -} - -// Provider holds the current config and allows atomic swaps. -type Provider struct { - mu sync.RWMutex - ptr atomic.Pointer[Config] -} - -func NewProvider(cfg *Config) *Provider { - p := &Provider{} - p.ptr.Store(cfg) - return p -} - -func (p *Provider) Get() *Config { - return p.ptr.Load() -} - -func (p *Provider) Swap(cfg *Config) { - p.ptr.Store(cfg) -} -``` - -- [ ] **Step 2: Write hot reload watcher** - -`internal/config/watcher.go`: -```go -package config - -import ( - "log/slog" - "time" - - "github.com/fsnotify/fsnotify" -) - -// Watch watches the config file and calls onReload with the new config on change. -// Runs until ctx is done. -func Watch(path string, provider *Provider, onReload func(*Config)) error { - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - if err := watcher.Add(path); err != nil { - watcher.Close() - return err - } - - go func() { - defer watcher.Close() - var debounce *time.Timer - for { - select { - case event, ok := <-watcher.Events: - if !ok { - return - } - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) { - if debounce != nil { - debounce.Stop() - } - debounce = time.AfterFunc(500*time.Millisecond, func() { - cfg, err := Load(path) - if err != nil { - slog.Error("hot reload: invalid config, keeping old", "err", err) - return - } - provider.Swap(cfg) - slog.Info("config reloaded") - if onReload != nil { - onReload(cfg) - } - }) - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - slog.Error("config watcher error", "err", err) - } - } - }() - return nil -} -``` - -- [ ] **Step 3: Write config tests** - -`internal/config/config_test.go`: -```go -package config - -import ( - "os" - "path/filepath" - "testing" -) - -func TestLoadValidConfig(t *testing.T) { - content := ` -bot: - admin_role: "123456789" - logs_channel: "111" - claim_channel: "222" - convocation_category: "333" - claim_reup_minutes: 15 -panels: - support_panel: - embed_title: "Support" - embed_description: "Choisis" - embed_color: "#2b2d31" - types: - support: - button_label: "Support" - button_color: "primary" - embed_color: "#5865f2" - embed_title: "Ticket support" - embed_text: "Décris ton problème." - staff_role: "444" - category: "555" -` - f := filepath.Join(t.TempDir(), "config.yaml") - if err := os.WriteFile(f, []byte(content), 0644); err != nil { - t.Fatal(err) - } - cfg, err := Load(f) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Bot.AdminRole != "123456789" { - t.Errorf("admin_role = %q, want 123456789", cfg.Bot.AdminRole) - } - if cfg.Bot.ClaimReupMinutes != 15 { - t.Errorf("claim_reup_minutes = %d, want 15", cfg.Bot.ClaimReupMinutes) - } - panel, ok := cfg.Panels["support_panel"] - if !ok { - t.Fatal("support_panel missing") - } - if _, ok := panel.Types["support"]; !ok { - t.Fatal("support type missing") - } -} - -func TestLoadMissingAdminRole(t *testing.T) { - content := `bot:\n logs_channel: "111"\n` - f := filepath.Join(t.TempDir(), "config.yaml") - os.WriteFile(f, []byte(content), 0644) - _, err := Load(f) - if err == nil { - t.Fatal("expected error for missing admin_role") - } -} - -func TestDefaultReupMinutes(t *testing.T) { - content := `bot:\n admin_role: "123"\n` - f := filepath.Join(t.TempDir(), "config.yaml") - os.WriteFile(f, []byte(content), 0644) - cfg, _ := Load(f) - if cfg != nil && cfg.Bot.ClaimReupMinutes != 30 { - t.Errorf("default reup = %d, want 30", cfg.Bot.ClaimReupMinutes) - } -} -``` - -- [ ] **Step 4: Run tests** - -```powershell -go test ./internal/config/... -v -``` - -Expected: PASS - -- [ ] **Step 5: Verify build + vet + fmt** - -```powershell -go build ./... -go vet ./... -gofmt -l . -``` - ---- - -## Task 3: Database — SQLite, Migrations, Repositories - -**Files:** -- Create: `internal/db/db.go` -- Create: `internal/db/tickets.go` -- Create: `internal/db/claim_messages.go` -- Create: `internal/db/db_test.go` - -- [ ] **Step 1: Write db.go with migrations** - -`internal/db/db.go`: -```go -package db - -import ( - "context" - "database/sql" - "fmt" - - _ "modernc.org/sqlite" -) - -func Open(path string) (*sql.DB, error) { - db, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000&_fk=true") - if err != nil { - return nil, err - } - db.SetMaxOpenConns(1) // SQLite is single-writer - if err := migrate(db); err != nil { - db.Close() - return nil, fmt.Errorf("migrate: %w", err) - } - return db, nil -} - -func migrate(db *sql.DB) error { - ctx := context.Background() - _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`) - if err != nil { - return err - } - var version int - row := db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM schema_version`) - row.Scan(&version) - - migrations := []string{ - // v1 - `CREATE TABLE IF NOT EXISTS tickets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ticket_number INTEGER NOT NULL, - user_id TEXT NOT NULL, - panel TEXT NOT NULL, - type TEXT NOT NULL, - channel_id TEXT NOT NULL UNIQUE, - opened_at DATETIME NOT NULL, - claimed_by TEXT, - claimed_at DATETIME, - closed_at DATETIME, - closed_by TEXT, - reason TEXT, - transcript_path TEXT, - status TEXT NOT NULL DEFAULT 'open' - ); - CREATE INDEX IF NOT EXISTS idx_tickets_user ON tickets(user_id); - CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); - CREATE INDEX IF NOT EXISTS idx_tickets_type ON tickets(type); - CREATE TABLE IF NOT EXISTS claim_messages ( - ticket_id INTEGER PRIMARY KEY, - message_id TEXT NOT NULL, - last_reup_at DATETIME NOT NULL - ); - CREATE TABLE IF NOT EXISTS convocation_counter ( - id INTEGER PRIMARY KEY CHECK(id=1), - count INTEGER NOT NULL DEFAULT 0 - ); - INSERT OR IGNORE INTO convocation_counter(id,count) VALUES(1,0);`, - } - - for i, m := range migrations { - v := i + 1 - if v <= version { - continue - } - if _, err := db.ExecContext(ctx, m); err != nil { - return fmt.Errorf("migration v%d: %w", v, err) - } - if _, err := db.ExecContext(ctx, `INSERT INTO schema_version(version) VALUES(?)`, v); err != nil { - return fmt.Errorf("record migration v%d: %w", v, err) - } - } - return nil -} -``` - -- [ ] **Step 2: Write tickets.go repository** - -`internal/db/tickets.go`: -```go -package db - -import ( - "context" - "database/sql" - "fmt" - "time" -) - -type Ticket struct { - ID int64 - TicketNumber int - UserID string - Panel string - Type string - ChannelID string - OpenedAt time.Time - ClaimedBy sql.NullString - ClaimedAt sql.NullTime - ClosedAt sql.NullTime - ClosedBy sql.NullString - Reason sql.NullString - TranscriptPath sql.NullString - Status string -} - -type TicketRepo struct{ db *sql.DB } - -func NewTicketRepo(db *sql.DB) *TicketRepo { return &TicketRepo{db: db} } - -// NextNumber returns the next ticket_number for the given type (within a tx). -func (r *TicketRepo) NextNumber(ctx context.Context, tx *sql.Tx, ticketType string) (int, error) { - var n int - err := tx.QueryRowContext(ctx, - `SELECT COALESCE(MAX(ticket_number),0)+1 FROM tickets WHERE type=?`, ticketType, - ).Scan(&n) - return n, err -} - -func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error { - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - defer tx.Rollback() - - n, err := r.NextNumber(ctx, tx, t.Type) - if err != nil { - return err - } - t.TicketNumber = n - - res, err := tx.ExecContext(ctx, ` - INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status) - VALUES(?,?,?,?,?,?,?)`, - n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", - ) - if err != nil { - return fmt.Errorf("insert ticket: %w", err) - } - t.ID, _ = res.LastInsertId() - return tx.Commit() -} - -func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) { - row := r.db.QueryRowContext(ctx, ` - SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status - FROM tickets WHERE channel_id=?`, channelID) - return scanTicket(row) -} - -func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) { - row := r.db.QueryRowContext(ctx, ` - SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status - FROM tickets WHERE id=?`, id) - return scanTicket(row) -} - -func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) { - row := r.db.QueryRowContext(ctx, ` - SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status - FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed') - LIMIT 1`, userID, ticketType) - t, err := scanTicket(row) - if err == sql.ErrNoRows { - return nil, nil - } - return t, err -} - -func (r *TicketRepo) SetClaimed(ctx context.Context, id int64, staffID string, at time.Time) error { - _, err := r.db.ExecContext(ctx, - `UPDATE tickets SET status='claimed', claimed_by=?, claimed_at=? WHERE id=?`, - staffID, at.UTC(), id) - return err -} - -func (r *TicketRepo) SetClosed(ctx context.Context, id int64, closedBy, reason, transcriptPath string, at time.Time) error { - _, err := r.db.ExecContext(ctx, ` - UPDATE tickets SET status='closed', closed_at=?, closed_by=?, reason=?, transcript_path=? - WHERE id=?`, - at.UTC(), closedBy, reason, transcriptPath, id) - return err -} - -func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason string) error { - _, err := r.db.ExecContext(ctx, ` - UPDATE tickets SET status='closed', closed_at=?, reason=? - WHERE channel_id=? AND status IN('open','claimed')`, - time.Now().UTC(), reason, channelID) - return err -} - -func (r *TicketRepo) ListOpenForReup(ctx context.Context) ([]*Ticket, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status - FROM tickets WHERE status='open'`) - if err != nil { - return nil, err - } - defer rows.Close() - var result []*Ticket - for rows.Next() { - t, err := scanTicketRow(rows) - if err != nil { - return nil, err - } - result = append(result, t) - } - return result, rows.Err() -} - -func (r *TicketRepo) UpdateChannelID(ctx context.Context, id int64, newChannelID string) error { - _, err := r.db.ExecContext(ctx, `UPDATE tickets SET channel_id=? WHERE id=?`, newChannelID, id) - return err -} - -func scanTicket(row *sql.Row) (*Ticket, error) { - var t Ticket - err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, - &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, - &t.Reason, &t.TranscriptPath, &t.Status) - if err != nil { - return nil, err - } - return &t, nil -} - -func scanTicketRow(rows *sql.Rows) (*Ticket, error) { - var t Ticket - err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, - &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, - &t.Reason, &t.TranscriptPath, &t.Status) - if err != nil { - return nil, err - } - return &t, nil -} -``` - -- [ ] **Step 3: Write claim_messages.go** - -`internal/db/claim_messages.go`: -```go -package db - -import ( - "context" - "database/sql" - "time" -) - -type ClaimMessage struct { - TicketID int64 - MessageID string - LastReupAt time.Time -} - -type ClaimMessageRepo struct{ db *sql.DB } - -func NewClaimMessageRepo(db *sql.DB) *ClaimMessageRepo { return &ClaimMessageRepo{db: db} } - -func (r *ClaimMessageRepo) Upsert(ctx context.Context, ticketID int64, messageID string, at time.Time) error { - _, err := r.db.ExecContext(ctx, ` - INSERT INTO claim_messages(ticket_id, message_id, last_reup_at) VALUES(?,?,?) - ON CONFLICT(ticket_id) DO UPDATE SET message_id=excluded.message_id, last_reup_at=excluded.last_reup_at`, - ticketID, messageID, at.UTC()) - return err -} - -func (r *ClaimMessageRepo) Get(ctx context.Context, ticketID int64) (*ClaimMessage, error) { - var cm ClaimMessage - err := r.db.QueryRowContext(ctx, - `SELECT ticket_id, message_id, last_reup_at FROM claim_messages WHERE ticket_id=?`, ticketID, - ).Scan(&cm.TicketID, &cm.MessageID, &cm.LastReupAt) - if err == sql.ErrNoRows { - return nil, nil - } - return &cm, err -} - -func (r *ClaimMessageRepo) Delete(ctx context.Context, ticketID int64) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM claim_messages WHERE ticket_id=?`, ticketID) - return err -} - -func (r *ClaimMessageRepo) ListAll(ctx context.Context) ([]*ClaimMessage, error) { - rows, err := r.db.QueryContext(ctx, `SELECT ticket_id, message_id, last_reup_at FROM claim_messages`) - if err != nil { - return nil, err - } - defer rows.Close() - var result []*ClaimMessage - for rows.Next() { - var cm ClaimMessage - if err := rows.Scan(&cm.TicketID, &cm.MessageID, &cm.LastReupAt); err != nil { - return nil, err - } - result = append(result, &cm) - } - return result, rows.Err() -} -``` - -- [ ] **Step 4: Write db tests** - -`internal/db/db_test.go`: -```go -package db - -import ( - "context" - "os" - "path/filepath" - "testing" - "time" -) - -func newTestDB(t *testing.T) *TicketRepo { - t.Helper() - path := filepath.Join(t.TempDir(), "test.db") - sqldb, err := Open(path) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { sqldb.Close() }) - return NewTicketRepo(sqldb) -} - -func TestInsertAndGet(t *testing.T) { - repo := newTestDB(t) - ctx := context.Background() - ticket := &Ticket{ - UserID: "user1", - Panel: "support_panel", - Type: "support", - ChannelID: "chan1", - OpenedAt: time.Now(), - } - if err := repo.Insert(ctx, ticket); err != nil { - t.Fatal(err) - } - if ticket.ID == 0 { - t.Fatal("expected non-zero ID") - } - if ticket.TicketNumber != 1 { - t.Errorf("ticket_number = %d, want 1", ticket.TicketNumber) - } - - got, err := repo.GetByChannelID(ctx, "chan1") - if err != nil { - t.Fatal(err) - } - if got.UserID != "user1" { - t.Errorf("user_id = %q, want user1", got.UserID) - } -} - -func TestTicketNumberPerType(t *testing.T) { - repo := newTestDB(t) - ctx := context.Background() - - for i := 0; i < 3; i++ { - t := &Ticket{UserID: "u", Panel: "p", Type: "support", ChannelID: "c" + string(rune('a'+i)), OpenedAt: time.Now()} - repo.Insert(ctx, t) - } - t2 := &Ticket{UserID: "u", Panel: "p", Type: "mod", ChannelID: "cx", OpenedAt: time.Now()} - repo.Insert(ctx, t2) - - if t2.TicketNumber != 1 { - t.Errorf("mod ticket_number = %d, want 1", t2.TicketNumber) - } - - repo2 := newTestDB(t) - _ = repo2 // just ensure separate counter works -} - -func TestHasOpenTicket(t *testing.T) { - repo := newTestDB(t) - ctx := context.Background() - - tk := &Ticket{UserID: "u1", Panel: "p", Type: "support", ChannelID: "c1", OpenedAt: time.Now()} - repo.Insert(ctx, tk) - - found, err := repo.HasOpenTicket(ctx, "u1", "support") - if err != nil || found == nil { - t.Fatal("expected open ticket") - } - repo.SetClosed(ctx, tk.ID, "staff1", "resolved", "", time.Now()) - found, _ = repo.HasOpenTicket(ctx, "u1", "support") - if found != nil { - t.Fatal("expected no open ticket after close") - } -} -``` - -- [ ] **Step 5: Run tests** - -```powershell -go test ./internal/db/... -v -``` - -Expected: PASS - ---- - -## Task 4: Discord Bot — Session + Command Registration - -**Files:** -- Create: `internal/discord/bot.go` -- Create: `internal/discord/events/ready.go` -- Create: `internal/discord/events/channel_delete.go` -- Modify: `cmd/bot/main.go` - -- [ ] **Step 1: Write bot.go** - -`internal/discord/bot.go`: -```go -package discord - -import ( - "fmt" - "log/slog" - "os" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -type Bot struct { - Session *discordgo.Session - Config *config.Provider - Tickets *db.TicketRepo - Claims *db.ClaimMessageRepo - GuildID string -} - -func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo) (*Bot, error) { - s, err := discordgo.New("Bot " + token) - if err != nil { - return nil, fmt.Errorf("create session: %w", err) - } - s.Identify.Intents = discordgo.IntentsGuilds | - discordgo.IntentsGuildMembers | - discordgo.IntentsGuildMessages | - discordgo.IntentsMessageContent - - return &Bot{ - Session: s, - Config: cfg, - Tickets: tickets, - Claims: claims, - GuildID: os.Getenv("GUILD_ID"), - }, nil -} - -func (b *Bot) Open() error { - return b.Session.Open() -} - -func (b *Bot) Close() { - b.Session.Close() -} - -func (b *Bot) RegisterCommands(appID string) error { - commands := ApplicationCommands() - for _, cmd := range commands { - _, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd) - if err != nil { - return fmt.Errorf("register %s: %w", cmd.Name, err) - } - slog.Info("registered command", "name", cmd.Name) - } - return nil -} -``` - -- [ ] **Step 2: Write events/ready.go** - -`internal/discord/events/ready.go`: -```go -package events - -import ( - "log/slog" - - "github.com/bwmarrin/discordgo" -) - -func ReadyHandler(s *discordgo.Session, r *discordgo.Ready) { - slog.Info("bot ready", "user", r.User.Username, "guilds", len(r.Guilds)) -} -``` - -- [ ] **Step 3: Write events/channel_delete.go** - -`internal/discord/events/channel_delete.go`: -```go -package events - -import ( - "context" - "log/slog" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/db" -) - -type ChannelDeleteHandler struct { - Tickets *db.TicketRepo -} - -func (h *ChannelDeleteHandler) Handle(s *discordgo.Session, c *discordgo.ChannelDelete) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := h.Tickets.SetClosedByChannel(ctx, c.ID, "channel deleted manually"); err != nil { - slog.Error("channel_delete: update ticket", "channel_id", c.ID, "err", err) - } -} -``` - -- [ ] **Step 4: Write command definitions** - -`internal/discord/commands.go`: -```go -package discord - -import "github.com/bwmarrin/discordgo" - -func ApplicationCommands() []*discordgo.ApplicationCommand { - return []*discordgo.ApplicationCommand{ - { - Name: "panel_send", - Description: "Envoie un panel de tickets dans un channel", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionString, Name: "panel", Description: "Nom du panel", Required: true}, - {Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Channel cible", Required: true}, - }, - }, - { - Name: "ticket", - Description: "Commandes de gestion de ticket", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close", - Description: "Ferme le ticket", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Raison"}, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", - Description: "Ajoute un user au ticket", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à ajouter", Required: true}, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", - Description: "Retire un user du ticket", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à retirer", Required: true}, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "rename", - Description: "Renomme le channel du ticket", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Nouveau nom", Required: true}, - }, - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "transcript", - Description: "Génère le transcript HTML", - }, - { - Type: discordgo.ApplicationCommandOptionSubCommand, Name: "reclaim", - Description: "Reprend un ticket claimé par un staff parti", - }, - }, - }, - { - Name: "convocation", - Description: "Crée une convocation", - Options: []*discordgo.ApplicationCommandOption{ - {Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à convoquer", Required: true}, - {Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Raison", Required: true}, - }, - }, - } -} -``` - -- [ ] **Step 5: Update main.go with startup wiring** - -`cmd/bot/main.go`: -```go -package main - -import ( - "log/slog" - "os" - "os/signal" - "syscall" - - "github.com/joho/godotenv" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" - discordbot "github.com/leolionad58/ticketbot/internal/discord" - "github.com/leolionad58/ticketbot/internal/discord/events" -) - -func main() { - setupLogger() - godotenv.Load(".env") - - cfg, err := config.Load("config.yaml") - must(err, "load config") - cfgProvider := config.NewProvider(cfg) - - sqldb, err := db.Open("data/tickets.db") - must(err, "open db") - defer sqldb.Close() - - tickets := db.NewTicketRepo(sqldb) - claims := db.NewClaimMessageRepo(sqldb) - - token := os.Getenv("DISCORD_TOKEN") - if token == "" { - slog.Error("DISCORD_TOKEN not set") - os.Exit(1) - } - appID := os.Getenv("DISCORD_APP_ID") - - bot, err := discordbot.New(token, cfgProvider, tickets, claims) - must(err, "create bot") - - bot.Session.AddHandler(events.ReadyHandler) - cdHandler := &events.ChannelDeleteHandler{Tickets: tickets} - bot.Session.AddHandler(cdHandler.Handle) - - must(bot.Open(), "open discord session") - defer bot.Close() - - if appID != "" { - must(bot.RegisterCommands(appID), "register commands") - } - - config.Watch("config.yaml", cfgProvider, nil) - - slog.Info("ticketbot ready, Ctrl+C to stop") - stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) - <-stop - slog.Info("shutting down") -} - -func setupLogger() { - level := slog.LevelInfo - switch os.Getenv("LOG_LEVEL") { - case "debug": - level = slog.LevelDebug - case "warn": - level = slog.LevelWarn - case "error": - level = slog.LevelError - } - var handler slog.Handler - if os.Getenv("LOG_FORMAT") == "json" { - handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } else { - handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}) - } - slog.SetDefault(slog.New(handler)) -} - -func must(err error, msg string) { - if err != nil { - slog.Error(msg, "err", err) - os.Exit(1) - } -} -``` - -- [ ] **Step 6: Verify build** - -```powershell -go build ./... -go vet ./... -gofmt -l . -``` - ---- - -## Task 5: AuthService + /panel_send + Ticket Opening - -**Files:** -- Create: `internal/tickets/auth.go` -- Create: `internal/tickets/auth_test.go` -- Create: `internal/tickets/service.go` -- Create: `internal/discord/commands/panel.go` -- Create: `internal/discord/components/panel.go` - -- [ ] **Step 1: Write AuthService** - -`internal/tickets/auth.go`: -```go -package tickets - -import ( - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -type Action int - -const ( - ActionAdmin Action = iota - ActionClaim - ActionClose - ActionAdd - ActionRemove - ActionRename - ActionTranscript - ActionConvocation - ActionReclaim -) - -type AuthService struct { - Config *config.Provider -} - -func NewAuthService(cfg *config.Provider) *AuthService { - return &AuthService{Config: cfg} -} - -// Can returns true if the member (by their role IDs) is authorized to perform action on ticket. -// ticket may be nil for actions that don't require a specific ticket (e.g. ActionConvocation). -func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket) bool { - cfg := a.Config.Get() - hasRole := func(roleID string) bool { - for _, r := range memberRoles { - if r == roleID { - return true - } - } - return false - } - - isAdmin := hasRole(cfg.Bot.AdminRole) - - switch action { - case ActionAdmin: - return isAdmin - - case ActionClaim: - if ticket == nil { - return false - } - if isAdmin { - return true - } - if p, ok := cfg.Panels[ticket.Panel]; ok { - if t, ok := p.Types[ticket.Type]; ok { - return hasRole(t.StaffRole) - } - } - return false - - case ActionClose, ActionAdd, ActionRemove, ActionRename, ActionTranscript, ActionReclaim: - if isAdmin { - return true - } - if ticket == nil { - return false - } - if p, ok := cfg.Panels[ticket.Panel]; ok { - if t, ok := p.Types[ticket.Type]; ok { - return hasRole(t.StaffRole) - } - } - return false - - case ActionConvocation: - // any staff role across all panels - for _, panel := range cfg.Panels { - for _, t := range panel.Types { - if hasRole(t.StaffRole) { - return true - } - } - } - return isAdmin - - default: - return false - } -} -``` - -- [ ] **Step 2: Write auth tests** - -`internal/tickets/auth_test.go`: -```go -package tickets - -import ( - "testing" - - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -func makeProvider() *config.Provider { - cfg := &config.Config{ - Bot: config.Bot{AdminRole: "admin-role"}, - Panels: map[string]config.Panel{ - "support_panel": { - Types: map[string]config.TicketType{ - "support": {StaffRole: "staff-role"}, - }, - }, - }, - } - return config.NewProvider(cfg) -} - -func TestCanAdmin(t *testing.T) { - auth := NewAuthService(makeProvider()) - if !auth.Can([]string{"admin-role"}, ActionAdmin, nil) { - t.Error("admin should be able to ActionAdmin") - } - if auth.Can([]string{"random-role"}, ActionAdmin, nil) { - t.Error("non-admin should not be able to ActionAdmin") - } -} - -func TestCanClaim(t *testing.T) { - auth := NewAuthService(makeProvider()) - ticket := &db.Ticket{Panel: "support_panel", Type: "support"} - - if !auth.Can([]string{"staff-role"}, ActionClaim, ticket) { - t.Error("staff should be able to claim") - } - if !auth.Can([]string{"admin-role"}, ActionClaim, ticket) { - t.Error("admin should be able to claim") - } - if auth.Can([]string{"other-role"}, ActionClaim, ticket) { - t.Error("non-staff should not be able to claim") - } -} - -func TestCanConvocation(t *testing.T) { - auth := NewAuthService(makeProvider()) - if !auth.Can([]string{"staff-role"}, ActionConvocation, nil) { - t.Error("staff should be able to convocation") - } - if auth.Can([]string{"random"}, ActionConvocation, nil) { - t.Error("non-staff should not be able to convocation") - } -} -``` - -- [ ] **Step 3: Run auth tests** - -```powershell -go test ./internal/tickets/... -v -``` - -Expected: PASS - -- [ ] **Step 4: Write ticket service (Open)** - -`internal/tickets/service.go`: -```go -package tickets - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -type Service struct { - db *db.TicketRepo - auth *AuthService - session *discordgo.Session - cfg *config.Provider - opening sync.Map // key: userID+":"+ticketType → struct{} -} - -func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { - return &Service{db: repo, auth: auth, session: s, cfg: cfg} -} - -// Open creates a ticket channel and inserts into DB. Returns the created Ticket. -func (svc *Service) Open(ctx context.Context, guildID, userID, panelName, ticketType string) (*db.Ticket, error) { - lockKey := userID + ":" + ticketType - if _, loaded := svc.opening.LoadOrStore(lockKey, struct{}{}); loaded { - return nil, fmt.Errorf("creation already in progress") - } - defer svc.opening.Delete(lockKey) - - // Check for existing open ticket - existing, err := svc.db.HasOpenTicket(ctx, userID, ticketType) - if err != nil { - return nil, err - } - if existing != nil { - return existing, fmt.Errorf("already_open:%s", existing.ChannelID) - } - - cfg := svc.cfg.Get() - panel, ok := cfg.Panels[panelName] - if !ok { - return nil, fmt.Errorf("panel %q not found", panelName) - } - typeCfg, ok := panel.Types[ticketType] - if !ok { - return nil, fmt.Errorf("type %q not found in panel %q", ticketType, panelName) - } - - // Insert first to get ticket number - ticket := &db.Ticket{ - UserID: userID, - Panel: panelName, - Type: ticketType, - ChannelID: "pending", // placeholder - OpenedAt: time.Now(), - Status: "open", - } - // Use a placeholder channel ID that we'll update - ticket.ChannelID = fmt.Sprintf("pending-%d", time.Now().UnixNano()) - if err := svc.db.Insert(ctx, ticket); err != nil { - return nil, fmt.Errorf("insert ticket: %w", err) - } - - channelName := fmt.Sprintf("%s-%04d", ticketType, ticket.TicketNumber) - ch, err := svc.session.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{ - Name: channelName, - Type: discordgo.ChannelTypeGuildText, - ParentID: typeCfg.Category, - PermissionOverwrites: []*discordgo.PermissionOverwrite{ - { - ID: guildID, - Type: discordgo.PermissionOverwriteTypeRole, - Deny: discordgo.PermissionViewChannel, - }, - { - ID: userID, - Type: discordgo.PermissionOverwriteTypeMember, - Allow: discordgo.PermissionViewChannel | - discordgo.PermissionSendMessages | - discordgo.PermissionReadMessageHistory | - discordgo.PermissionAttachFiles, - }, - }, - }) - if err != nil { - return nil, fmt.Errorf("create channel: %w", err) - } - - if err := svc.db.UpdateChannelID(ctx, ticket.ID, ch.ID); err != nil { - slog.Error("update channel_id", "ticket_id", ticket.ID, "err", err) - } - ticket.ChannelID = ch.ID - - return ticket, nil -} - -func (svc *Service) AddMember(ctx context.Context, channelID, userID string) error { - _, err := svc.session.ChannelPermissionSet(channelID, userID, - discordgo.PermissionOverwriteTypeMember, - discordgo.PermissionViewChannel|discordgo.PermissionSendMessages| - discordgo.PermissionReadMessageHistory|discordgo.PermissionAttachFiles, - 0) - return err -} - -func (svc *Service) RemoveMember(ctx context.Context, channelID, userID string) error { - return svc.session.ChannelPermissionDelete(channelID, userID) -} - -func (svc *Service) Rename(ctx context.Context, channelID, name string) error { - // sanitize: max 100 chars, replace spaces with hyphens - if len(name) > 100 { - name = name[:100] - } - safe := "" - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { - safe += string(r) - } else if r >= 'A' && r <= 'Z' { - safe += string(r + 32) - } else if r == ' ' { - safe += "-" - } - } - if safe == "" { - return fmt.Errorf("invalid channel name") - } - _, err := svc.session.ChannelEdit(channelID, &discordgo.ChannelEdit{Name: safe}) - return err -} -``` - -- [ ] **Step 5: Write panel command handler** - -`internal/discord/commands/panel.go`: -```go -package commands - -import ( - "log/slog" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/tickets" -) - -type PanelCommand struct { - Config *config.Provider - Auth *tickets.AuthService -} - -func (c *PanelCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { - data := i.ApplicationCommandData() - memberRoles := memberRoleIDs(i) - if !c.Auth.Can(memberRoles, tickets.ActionAdmin, nil) { - ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") - slog.Warn("panel_send denied", "user_id", userID(i)) - return - } - - panelName := data.Options[0].StringValue() - channelID := data.Options[1].ChannelValue(s).ID - cfg := c.Config.Get() - panel, ok := cfg.Panels[panelName] - if !ok { - ephemeral(s, i, "Panel introuvable: "+panelName) - return - } - - components := buildPanelButtons(panelName, panel) - color := parseColor(panel.EmbedColor) - embed := &discordgo.MessageEmbed{ - Title: panel.EmbedTitle, - Description: panel.EmbedDescription, - Color: color, - } - - _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{embed}, - Components: components, - }) - if err != nil { - slog.Error("panel_send: send message", "err", err) - ephemeral(s, i, "Erreur lors de l'envoi du panel.") - return - } - ephemeral(s, i, "Panel envoyé.") -} - -func buildPanelButtons(panelName string, panel config.Panel) []discordgo.MessageComponent { - var buttons []discordgo.MessageComponent - for typeName, t := range panel.Types { - customID := "panel:open:" + panelName + ":" + typeName - if len(customID) > 100 { - customID = customID[:100] - } - btn := discordgo.Button{ - Label: t.ButtonLabel, - Style: buttonStyle(t.ButtonColor), - CustomID: customID, - } - if t.ButtonEmoji != "" { - btn.Emoji = discordgo.ComponentEmoji{Name: t.ButtonEmoji} - } - buttons = append(buttons, btn) - } - return []discordgo.MessageComponent{ - discordgo.ActionsRow{Components: buttons}, - } -} -``` - -- [ ] **Step 6: Write panel component handler (button click)** - -`internal/discord/components/panel.go`: -```go -package components - -import ( - "context" - "fmt" - "log/slog" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/claim" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/logger" - "github.com/leolionad58/ticketbot/internal/tickets" -) - -type PanelComponent struct { - TicketSvc *tickets.Service - ClaimMgr *claim.Manager - LogSvc *logger.DiscordLogger - Config *config.Provider -} - -func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { - // custom_id: panel:open:: - parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4) - if len(parts) != 4 { - return - } - panelName, ticketType := parts[2], parts[3] - - // Defer — channel creation can take >3s - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, - }) - - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - - uID := userID(i) - ticket, err := c.TicketSvc.Open(ctx, guildID(i), uID, panelName, ticketType) - if err != nil { - if strings.HasPrefix(err.Error(), "already_open:") { - chID := strings.TrimPrefix(err.Error(), "already_open:") - followup(s, i, fmt.Sprintf("Tu as déjà un ticket ouvert : <#%s>", chID)) - return - } - slog.Error("open ticket", "user_id", uID, "err", err) - followup(s, i, "Erreur lors de la création du ticket.") - return - } - - cfg := c.Config.Get() - panel, _ := cfg.Panels[panelName] - typeCfg, _ := panel.Types[ticketType] - - // Post welcome embed in ticket channel - color := parseColor(typeCfg.EmbedColor) - deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID - _, err = s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{ - {Title: typeCfg.EmbedTitle, Description: typeCfg.EmbedText, Color: color}, - }, - Components: []discordgo.MessageComponent{ - discordgo.ActionsRow{Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "Supprimer le ticket", - Style: discordgo.DangerButton, - CustomID: deleteCustomID, - }, - }}, - }, - }) - if err != nil { - slog.Error("open ticket: post welcome", "channel_id", ticket.ChannelID, "err", err) - } - - // Post in claim channel - c.ClaimMgr.StartTicket(ctx, s, ticket) - - // Log - c.LogSvc.LogTicketOpened(ticket, uID) - - followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID)) -} -``` - -- [ ] **Step 7: Write shared helpers** - -`internal/discord/commands/helpers.go`: -```go -package commands - -import ( - "strconv" - - "github.com/bwmarrin/discordgo" -) - -func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: msg, - Flags: discordgo.MessageFlagsEphemeral, - }, - }) -} - -func memberRoleIDs(i *discordgo.InteractionCreate) []string { - if i.Member != nil { - return i.Member.Roles - } - return nil -} - -func userID(i *discordgo.InteractionCreate) string { - if i.Member != nil { - return i.Member.User.ID - } - if i.User != nil { - return i.User.ID - } - return "" -} - -func parseColor(hex string) int { - if len(hex) > 0 && hex[0] == '#' { - hex = hex[1:] - } - v, _ := strconv.ParseInt(hex, 16, 32) - return int(v) -} - -func buttonStyle(color config.ButtonColor) discordgo.ButtonStyle { - // Need config import — will be in same package - switch color { - case "primary": - return discordgo.PrimaryButton - case "secondary": - return discordgo.SecondaryButton - case "success": - return discordgo.SuccessButton - case "danger": - return discordgo.DangerButton - default: - return discordgo.PrimaryButton - } -} -``` - -`internal/discord/components/helpers.go`: -```go -package components - -import ( - "strconv" - - "github.com/bwmarrin/discordgo" -) - -func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{Content: msg, Flags: discordgo.MessageFlagsEphemeral}, - }) -} - -func followup(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { - s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ - Content: msg, - Flags: discordgo.MessageFlagsEphemeral, - }) -} - -func userID(i *discordgo.InteractionCreate) string { - if i.Member != nil { - return i.Member.User.ID - } - if i.User != nil { - return i.User.ID - } - return "" -} - -func guildID(i *discordgo.InteractionCreate) string { - return i.GuildID -} - -func parseColor(hex string) int { - if len(hex) > 0 && hex[0] == '#' { - hex = hex[1:] - } - v, _ := strconv.ParseInt(hex, 16, 32) - return int(v) -} -``` - -- [ ] **Step 8: Verify build** - -```powershell -go build ./... -go vet ./... -``` - ---- - -## Task 6: Claim Manager + Re-up Loop - -**Files:** -- Create: `internal/claim/manager.go` - -- [ ] **Step 1: Write claim manager** - -`internal/claim/manager.go`: -```go -package claim - -import ( - "context" - "fmt" - "log/slog" - "sync" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -type Manager struct { - cfg *config.Provider - repo *db.TicketRepo - claims *db.ClaimMessageRepo - mu sync.Mutex - stops map[int64]context.CancelFunc -} - -func NewManager(cfg *config.Provider, repo *db.TicketRepo, claims *db.ClaimMessageRepo) *Manager { - return &Manager{ - cfg: cfg, - repo: repo, - claims: claims, - stops: make(map[int64]context.CancelFunc), - } -} - -// StartTicket posts the claim message and starts the re-up goroutine. -func (m *Manager) StartTicket(ctx context.Context, s *discordgo.Session, ticket *db.Ticket) { - msgID, err := m.postClaim(ctx, s, ticket, false) - if err != nil { - slog.Error("claim: post initial", "ticket_id", ticket.ID, "err", err) - return - } - m.claims.Upsert(ctx, ticket.ID, msgID, time.Now()) - m.startReup(s, ticket, time.Now()) -} - -// ResumeTicket re-attaches re-up loop for a ticket reloaded from DB after restart. -func (m *Manager) ResumeTicket(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { - m.startReup(s, ticket, lastReupAt) -} - -// Stop cancels the re-up loop for a ticket (called on claim or close). -func (m *Manager) Stop(ticketID int64) { - m.mu.Lock() - defer m.mu.Unlock() - if cancel, ok := m.stops[ticketID]; ok { - cancel() - delete(m.stops, ticketID) - } -} - -func (m *Manager) startReup(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { - ctx, cancel := context.WithCancel(context.Background()) - m.mu.Lock() - m.stops[ticket.ID] = cancel - m.mu.Unlock() - - go func() { - defer func() { - m.mu.Lock() - delete(m.stops, ticket.ID) - m.mu.Unlock() - }() - - cfg := m.cfg.Get() - interval := time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute - elapsed := time.Since(lastReupAt) - next := interval - elapsed - if next < 0 { - next = 0 - } - - timer := time.NewTimer(next) - defer timer.Stop() - - reupCount := 0 - for { - select { - case <-ctx.Done(): - return - case <-timer.C: - reupCount++ - // Check DB status before re-up - dbCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - t, err := m.repo.GetByID(dbCtx, ticket.ID) - cancel() - if err != nil || t == nil || t.Status != "open" { - return - } - - // Delete old message - oldCm, _ := m.claims.Get(context.Background(), ticket.ID) - if oldCm != nil { - cfg := m.cfg.Get() - s.ChannelMessageDelete(cfg.Bot.ClaimChannel, oldCm.MessageID) - } - - // Re-post with ping - postCtx, cancel := context.WithTimeout(ctx, 10*time.Second) - msgID, err := m.postClaim(postCtx, s, ticket, reupCount > 0) - cancel() - if err != nil { - slog.Error("claim reup: post", "ticket_id", ticket.ID, "err", err) - } else { - m.claims.Upsert(context.Background(), ticket.ID, msgID, time.Now()) - } - - cfg = m.cfg.Get() - interval = time.Duration(cfg.Bot.ClaimReupMinutes) * time.Minute - timer.Reset(interval) - } - } - }() -} - -func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *db.Ticket, withPing bool) (string, error) { - cfg := m.cfg.Get() - claimChannelID := cfg.Bot.ClaimChannel - - elapsed := time.Since(ticket.OpenedAt).Round(time.Second) - embed := &discordgo.MessageEmbed{ - Title: "Nouveau ticket à claim", - Fields: []*discordgo.MessageEmbedField{ - {Name: "Type", Value: ticket.Type, Inline: true}, - {Name: "Panel", Value: ticket.Panel, Inline: true}, - {Name: "Utilisateur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true}, - {Name: "Numéro", Value: fmt.Sprintf("%04d", ticket.TicketNumber), Inline: true}, - {Name: "Channel", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, - {Name: "Ouvert il y a", Value: elapsed.String(), Inline: true}, - }, - Color: 0x5865f2, - } - - customID := fmt.Sprintf("claim:take:%d", ticket.ID) - send := &discordgo.MessageSend{ - Embeds: []*discordgo.MessageEmbed{embed}, - Components: []discordgo.MessageComponent{ - discordgo.ActionsRow{Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "Claim", - Style: discordgo.SuccessButton, - CustomID: customID, - }, - }}, - }, - } - - // Add staff ping on re-up - if withPing { - panel, ok := cfg.Panels[ticket.Panel] - if ok { - if t, ok := panel.Types[ticket.Type]; ok { - send.Content = fmt.Sprintf("<@&%s>", t.StaffRole) - } - } - } - - msg, err := s.ChannelMessageSendComplex(claimChannelID, send) - if err != nil { - return "", err - } - return msg.ID, nil -} -``` - ---- - -## Task 7: Ticket Close + Transcript HTML - -**Files:** -- Create: `internal/transcript/generator.go` -- Create: `internal/transcript/template.go` -- Create: `internal/transcript/generator_test.go` -- Create: `internal/discord/components/ticket.go` -- Create: `internal/discord/commands/ticket.go` (close subcommand) - -- [ ] **Step 1: Write HTML transcript generator** - -`internal/transcript/generator.go`: -```go -package transcript - -import ( - "bytes" - "fmt" - "html/template" - "log/slog" - "os" - "path/filepath" - "strings" - "time" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/db" -) - -const maxFileSize = 25 * 1024 * 1024 // 25MB - -type Generator struct { - OutputDir string - Session *discordgo.Session -} - -func NewGenerator(outputDir string, s *discordgo.Session) *Generator { - return &Generator{OutputDir: outputDir, Session: s} -} - -type Message struct { - AuthorName string - AuthorAvatar string - Content template.HTML - Timestamp string - Attachments []Attachment -} - -type Attachment struct { - Name string - URL string -} - -// Generate fetches all messages and writes the HTML transcript. Returns output path. -func (g *Generator) Generate(ticket *db.Ticket) (string, error) { - var allMessages []*discordgo.Message - var before string - for { - msgs, err := g.Session.ChannelMessages(ticket.ChannelID, 100, before, "", "") - if err != nil { - return "", fmt.Errorf("fetch messages: %w", err) - } - allMessages = append(allMessages, msgs...) - if len(msgs) < 100 { - break - } - before = msgs[len(msgs)-1].ID - } - - // Reverse to chronological order - for i, j := 0, len(allMessages)-1; i < j; i, j = i+1, j-1 { - allMessages[i], allMessages[j] = allMessages[j], allMessages[i] - } - - var rendered []Message - for _, m := range allMessages { - name := m.Author.Username - avatar := m.Author.AvatarURL("64") - content := template.HTMLEscapeString(m.Content) - // Resolve basic mentions - ts := "" - if t, err := m.Timestamp.Parse(); err == nil { - ts = t.Format("02/01/2006 15:04:05") - } - var attachments []Attachment - for _, a := range m.Attachments { - attachments = append(attachments, Attachment{Name: a.Filename, URL: a.URL}) - } - rendered = append(rendered, Message{ - AuthorName: name, - AuthorAvatar: avatar, - Content: template.HTML(strings.ReplaceAll(content, "\n", "
")), - Timestamp: ts, - Attachments: attachments, - }) - } - - tmpl, err := template.New("transcript").Parse(HTMLTemplate) - if err != nil { - return "", err - } - - data := struct { - Ticket *db.Ticket - Messages []Message - Generated string - }{ - Ticket: ticket, - Messages: rendered, - Generated: time.Now().Format("02/01/2006 15:04:05"), - } - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - return "", err - } - - if err := os.MkdirAll(g.OutputDir, 0755); err != nil { - return "", err - } - - outPath := filepath.Join(g.OutputDir, - fmt.Sprintf("%s-%04d-%s.html", ticket.Type, ticket.TicketNumber, ticket.ChannelID)) - - if buf.Len() > maxFileSize { - slog.Warn("transcript too large, saving locally only", "path", outPath, "size", buf.Len()) - } - - if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { - return "", err - } - return outPath, nil -} -``` - -`internal/transcript/template.go`: -```go -package transcript - -const HTMLTemplate = ` - - - -Transcript {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}} - - - -
-

Transcript — {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}}

-

Utilisateur: {{.Ticket.UserID}} | Ouvert: {{.Ticket.OpenedAt.Format "02/01/2006 15:04"}}

-

Généré le {{.Generated}} — {{len .Messages}} messages

-
-{{range .Messages}} -
- -
- {{.AuthorName}} - {{.Timestamp}} -
{{.Content}}
- {{range .Attachments}} - - {{end}} -
-
-{{end}} - - -` -``` - -- [ ] **Step 2: Write transcript test** - -`internal/transcript/generator_test.go`: -```go -package transcript - -import ( - "database/sql" - "os" - "path/filepath" - "testing" - "time" - - "github.com/leolionad58/ticketbot/internal/db" -) - -func TestGenerateEmpty(t *testing.T) { - dir := t.TempDir() - g := &Generator{OutputDir: dir, Session: nil} - ticket := &db.Ticket{ - ID: 1, TicketNumber: 1, Type: "support", Panel: "p", - ChannelID: "ch1", UserID: "u1", - OpenedAt: time.Now(), - Status: "open", - ClaimedBy: sql.NullString{}, - } - // We can't call Generate without a real session, so test template rendering - import_tmpl_test(t, ticket, dir) -} - -func import_tmpl_test(t *testing.T, ticket *db.Ticket, dir string) { - t.Helper() - outPath := filepath.Join(dir, "support-0001-ch1.html") - // Manually render the template - import "html/template" - import "bytes" - tmpl, err := template.New("t").Parse(HTMLTemplate) - if err != nil { - t.Fatal(err) - } - data := struct { - Ticket *db.Ticket - Messages []Message - Generated string - }{Ticket: ticket, Messages: nil, Generated: "test"} - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - t.Fatal(err) - } - os.WriteFile(outPath, buf.Bytes(), 0644) - content, _ := os.ReadFile(outPath) - if len(content) == 0 { - t.Fatal("empty transcript") - } - if !bytes.Contains(content, []byte("support-0001")) { - t.Error("transcript missing ticket reference") - } -} -``` - -Note: The test above uses import inside a function which won't compile. Let me rewrite it properly. - -`internal/transcript/generator_test.go` (correct version): -```go -package transcript - -import ( - "bytes" - "database/sql" - "html/template" - "os" - "path/filepath" - "testing" - "time" - - "github.com/leolionad58/ticketbot/internal/db" -) - -func TestTemplateRender(t *testing.T) { - ticket := &db.Ticket{ - ID: 1, TicketNumber: 1, Type: "support", Panel: "p", - ChannelID: "ch1", UserID: "u1", - OpenedAt: time.Now(), - Status: "open", - ClaimedBy: sql.NullString{}, - } - - tmpl, err := template.New("t").Parse(HTMLTemplate) - if err != nil { - t.Fatalf("parse template: %v", err) - } - - data := struct { - Ticket *db.Ticket - Messages []Message - Generated string - }{Ticket: ticket, Messages: []Message{ - {AuthorName: "TestUser", Content: "Hello <world>", Timestamp: "01/01/2024 12:00"}, - }, Generated: "test"} - - var buf bytes.Buffer - if err := tmpl.Execute(&buf, data); err != nil { - t.Fatalf("execute template: %v", err) - } - - dir := t.TempDir() - outPath := filepath.Join(dir, "support-0001-ch1.html") - os.WriteFile(outPath, buf.Bytes(), 0644) - - content, _ := os.ReadFile(outPath) - if !bytes.Contains(content, []byte("support-0001")) { - t.Error("missing ticket reference") - } - if !bytes.Contains(content, []byte("TestUser")) { - t.Error("missing author") - } - // XSS check — raw < should not appear in message content - if bytes.Contains(content, []byte("")) { - t.Error("XSS: unescaped content in transcript") - } -} -``` - -- [ ] **Step 3: Run transcript tests** - -```powershell -go test ./internal/transcript/... -v -``` - -Expected: PASS - ---- - -## Task 8: Ticket Commands (add/remove/rename/transcript/close/reclaim) - -**Files:** -- Modify: `internal/discord/commands/ticket.go` -- Create: `internal/discord/components/ticket.go` (delete confirm buttons) - -- [ ] **Step 1: Write ticket command handler** - -`internal/discord/commands/ticket.go` — handles all `/ticket *` subcommands with auth checks, deferred interactions for close/transcript. - -- [ ] **Step 2: Write ticket component handler** - -`internal/discord/components/ticket.go` — handles `ticket:delete:confirm:`, `ticket:delete:yes:`, `ticket:delete:cancel`. - ---- - -## Task 9: /convocation Command - -**Files:** -- Create: `internal/discord/commands/convocation.go` - ---- - -## Task 10: Discord Log Channel - -**Files:** -- Create: `internal/logger/discord.go` - -`internal/logger/discord.go`: -```go -package logger - -import ( - "fmt" - "log/slog" - - "github.com/bwmarrin/discordgo" - "github.com/leolionad58/ticketbot/internal/config" - "github.com/leolionad58/ticketbot/internal/db" -) - -type DiscordLogger struct { - Session *discordgo.Session - Config *config.Provider -} - -func (l *DiscordLogger) send(embed *discordgo.MessageEmbed) { - cfg := l.Config.Get() - if cfg.Bot.LogsChannel == "" { - return - } - _, err := l.Session.ChannelMessageSendEmbed(cfg.Bot.LogsChannel, embed) - if err != nil { - slog.Error("discord log: send", "err", err) - } -} - -func (l *DiscordLogger) LogTicketOpened(ticket *db.Ticket, userID string) { - l.send(&discordgo.MessageEmbed{ - Title: "Ticket ouvert", - Color: 0x57f287, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Type", Value: ticket.Type, Inline: true}, - {Name: "Utilisateur", Value: fmt.Sprintf("<@%s>", userID), Inline: true}, - {Name: "Channel", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, - {Name: "Numéro", Value: fmt.Sprintf("%04d", ticket.TicketNumber), Inline: true}, - }, - }) -} - -func (l *DiscordLogger) LogTicketClaimed(ticket *db.Ticket, staffID string) { - l.send(&discordgo.MessageEmbed{ - Title: "Ticket claim", - Color: 0xfee75c, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Staff", Value: fmt.Sprintf("<@%s>", staffID), Inline: true}, - {Name: "Ticket", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, - }, - }) -} - -func (l *DiscordLogger) LogTicketClosed(ticket *db.Ticket, staffID, reason, transcriptPath string) { - embed := &discordgo.MessageEmbed{ - Title: "Ticket fermé", - Color: 0xed4245, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Staff", Value: fmt.Sprintf("<@%s>", staffID), Inline: true}, - {Name: "Ticket", Value: ticket.ChannelID, Inline: true}, - {Name: "Raison", Value: reason, Inline: false}, - }, - } - l.send(embed) -} - -func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason string, channelID string) { - l.send(&discordgo.MessageEmbed{ - Title: "Convocation ouverte", - Color: 0x5865f2, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Staff", Value: fmt.Sprintf("<@%s>", staffID), Inline: true}, - {Name: "Utilisateur", Value: fmt.Sprintf("<@%s>", targetID), Inline: true}, - {Name: "Raison", Value: reason}, - {Name: "Channel", Value: fmt.Sprintf("<#%s>", channelID), Inline: true}, - }, - }) -} -``` - ---- - -## Task 11: Restart Resilience - -**Files:** -- Modify: `internal/discord/events/ready.go` -- Modify: `cmd/bot/main.go` - -On Ready, load all `status='open'` tickets from DB and resume their re-up goroutines using `ClaimManager.ResumeTicket`. - ---- - -## Task 12: Dockerfile + docker-compose + README - -**Files:** -- Create: `Dockerfile` -- Create: `docker-compose.yml` -- Create: `README.md` -- Create: `config.yaml.example` -- Create: `.env.example` - ---- - -## Self-Review Against Spec - -**Coverage check:** -- [x] Panels & ticket opening (Task 5) -- [x] Claim system + re-up loop (Task 6) -- [x] Ticket deletion + transcript (Task 7) -- [x] Transcript HTML discord-like (Task 7) -- [x] /ticket close|add|remove|rename|transcript|reclaim (Task 8) -- [x] /convocation (Task 9) -- [x] Logs channel (Task 10) -- [x] Hot reload (Task 2) -- [x] DB schema + migrations (Task 3) -- [x] AuthService.Can() (Task 5) -- [x] Restart resilience (Task 11) -- [x] No Discord native perms check — only role-based -- [x] Deferred interactions for long ops -- [x] Edge cases: double-click, manual channel delete, user leaves, re-up race condition -- [x] Docker + README (Task 12) - -**Gaps identified:** -- `/ticket add` check for already-present user (ephemeral response needed) -- `/ticket remove` check for ticket opener (refuse) -- Convocation: refuse if bot or self target -- `ticket:delete:confirm` → confirmation flow with Yes/Cancel buttons -- Transcript upload as attachment in close log -- `convoc--` naming with separate convocation counter - -These are handled in Tasks 8 and 9 implementations.