diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d4eec6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o ticketbot ./cmd/bot + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/ticketbot . +RUN mkdir -p data transcripts +VOLUME ["/app/data", "/app/transcripts"] +ENTRYPOINT ["./ticketbot"] diff --git a/bot.exe b/bot.exe new file mode 100644 index 0000000..9b4b067 Binary files /dev/null and b/bot.exe differ diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..0927059 --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/joho/godotenv" + "github.com/leolionad58/ticketbot/internal/claim" + "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/commands" + "github.com/leolionad58/ticketbot/internal/discord/components" + "github.com/leolionad58/ticketbot/internal/discord/events" + "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/tickets" + "github.com/leolionad58/ticketbot/internal/transcript" +) + +func main() { + setupLogger() + godotenv.Load(".env") //nolint — optional file + + cfg, err := config.Load("config.yaml") + must(err, "load config") + cfgProvider := config.NewProvider(cfg) + + must(os.MkdirAll("data", 0755), "create data dir") + must(os.MkdirAll("transcripts", 0755), "create transcripts dir") + + sqldb, err := db.Open("data/tickets.db") + must(err, "open db") + defer sqldb.Close() + + ticketRepo := db.NewTicketRepo(sqldb) + claimRepo := 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") + guildID := os.Getenv("GUILD_ID") + + bot, err := discordbot.New(token, cfgProvider, ticketRepo, claimRepo, guildID) + must(err, "create bot") + + // Services + auth := tickets.NewAuthService(cfgProvider) + ticketSvc := tickets.NewService(ticketRepo, auth, bot.Session, cfgProvider) + claimMgr := claim.NewManager(cfgProvider, ticketRepo, claimRepo) + logSvc := logger.NewDiscordLogger(bot.Session, cfgProvider) + transcriptGen := transcript.NewGenerator("transcripts", bot.Session) + + // Commands + panelCmd := &commands.PanelCommand{Config: cfgProvider, Auth: auth} + ticketCmd := &commands.TicketCommand{ + TicketSvc: ticketSvc, + TicketRepo: ticketRepo, + ClaimMgr: claimMgr, + LogSvc: logSvc, + Transcript: transcriptGen, + Auth: auth, + } + convocCmd := &commands.ConvocationCommand{ + Config: cfgProvider, + TicketRepo: ticketRepo, + Auth: auth, + LogSvc: logSvc, + } + + // Components + panelComp := &components.PanelComponent{ + TicketSvc: ticketSvc, + ClaimMgr: claimMgr, + LogSvc: logSvc, + Config: cfgProvider, + } + ticketComp := &components.TicketComponent{ + TicketSvc: ticketSvc, + TicketRepo: ticketRepo, + ClaimMgr: claimMgr, + LogSvc: logSvc, + Transcript: transcriptGen, + Auth: auth, + } + + // Router + router := &discordbot.Router{ + PanelCmd: panelCmd, + TicketCmd: ticketCmd, + ConvocCmd: convocCmd, + PanelComp: panelComp, + TicketComp: ticketComp, + } + bot.Session.AddHandler(router.Handle) + + // Events + readyHandler := &events.ReadyEvent{ + TicketRepo: ticketRepo, + ClaimRepo: claimRepo, + ClaimMgr: claimMgr, + } + bot.Session.AddHandler(readyHandler.Handle) + + cdHandler := &events.ChannelDeleteHandler{Tickets: ticketRepo} + bot.Session.AddHandler(cdHandler.Handle) + + must(bot.Open(), "open discord session") + defer bot.Close() + + if appID != "" { + if err := bot.RegisterCommands(appID); err != nil { + slog.Error("register commands", "err", err) + } + } + + if err := config.Watch("config.yaml", cfgProvider, nil); err != nil { + slog.Warn("config watcher failed to start", "err", err) + } + + 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) + } +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..a3f7363 --- /dev/null +++ b/config.yaml @@ -0,0 +1,22 @@ +bot: + admin_role: "1499923403761647689" # role pouvant lancer /panel_send + logs_channel: "1499924634655658004" # channel de logs (open/claim/close) + claim_channel: "1499924671884431380" # channel où défilent les tickets à claim + convocation_category: "1499924426358263950" # catégorie pour /convocation + claim_reup_minutes: 1 # délai avant re-up et ping staff + +panels: + support_panel: + embed_title: "Ouvrir un ticket" + embed_description: "Choisis le type de ticket ci-dessous." + embed_color: "#2b2d31" + types: + support: + button_label: "Support" + button_color: "primary" # primary / secondary / success / danger + button_emoji: "🛠️" # optionnel + embed_color: "#5865f2" + embed_title: "Ticket support" + embed_text: "Décris ton problème, un staff va te répondre." + staff_role: "1499923512683659314" + category: "1499924426358263950" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..16e6f03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + ticketbot: + build: . + restart: unless-stopped + env_file: .env + volumes: + - ./data:/app/data + - ./transcripts:/app/transcripts + - ./config.yaml:/app/config.yaml:ro + environment: + LOG_FORMAT: json diff --git a/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md b/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md new file mode 100644 index 0000000..19fabc5 --- /dev/null +++ b/docs/superpowers/plans/2026-05-02-discord-ticket-bot.md @@ -0,0 +1,2397 @@ +# 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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..240fd37 --- /dev/null +++ b/go.mod @@ -0,0 +1,22 @@ +module github.com/leolionad58/ticketbot + +go 1.26.2 + +require ( + github.com/bwmarrin/discordgo v0.29.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.10.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.72.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.50.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..eb790e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,40 @@ +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c= +modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= diff --git a/internal/claim/manager.go b/internal/claim/manager.go new file mode 100644 index 0000000..f15b8cb --- /dev/null +++ b/internal/claim/manager.go @@ -0,0 +1,190 @@ +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 initial 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 message", "ticket_id", ticket.ID, "err", err) + } else { + if err := m.claims.Upsert(ctx, ticket.ID, msgID, time.Now()); err != nil { + slog.Error("claim: upsert claim_message", "ticket_id", ticket.ID, "err", err) + } + } + m.startReupLoop(s, ticket, time.Now()) +} + +// ResumeTicket re-attaches the re-up loop after a bot restart. +func (m *Manager) ResumeTicket(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { + m.startReupLoop(s, ticket, lastReupAt) +} + +// Stop cancels the re-up loop for a ticket (call 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) + } +} + +// DeleteClaimMessage removes the claim message from the claim channel. +func (m *Manager) DeleteClaimMessage(ctx context.Context, s *discordgo.Session, ticketID int64) { + cm, err := m.claims.Get(ctx, ticketID) + if err != nil || cm == nil { + return + } + cfg := m.cfg.Get() + if err := s.ChannelMessageDelete(cfg.Bot.ClaimChannel, cm.MessageID); err != nil { + slog.Warn("claim: delete message", "ticket_id", ticketID, "err", err) + } + m.claims.Delete(ctx, ticketID) //nolint +} + +func (m *Manager) startReupLoop(s *discordgo.Session, ticket *db.Ticket, lastReupAt time.Time) { + ctx, cancel := context.WithCancel(context.Background()) + m.mu.Lock() + // Cancel previous loop if any (shouldn't happen in normal flow) + if old, ok := m.stops[ticket.ID]; ok { + old() + } + 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 = time.Second // fire almost immediately if already overdue + } + + timer := time.NewTimer(next) + defer timer.Stop() + + reupCount := 0 + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + // Verify ticket is still open before re-posting + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + t, err := m.repo.GetByID(checkCtx, ticket.ID) + checkCancel() + if err != nil || t == nil || t.Status != "open" { + return + } + + // Delete old claim message + delCtx, delCancel := context.WithTimeout(ctx, 5*time.Second) + m.DeleteClaimMessage(delCtx, s, ticket.ID) + delCancel() + + // Re-post with staff ping (first re-up and beyond) + postCtx, postCancel := context.WithTimeout(ctx, 10*time.Second) + msgID, postErr := m.postClaim(postCtx, s, ticket, reupCount >= 0) + postCancel() + if postErr != nil { + slog.Error("claim reup: post", "ticket_id", ticket.ID, "err", postErr) + } else { + saveCtx, saveCancel := context.WithTimeout(context.Background(), 3*time.Second) + m.claims.Upsert(saveCtx, ticket.ID, msgID, time.Now()) //nolint + saveCancel() + } + reupCount++ + + // Re-read config in case of hot reload + 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 + if claimChannelID == "" { + return "", fmt.Errorf("claim_channel not configured") + } + + elapsed := time.Since(ticket.OpenedAt).Round(time.Second) + embed := &discordgo.MessageEmbed{ + Title: "Nouveau ticket à claim", + Color: 0x5865f2, + 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}, + }, + } + + 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, + }, + }}, + }, + } + + if withPing { + if panel, ok := cfg.Panels[ticket.Panel]; ok { + if t, ok := panel.Types[ticket.Type]; ok && t.StaffRole != "" { + send.Content = fmt.Sprintf("<@&%s>", t.StaffRole) + } + } + } + + msg, err := s.ChannelMessageSendComplex(claimChannelID, send) + if err != nil { + return "", err + } + return msg.ID, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4d88faf --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + "os" + "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 { + 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) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4265bae --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,91 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +const validConfig = ` +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" +` + +func writeConfig(t *testing.T, content string) string { + t.Helper() + f := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(f, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return f +} + +func TestLoadValidConfig(t *testing.T) { + cfg, err := Load(writeConfig(t, validConfig)) + 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) { + f := writeConfig(t, "bot:\n logs_channel: \"111\"\n") + _, err := Load(f) + if err == nil { + t.Fatal("expected error for missing admin_role") + } +} + +func TestDefaultReupMinutes(t *testing.T) { + f := writeConfig(t, "bot:\n admin_role: \"123\"\n") + cfg, err := Load(f) + if err != nil { + t.Fatal(err) + } + if cfg.Bot.ClaimReupMinutes != 30 { + t.Errorf("default reup = %d, want 30", cfg.Bot.ClaimReupMinutes) + } +} + +func TestProviderSwap(t *testing.T) { + cfg1 := &Config{Bot: Bot{AdminRole: "a"}} + p := NewProvider(cfg1) + if p.Get().Bot.AdminRole != "a" { + t.Fatal("initial get failed") + } + cfg2 := &Config{Bot: Bot{AdminRole: "b"}} + p.Swap(cfg2) + if p.Get().Bot.AdminRole != "b" { + t.Fatal("swap failed") + } +} diff --git a/internal/config/watcher.go b/internal/config/watcher.go new file mode 100644 index 0000000..79a27b4 --- /dev/null +++ b/internal/config/watcher.go @@ -0,0 +1,57 @@ +package config + +import ( + "log/slog" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Watch watches the config file and calls onReload with the new config on change. +// Debounces Write/Create events by 500ms to avoid multiple triggers on a single save. +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 +} diff --git a/internal/db/claim_messages.go b/internal/db/claim_messages.go new file mode 100644 index 0000000..be160ee --- /dev/null +++ b/internal/db/claim_messages.go @@ -0,0 +1,58 @@ +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() +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..65cdcb8 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,78 @@ +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() + if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil { + return err + } + var version int + db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM schema_version`).Scan(&version) //nolint + + migrations := []string{ + // v1: core schema + `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 +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..a0a8a67 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,134 @@ +package db + +import ( + "context" + "path/filepath" + "testing" + "time" +) + +func openTestDB(t *testing.T) (*TicketRepo, *ClaimMessageRepo) { + 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), NewClaimMessageRepo(sqldb) +} + +func TestInsertAndGet(t *testing.T) { + repo, _ := openTestDB(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) + } + if got.Status != "open" { + t.Errorf("status = %q, want open", got.Status) + } +} + +func TestTicketNumberPerType(t *testing.T) { + repo, _ := openTestDB(t) + ctx := context.Background() + + for i := 0; i < 3; i++ { + tk := &Ticket{UserID: "u", Panel: "p", Type: "support", ChannelID: "c" + string(rune('a'+i)), OpenedAt: time.Now()} + if err := repo.Insert(ctx, tk); err != nil { + t.Fatal(err) + } + } + // mod type starts its own counter at 1 + tk2 := &Ticket{UserID: "u", Panel: "p", Type: "mod", ChannelID: "cx", OpenedAt: time.Now()} + if err := repo.Insert(ctx, tk2); err != nil { + t.Fatal(err) + } + if tk2.TicketNumber != 1 { + t.Errorf("mod ticket_number = %d, want 1", tk2.TicketNumber) + } +} + +func TestHasOpenTicket(t *testing.T) { + repo, _ := openTestDB(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") + } +} + +func TestClaimMessages(t *testing.T) { + _, claims := openTestDB(t) + ctx := context.Background() + + err := claims.Upsert(ctx, 1, "msg1", time.Now()) + if err != nil { + t.Fatal(err) + } + cm, err := claims.Get(ctx, 1) + if err != nil || cm == nil { + t.Fatal("expected claim message") + } + if cm.MessageID != "msg1" { + t.Errorf("message_id = %q, want msg1", cm.MessageID) + } + + // Upsert updates + claims.Upsert(ctx, 1, "msg2", time.Now()) + cm, _ = claims.Get(ctx, 1) + if cm.MessageID != "msg2" { + t.Errorf("after update message_id = %q, want msg2", cm.MessageID) + } + + claims.Delete(ctx, 1) + cm, _ = claims.Get(ctx, 1) + if cm != nil { + t.Fatal("expected nil after delete") + } +} + +func TestMigrationIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "test.db") + db1, err := Open(path) + if err != nil { + t.Fatal(err) + } + db1.Close() + // Open again — migrations must not fail or duplicate + db2, err := Open(path) + if err != nil { + t.Fatalf("second open: %v", err) + } + db2.Close() +} diff --git a/internal/db/tickets.go b/internal/db/tickets.go new file mode 100644 index 0000000..439040f --- /dev/null +++ b/internal/db/tickets.go @@ -0,0 +1,186 @@ +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 transaction. +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() //nolint + + 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() + t.Status = "open" + return tx.Commit() +} + +// InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations). +func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error { + res, err := r.db.ExecContext(ctx, ` + INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status) + VALUES(?,?,?,?,?,?,?)`, + t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", + ) + if err != nil { + return fmt.Errorf("insert ticket with number: %w", err) + } + t.ID, _ = res.LastInsertId() + t.Status = "open" + return nil +} + +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) ListOpen(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() + return scanTickets(rows) +} + +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 (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() //nolint + var n int + if err := tx.QueryRowContext(ctx, `UPDATE convocation_counter SET count=count+1 WHERE id=1 RETURNING count`).Scan(&n); err != nil { + return 0, err + } + return n, tx.Commit() +} + +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 scanTickets(rows *sql.Rows) ([]*Ticket, error) { + var result []*Ticket + for rows.Next() { + var t Ticket + if 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); err != nil { + return nil, err + } + result = append(result, &t) + } + return result, rows.Err() +} diff --git a/internal/discord/bot.go b/internal/discord/bot.go new file mode 100644 index 0000000..1dbf3be --- /dev/null +++ b/internal/discord/bot.go @@ -0,0 +1,57 @@ +package discord + +import ( + "fmt" + "log/slog" + + "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, guildID string) (*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: guildID, + }, nil +} + +func (b *Bot) Open() error { + return b.Session.Open() +} + +func (b *Bot) Close() { + b.Session.Close() +} + +func (b *Bot) RegisterCommands(appID string) error { + cmds := ApplicationCommands() + for _, cmd := range cmds { + _, 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 +} diff --git a/internal/discord/commands.go b/internal/discord/commands.go new file mode 100644 index 0000000..6b0a574 --- /dev/null +++ b/internal/discord/commands.go @@ -0,0 +1,68 @@ +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}, + }, + }, + } +} diff --git a/internal/discord/commands/convocation.go b/internal/discord/commands/convocation.go new file mode 100644 index 0000000..9a3e08c --- /dev/null +++ b/internal/discord/commands/convocation.go @@ -0,0 +1,187 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/config" + "github.com/leolionad58/ticketbot/internal/db" + "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/tickets" +) + +type ConvocationCommand struct { + Config *config.Provider + TicketRepo *db.TicketRepo + Auth *tickets.AuthService + LogSvc *logger.DiscordLogger +} + +// Handle handles /convocation . +func (c *ConvocationCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + memberRoles := memberRoleIDs(i) + staffID := interactionUserID(i) + + if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + slog.Warn("convocation denied", "user_id", staffID) + return + } + + data := i.ApplicationCommandData() + target := data.Options[0].UserValue(s) + reason := data.Options[1].StringValue() + + if target == nil { + ephemeral(s, i, "Utilisateur introuvable.") + return + } + if target.Bot { + ephemeral(s, i, "Tu ne peux pas convoquer un bot.") + return + } + if target.ID == staffID { + ephemeral(s, i, "Tu ne peux pas te convoquer toi-même.") + return + } + + // Defer — channel creation can take time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + cfg := c.Config.Get() + if cfg.Bot.ConvocationCategory == "" { + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ //nolint + Content: "La catégorie de convocation n'est pas configurée.", + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + // Get next convocation number + n, err := c.TicketRepo.NextConvocationNumber(ctx) + if err != nil { + slog.Error("convocation: next number", "err", err) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur interne.", Flags: discordgo.MessageFlagsEphemeral}) //nolint + return + } + + channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocName(target.Username), n) + if len(channelName) > 100 { + channelName = channelName[:100] + } + + ch, err := s.GuildChannelCreateComplex(i.GuildID, discordgo.GuildChannelCreateData{ + Name: channelName, + Type: discordgo.ChannelTypeGuildText, + ParentID: cfg.Bot.ConvocationCategory, + PermissionOverwrites: []*discordgo.PermissionOverwrite{ + { + ID: i.GuildID, + Type: discordgo.PermissionOverwriteTypeRole, + Deny: discordgo.PermissionViewChannel, + }, + { + ID: staffID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory | + discordgo.PermissionAttachFiles, + }, + { + ID: target.ID, + Type: discordgo.PermissionOverwriteTypeMember, + Allow: discordgo.PermissionViewChannel | + discordgo.PermissionSendMessages | + discordgo.PermissionReadMessageHistory, + }, + }, + }) + if err != nil { + slog.Error("convocation: create channel", "err", err) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la création du channel.", Flags: discordgo.MessageFlagsEphemeral}) //nolint + return + } + + // Insert in DB as type=convocation + ticket := &db.Ticket{ + UserID: target.ID, + Panel: "convocation", + Type: "convocation", + ChannelID: ch.ID, + OpenedAt: time.Now(), + Status: "open", + } + ticket.TicketNumber = n + // Manual insert without number increment (we already computed it) + if err := insertConvocation(ctx, c.TicketRepo, ticket); err != nil { + slog.Error("convocation: insert DB", "err", err) + } + + // Custom delete button ID encodes staff ID for auth + deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID) + if len(deleteCustomID) > 100 { + deleteCustomID = "convoc:delete:" + ch.ID + } + + // Post embed in convocation channel + s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint + Content: fmt.Sprintf("<@%s>", target.ID), + Embeds: []*discordgo.MessageEmbed{ + { + Title: "Convocation", + Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffID, reason), + Color: 0x5865f2, + }, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Supprimer", + Style: discordgo.DangerButton, + CustomID: deleteCustomID, + }, + }}, + }, + }) + + c.LogSvc.LogConvocationOpened(staffID, target.ID, reason, ch.ID) + slog.Info("convocation created", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.ID) + + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ //nolint + Content: fmt.Sprintf("Convocation créée : <#%s>", ch.ID), + Flags: discordgo.MessageFlagsEphemeral, + }) +} + +func sanitizeConvocName(username string) string { + var out []byte + for _, b := range []byte(username) { + if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' { + out = append(out, b) + } else if b >= 'A' && b <= 'Z' { + out = append(out, b+32) + } + } + if len(out) == 0 { + return "user" + } + if len(out) > 20 { + out = out[:20] + } + return string(out) +} + +// insertConvocation inserts a convocation ticket that already has its number set. +func insertConvocation(ctx context.Context, repo *db.TicketRepo, ticket *db.Ticket) error { + return repo.InsertWithNumber(ctx, ticket) +} diff --git a/internal/discord/commands/helpers.go b/internal/discord/commands/helpers.go new file mode 100644 index 0000000..8ff1803 --- /dev/null +++ b/internal/discord/commands/helpers.go @@ -0,0 +1,63 @@ +package commands + +import ( + "os" + "strconv" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/config" +) + +func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + 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 interactionUserID(i *discordgo.InteractionCreate) string { + if i.Member != nil && i.Member.User != 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 openFile(path string) (*os.File, error) { + return os.Open(path) +} + +func buttonStyle(color config.ButtonColor) discordgo.ButtonStyle { + switch color { + case config.ColorPrimary: + return discordgo.PrimaryButton + case config.ColorSecondary: + return discordgo.SecondaryButton + case config.ColorSuccess: + return discordgo.SuccessButton + case config.ColorDanger: + return discordgo.DangerButton + default: + return discordgo.PrimaryButton + } +} diff --git a/internal/discord/commands/panel.go b/internal/discord/commands/panel.go new file mode 100644 index 0000000..52354f6 --- /dev/null +++ b/internal/discord/commands/panel.go @@ -0,0 +1,74 @@ +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) { + 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", interactionUserID(i)) + return + } + + data := i.ApplicationCommandData() + 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) + embed := &discordgo.MessageEmbed{ + Title: panel.EmbedTitle, + Description: panel.EmbedDescription, + Color: parseColor(panel.EmbedColor), + } + + if _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{embed}, + Components: components, + }); 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é dans <#"+channelID+">.") +} + +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 != "" { + emoji := discordgo.ComponentEmoji{Name: t.ButtonEmoji} + btn.Emoji = &emoji + } + buttons = append(buttons, btn) + } + return []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: buttons}, + } +} diff --git a/internal/discord/commands/ticket.go b/internal/discord/commands/ticket.go new file mode 100644 index 0000000..08363fe --- /dev/null +++ b/internal/discord/commands/ticket.go @@ -0,0 +1,255 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/claim" + "github.com/leolionad58/ticketbot/internal/db" + "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/tickets" + "github.com/leolionad58/ticketbot/internal/transcript" +) + +type TicketCommand struct { + TicketSvc *tickets.Service + TicketRepo *db.TicketRepo + ClaimMgr *claim.Manager + LogSvc *logger.DiscordLogger + Transcript *transcript.Generator + Auth *tickets.AuthService +} + +// Handle dispatches /ticket subcommands. +func (c *TicketCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + ephemeral(s, i, "Sous-commande manquante.") + return + } + sub := data.Options[0] + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // All /ticket commands must be used inside a ticket channel + ticket, err := c.TicketRepo.GetByChannelID(ctx, i.ChannelID) + if err != nil || ticket == nil { + ephemeral(s, i, "Cette commande doit être utilisée dans un channel de ticket.") + return + } + if ticket.Status == "closed" { + ephemeral(s, i, "Ce ticket est déjà fermé.") + return + } + + memberRoles := memberRoleIDs(i) + staffID := interactionUserID(i) + + switch sub.Name { + case "close": + c.handleClose(ctx, s, i, ticket, memberRoles, staffID, sub) + case "add": + c.handleAdd(ctx, s, i, ticket, memberRoles, sub) + case "remove": + c.handleRemove(ctx, s, i, ticket, memberRoles, staffID, sub) + case "rename": + c.handleRename(ctx, s, i, ticket, memberRoles, sub) + case "transcript": + c.handleTranscript(ctx, s, i, ticket, memberRoles) + case "reclaim": + c.handleReclaim(ctx, s, i, ticket, memberRoles, staffID) + default: + ephemeral(s, i, "Sous-commande inconnue.") + } +} + +func (c *TicketCommand) handleClose(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string, sub *discordgo.ApplicationCommandInteractionDataOption) { + if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + slog.Warn("ticket close denied", "user_id", staffID, "channel_id", ticket.ChannelID) + return + } + + reason := "Fermé via /ticket close" + if len(sub.Options) > 0 { + reason = sub.Options[0].StringValue() + } + + // Defer — transcript + channel delete takes time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + // Generate transcript + transcriptPath := "" + if tp, err := c.Transcript.Generate(ticket); err != nil { + slog.Error("close: generate transcript", "ticket_id", ticket.ID, "err", err) + } else { + transcriptPath = tp + } + + if err := c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, reason, transcriptPath, time.Now()); err != nil { + slog.Error("close: set closed", "ticket_id", ticket.ID, "err", err) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la fermeture.", Flags: discordgo.MessageFlagsEphemeral}) //nolint + return + } + + c.ClaimMgr.Stop(ticket.ID) + c.ClaimMgr.DeleteClaimMessage(ctx, s, ticket.ID) + + ticket.TranscriptPath.String = transcriptPath + ticket.TranscriptPath.Valid = transcriptPath != "" + c.LogSvc.LogTicketClosed(ticket, staffID, reason, transcriptPath) + + if transcriptPath != "" { + sendTranscriptFile(s, c.LogSvc.Config.Get().Bot.LogsChannel, transcriptPath, ticket) + } + + slog.Info("ticket closed", "ticket_id", ticket.ID, "staff_id", staffID) + c.TicketSvc.DeleteChannel(ticket.ChannelID) //nolint +} + +func (c *TicketCommand) handleAdd(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, sub *discordgo.ApplicationCommandInteractionDataOption) { + if !c.Auth.Can(memberRoles, tickets.ActionAdd, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + + target := sub.Options[0].UserValue(s) + if target == nil { + ephemeral(s, i, "Utilisateur introuvable.") + return + } + + // Check if already present by trying to get channel permission overwrites + ch, err := s.Channel(ticket.ChannelID) + if err == nil { + for _, ow := range ch.PermissionOverwrites { + if ow.ID == target.ID && ow.Type == discordgo.PermissionOverwriteTypeMember { + ephemeral(s, i, fmt.Sprintf("<@%s> est déjà présent dans ce ticket.", target.ID)) + return + } + } + } + + if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, target.ID); err != nil { + slog.Error("ticket add: add member", "err", err) + ephemeral(s, i, "Erreur lors de l'ajout.") + return + } + ephemeral(s, i, fmt.Sprintf("<@%s> a été ajouté au ticket.", target.ID)) +} + +func (c *TicketCommand) handleRemove(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string, sub *discordgo.ApplicationCommandInteractionDataOption) { + if !c.Auth.Can(memberRoles, tickets.ActionRemove, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + + target := sub.Options[0].UserValue(s) + if target == nil { + ephemeral(s, i, "Utilisateur introuvable.") + return + } + if target.ID == ticket.UserID { + ephemeral(s, i, "Tu ne peux pas retirer l'utilisateur qui a ouvert le ticket.") + return + } + if target.ID == staffID { + ephemeral(s, i, "Tu ne peux pas te retirer toi-même du ticket.") + return + } + + if err := c.TicketSvc.RemoveMember(ctx, ticket.ChannelID, target.ID); err != nil { + slog.Error("ticket remove: remove member", "err", err) + ephemeral(s, i, "Erreur lors de la suppression.") + return + } + ephemeral(s, i, fmt.Sprintf("<@%s> a été retiré du ticket.", target.ID)) +} + +func (c *TicketCommand) handleRename(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, sub *discordgo.ApplicationCommandInteractionDataOption) { + if !c.Auth.Can(memberRoles, tickets.ActionRename, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + + name := sub.Options[0].StringValue() + if err := c.TicketSvc.Rename(ctx, ticket.ChannelID, name); err != nil { + slog.Error("ticket rename", "err", err) + ephemeral(s, i, "Nom invalide ou erreur lors du renommage : "+err.Error()) + return + } + ephemeral(s, i, "Channel renommé.") +} + +func (c *TicketCommand) handleTranscript(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string) { + if !c.Auth.Can(memberRoles, tickets.ActionTranscript, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + + // Defer — message fetching takes time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + tp, err := c.Transcript.Generate(ticket) + if err != nil { + slog.Error("transcript: generate", "ticket_id", ticket.ID, "err", err) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la génération du transcript.", Flags: discordgo.MessageFlagsEphemeral}) //nolint + return + } + + sendTranscriptFile(s, ticket.ChannelID, tp, ticket) + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Transcript généré.", Flags: discordgo.MessageFlagsEphemeral}) //nolint +} + +func (c *TicketCommand) handleReclaim(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string) { + if !c.Auth.Can(memberRoles, tickets.ActionReclaim, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + if ticket.Status != "claimed" { + ephemeral(s, i, "Ce ticket n'est pas claim.") + return + } + + if err := c.TicketRepo.SetClaimed(ctx, ticket.ID, staffID, time.Now()); err != nil { + slog.Error("reclaim: set claimed", "err", err) + ephemeral(s, i, "Erreur lors du reclaim.") + return + } + + if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, staffID); err != nil { + slog.Error("reclaim: add member", "err", err) + } + + s.ChannelMessageSend(ticket.ChannelID, fmt.Sprintf("Ticket reclaim par <@%s>.", staffID)) //nolint + c.LogSvc.LogTicketClaimed(ticket, staffID) + ephemeral(s, i, "Tu as reclaim ce ticket.") +} + +func sendTranscriptFile(s *discordgo.Session, channelID, path string, ticket *db.Ticket) { + if channelID == "" || path == "" { + return + } + f, err := openFile(path) + if err != nil { + slog.Error("send transcript: open file", "path", path, "err", err) + return + } + defer f.Close() + name := fmt.Sprintf("%s-%04d-transcript.html", ticket.Type, ticket.TicketNumber) + if _, err := s.ChannelFileSendWithMessage(channelID, + fmt.Sprintf("Transcript — %s-%04d", ticket.Type, ticket.TicketNumber), + name, f); err != nil { + slog.Error("send transcript: upload", "err", err) + } +} diff --git a/internal/discord/components/helpers.go b/internal/discord/components/helpers.go new file mode 100644 index 0000000..d3feadb --- /dev/null +++ b/internal/discord/components/helpers.go @@ -0,0 +1,53 @@ +package components + +import ( + "strconv" + + "github.com/bwmarrin/discordgo" +) + +func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + 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{ //nolint + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }) +} + +func interactionUserID(i *discordgo.InteractionCreate) string { + if i.Member != nil && i.Member.User != nil { + return i.Member.User.ID + } + if i.User != nil { + return i.User.ID + } + return "" +} + +func interactionGuildID(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) +} + +func memberRoleIDs(i *discordgo.InteractionCreate) []string { + if i.Member != nil { + return i.Member.Roles + } + return nil +} diff --git a/internal/discord/components/panel.go b/internal/discord/components/panel.go new file mode 100644 index 0000000..2e49cc8 --- /dev/null +++ b/internal/discord/components/panel.go @@ -0,0 +1,88 @@ +package components + +import ( + "context" + "errors" + "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 +} + +// Handle handles clicks on panel open buttons (custom_id: panel:open::). +func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + 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{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + uID := interactionUserID(i) + ticket, err := c.TicketSvc.Open(ctx, interactionGuildID(i), uID, panelName, ticketType) + if err != nil { + if errors.Is(err, tickets.ErrAlreadyOpen) { + chID := strings.TrimPrefix(err.Error(), fmt.Sprintf("%s:", tickets.ErrAlreadyOpen)) + followup(s, i, fmt.Sprintf("Tu as déjà un ticket ouvert : <#%s>", chID)) + return + } + slog.Error("open ticket", "user_id", uID, "panel", panelName, "type", ticketType, "err", err) + followup(s, i, "Erreur lors de la création du ticket.") + return + } + + cfg := c.Config.Get() + var embedTitle, embedText, embedColor string + if panel, ok := cfg.Panels[panelName]; ok { + if typeCfg, ok := panel.Types[ticketType]; ok { + embedTitle = typeCfg.EmbedTitle + embedText = typeCfg.EmbedText + embedColor = typeCfg.EmbedColor + } + } + + deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID + if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{ + Embeds: []*discordgo.MessageEmbed{ + {Title: embedTitle, Description: embedText, Color: parseColor(embedColor)}, + }, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Supprimer le ticket", + Style: discordgo.DangerButton, + CustomID: deleteCustomID, + }, + }}, + }, + }); err != nil { + slog.Error("open ticket: post welcome embed", "channel_id", ticket.ChannelID, "err", err) + } + + c.ClaimMgr.StartTicket(ctx, s, ticket) + c.LogSvc.LogTicketOpened(ticket, uID) + + slog.Info("ticket opened", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID) + followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID)) +} diff --git a/internal/discord/components/ticket.go b/internal/discord/components/ticket.go new file mode 100644 index 0000000..0feb709 --- /dev/null +++ b/internal/discord/components/ticket.go @@ -0,0 +1,291 @@ +package components + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/claim" + "github.com/leolionad58/ticketbot/internal/db" + "github.com/leolionad58/ticketbot/internal/logger" + "github.com/leolionad58/ticketbot/internal/tickets" + "github.com/leolionad58/ticketbot/internal/transcript" +) + +type TicketComponent struct { + TicketSvc *tickets.Service + TicketRepo *db.TicketRepo + ClaimMgr *claim.Manager + LogSvc *logger.DiscordLogger + Transcript *transcript.Generator + Auth *tickets.AuthService +} + +// HandleDeleteConfirm handles the "Supprimer le ticket" button — shows Yes/Cancel confirmation. +// custom_id: ticket:delete:confirm: +func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4) + if len(parts) != 4 { + return + } + channelID := parts[3] + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID) + if err != nil || ticket == nil { + ephemeral(s, i, "Ticket introuvable.") + return + } + if ticket.Status == "closed" { + ephemeral(s, i, "Ce ticket est déjà fermé.") + return + } + + memberRoles := memberRoleIDs(i) + if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + slog.Warn("delete denied", "user_id", interactionUserID(i), "channel_id", channelID) + return + } + + yesID := "ticket:delete:yes:" + channelID + cancelID := "ticket:delete:cancel" + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Es-tu sûr de vouloir supprimer ce ticket ? Cette action est irréversible.", + Flags: discordgo.MessageFlagsEphemeral, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID}, + discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID}, + }}, + }, + }, + }) +} + +// HandleDeleteYes handles the "Oui, supprimer" confirmation button. +// custom_id: ticket:delete:yes: +func (c *TicketComponent) HandleDeleteYes(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4) + if len(parts) != 4 { + return + } + channelID := parts[3] + + // Defer — transcript generation + channel delete can take time + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredMessageUpdate, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID) + if err != nil || ticket == nil { + followup(s, i, "Ticket introuvable.") + return + } + if ticket.Status == "closed" { + followup(s, i, "Ce ticket est déjà fermé.") + return + } + + memberRoles := memberRoleIDs(i) + staffID := interactionUserID(i) + if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) { + followup(s, i, "Tu n'as pas la permission d'effectuer cette action.") + return + } + + c.closeTicket(ctx, s, i, ticket, staffID, "Fermé via bouton") +} + +// HandleDeleteCancel dismisses the confirmation (no-op update). +// custom_id: ticket:delete:cancel +func (c *TicketComponent) HandleDeleteCancel(s *discordgo.Session, i *discordgo.InteractionCreate) { + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseUpdateMessage, + Data: &discordgo.InteractionResponseData{ + Content: "Suppression annulée.", + Components: []discordgo.MessageComponent{}, + }, + }) +} + +// HandleClaim handles the Claim button in the claim channel. +// custom_id: claim:take: +func (c *TicketComponent) HandleClaim(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3) + if len(parts) != 3 { + return + } + + var ticketID int64 + fmt.Sscanf(parts[2], "%d", &ticketID) + + // Defer — DB + permission ops + s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + ticket, err := c.TicketRepo.GetByID(ctx, ticketID) + if err != nil || ticket == nil { + followup(s, i, "Ticket introuvable.") + return + } + + staffID := interactionUserID(i) + memberRoles := memberRoleIDs(i) + + if ticket.Status == "claimed" { + // Already claimed — clean up claim message if still there + c.ClaimMgr.DeleteClaimMessage(ctx, s, ticketID) + followup(s, i, fmt.Sprintf("Ce ticket a déjà été claim par <@%s>.", ticket.ClaimedBy.String)) + return + } + if ticket.Status == "closed" { + followup(s, i, "Ce ticket est déjà fermé.") + return + } + + if !c.Auth.Can(memberRoles, tickets.ActionClaim, ticket) { + ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.") + slog.Warn("claim denied", "user_id", staffID, "ticket_id", ticketID) + return + } + + if err := c.TicketRepo.SetClaimed(ctx, ticketID, staffID, time.Now()); err != nil { + slog.Error("claim: set claimed", "ticket_id", ticketID, "err", err) + followup(s, i, "Erreur lors du claim.") + return + } + ticket.Status = "claimed" + + // Stop re-up loop and delete claim message + c.ClaimMgr.Stop(ticketID) + c.ClaimMgr.DeleteClaimMessage(ctx, s, ticketID) + + // Add staff to ticket channel + if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, staffID); err != nil { + slog.Error("claim: add staff to channel", "channel_id", ticket.ChannelID, "err", err) + } + + // Post in ticket channel + if _, err := s.ChannelMessageSend(ticket.ChannelID, + fmt.Sprintf("Ticket claim par <@%s>.", staffID)); err != nil { + slog.Error("claim: post in ticket", "channel_id", ticket.ChannelID, "err", err) + } + + c.LogSvc.LogTicketClaimed(ticket, staffID) + slog.Info("ticket claimed", "ticket_id", ticketID, "staff_id", staffID) + followup(s, i, fmt.Sprintf("Tu as claim le ticket <#%s>.", ticket.ChannelID)) +} + +// CloseTicket is exported for use by slash commands. +func (c *TicketComponent) CloseTicket(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, staffID, reason string) { + c.closeTicket(ctx, s, i, ticket, staffID, reason) +} + +func (c *TicketComponent) closeTicket(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, staffID, reason string) { + // Generate transcript + transcriptPath := "" + tp, err := c.Transcript.Generate(ticket) + if err != nil { + slog.Error("close: generate transcript", "ticket_id", ticket.ID, "err", err) + } else { + transcriptPath = tp + } + + // Update DB + if err := c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, reason, transcriptPath, time.Now()); err != nil { + slog.Error("close: set closed", "ticket_id", ticket.ID, "err", err) + } + + // Stop claim loop if still running + c.ClaimMgr.Stop(ticket.ID) + c.ClaimMgr.DeleteClaimMessage(ctx, s, ticket.ID) + + // Log with transcript attachment + ticket.TranscriptPath.String = transcriptPath + ticket.TranscriptPath.Valid = transcriptPath != "" + c.LogSvc.LogTicketClosed(ticket, staffID, reason, transcriptPath) + + // Send transcript to logs_channel as attachment + if transcriptPath != "" { + sendTranscriptAttachment(s, c.LogSvc.Config.Get().Bot.LogsChannel, transcriptPath, ticket) + } + + slog.Info("ticket closed", "ticket_id", ticket.ID, "staff_id", staffID, "reason", reason) + + // Delete channel (this will trigger ChannelDelete handler which sets closed again — idempotent) + if err := c.TicketSvc.DeleteChannel(ticket.ChannelID); err != nil { + slog.Error("close: delete channel", "channel_id", ticket.ChannelID, "err", err) + } +} + +// HandleConvocationDelete handles the Delete button on convocation channels. +// custom_id: convoc:delete:: +func (c *TicketComponent) HandleConvocationDelete(s *discordgo.Session, i *discordgo.InteractionCreate) { + parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4) + if len(parts) < 3 { + return + } + channelID := parts[2] + creatorStaffID := "" + if len(parts) == 4 { + creatorStaffID = parts[3] + } + + staffID := interactionUserID(i) + memberRoles := memberRoleIDs(i) + + // Only the creator staff or admin can delete + isAdmin := c.Auth.Can(memberRoles, tickets.ActionAdmin, nil) + if staffID != creatorStaffID && !isAdmin { + ephemeral(s, i, "Tu n'as pas la permission de supprimer cette convocation.") + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID) + if err == nil && ticket != nil { + c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, "Convocation supprimée", "", time.Now()) //nolint + } + + if err := c.TicketSvc.DeleteChannel(channelID); err != nil { + slog.Error("convoc delete: delete channel", "channel_id", channelID, "err", err) + } +} + +func sendTranscriptAttachment(s *discordgo.Session, logsChannel, path string, ticket *db.Ticket) { + if logsChannel == "" || path == "" { + return + } + f, err := os.Open(path) + if err != nil { + slog.Error("open transcript for upload", "path", path, "err", err) + return + } + defer f.Close() + + name := fmt.Sprintf("%s-%04d-transcript.html", ticket.Type, ticket.TicketNumber) + if _, err := s.ChannelFileSendWithMessage(logsChannel, + fmt.Sprintf("Transcript du ticket %s-%04d", ticket.Type, ticket.TicketNumber), + name, f); err != nil { + slog.Error("upload transcript", "path", path, "err", err) + } +} diff --git a/internal/discord/events/channel_delete.go b/internal/discord/events/channel_delete.go new file mode 100644 index 0000000..d11455c --- /dev/null +++ b/internal/discord/events/channel_delete.go @@ -0,0 +1,22 @@ +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) + } +} diff --git a/internal/discord/events/ready.go b/internal/discord/events/ready.go new file mode 100644 index 0000000..8e4ad8f --- /dev/null +++ b/internal/discord/events/ready.go @@ -0,0 +1,45 @@ +package events + +import ( + "context" + "log/slog" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/claim" + "github.com/leolionad58/ticketbot/internal/db" +) + +type ReadyEvent struct { + TicketRepo *db.TicketRepo + ClaimRepo *db.ClaimMessageRepo + ClaimMgr *claim.Manager +} + +func (h *ReadyEvent) Handle(s *discordgo.Session, r *discordgo.Ready) { + slog.Info("bot ready", "user", r.User.Username, "guilds", len(r.Guilds)) + h.resumeReupLoops(s) +} + +func (h *ReadyEvent) resumeReupLoops(s *discordgo.Session) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + openTickets, err := h.TicketRepo.ListOpen(ctx) + if err != nil { + slog.Error("ready: list open tickets", "err", err) + return + } + + resumed := 0 + for _, ticket := range openTickets { + cm, err := h.ClaimRepo.Get(ctx, ticket.ID) + lastReupAt := ticket.OpenedAt + if err == nil && cm != nil { + lastReupAt = cm.LastReupAt + } + h.ClaimMgr.ResumeTicket(s, ticket, lastReupAt) + resumed++ + } + slog.Info("ready: resumed re-up loops", "count", resumed) +} diff --git a/internal/discord/router.go b/internal/discord/router.go new file mode 100644 index 0000000..34ab5a5 --- /dev/null +++ b/internal/discord/router.go @@ -0,0 +1,75 @@ +package discord + +import ( + "log/slog" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/leolionad58/ticketbot/internal/discord/commands" + "github.com/leolionad58/ticketbot/internal/discord/components" +) + +// Router dispatches Discord interactions to the appropriate handler. +type Router struct { + PanelCmd *commands.PanelCommand + TicketCmd *commands.TicketCommand + ConvocCmd *commands.ConvocationCommand + PanelComp *components.PanelComponent + TicketComp *components.TicketComponent +} + +func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + r.routeCommand(s, i) + case discordgo.InteractionMessageComponent: + r.routeComponent(s, i) + } +} + +func (r *Router) routeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) { + name := i.ApplicationCommandData().Name + slog.Debug("command received", "name", name, "user_id", memberUserID(i), "guild_id", i.GuildID) + switch name { + case "panel_send": + r.PanelCmd.Handle(s, i) + case "ticket": + r.TicketCmd.Handle(s, i) + case "convocation": + r.ConvocCmd.Handle(s, i) + default: + slog.Warn("unknown command", "name", name) + } +} + +func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCreate) { + customID := i.MessageComponentData().CustomID + slog.Debug("component received", "custom_id", customID, "user_id", memberUserID(i)) + + switch { + case strings.HasPrefix(customID, "panel:open:"): + r.PanelComp.Handle(s, i) + case strings.HasPrefix(customID, "claim:take:"): + r.TicketComp.HandleClaim(s, i) + case strings.HasPrefix(customID, "ticket:delete:confirm:"): + r.TicketComp.HandleDeleteConfirm(s, i) + case strings.HasPrefix(customID, "ticket:delete:yes:"): + r.TicketComp.HandleDeleteYes(s, i) + case customID == "ticket:delete:cancel": + r.TicketComp.HandleDeleteCancel(s, i) + case strings.HasPrefix(customID, "convoc:delete:"): + r.TicketComp.HandleConvocationDelete(s, i) + default: + slog.Warn("unknown component", "custom_id", customID) + } +} + +func memberUserID(i *discordgo.InteractionCreate) string { + if i.Member != nil && i.Member.User != nil { + return i.Member.User.ID + } + if i.User != nil { + return i.User.ID + } + return "" +} diff --git a/internal/logger/discord.go b/internal/logger/discord.go new file mode 100644 index 0000000..7b99b3a --- /dev/null +++ b/internal/logger/discord.go @@ -0,0 +1,89 @@ +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 NewDiscordLogger(s *discordgo.Session, cfg *config.Provider) *DiscordLogger { + return &DiscordLogger{Session: s, Config: cfg} +} + +func (l *DiscordLogger) send(embed *discordgo.MessageEmbed) { + cfg := l.Config.Get() + if cfg.Bot.LogsChannel == "" { + return + } + if _, err := l.Session.ChannelMessageSendEmbed(cfg.Bot.LogsChannel, embed); 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: fmt.Sprintf("%s-%04d", ticket.Type, ticket.TicketNumber), Inline: true}, + {Name: "Raison", Value: reason}, + }, + } + l.send(embed) +} + +func (l *DiscordLogger) LogConvocationOpened(staffID, targetID, reason, 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}, + }, + }) +} + +func (l *DiscordLogger) LogError(msg string, fields ...any) { + slog.Error(msg, fields...) + content := msg + l.send(&discordgo.MessageEmbed{ + Title: "Erreur critique", + Description: content, + Color: 0xff0000, + }) +} diff --git a/internal/tickets/auth.go b/internal/tickets/auth.go new file mode 100644 index 0000000..7e8ac98 --- /dev/null +++ b/internal/tickets/auth.go @@ -0,0 +1,92 @@ +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 context (e.g. ActionAdmin, 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) + + staffRoleForTicket := func() string { + if ticket == nil { + return "" + } + if p, ok := cfg.Panels[ticket.Panel]; ok { + if t, ok := p.Types[ticket.Type]; ok { + return t.StaffRole + } + } + return "" + } + + switch action { + case ActionAdmin: + return isAdmin + + case ActionClaim: + if isAdmin { + return true + } + role := staffRoleForTicket() + return role != "" && hasRole(role) + + case ActionClose, ActionAdd, ActionRemove, ActionRename, ActionTranscript, ActionReclaim: + if isAdmin { + return true + } + role := staffRoleForTicket() + return role != "" && hasRole(role) + + case ActionConvocation: + if isAdmin { + return true + } + for _, panel := range cfg.Panels { + for _, t := range panel.Types { + if hasRole(t.StaffRole) { + return true + } + } + } + return false + + default: + return false + } +} diff --git a/internal/tickets/auth_test.go b/internal/tickets/auth_test.go new file mode 100644 index 0000000..447ce6f --- /dev/null +++ b/internal/tickets/auth_test.go @@ -0,0 +1,95 @@ +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"}, + "mod": {StaffRole: "mod-role"}, + }, + }, + }, + } + return config.NewProvider(cfg) +} + +func supportTicket() *db.Ticket { + return &db.Ticket{Panel: "support_panel", Type: "support"} +} + +func TestCanAdmin(t *testing.T) { + auth := NewAuthService(makeProvider()) + if !auth.Can([]string{"admin-role"}, ActionAdmin, nil) { + t.Error("admin should pass ActionAdmin") + } + if auth.Can([]string{"random"}, ActionAdmin, nil) { + t.Error("non-admin should fail ActionAdmin") + } +} + +func TestCanClaim(t *testing.T) { + auth := NewAuthService(makeProvider()) + ticket := supportTicket() + if !auth.Can([]string{"staff-role"}, ActionClaim, ticket) { + t.Error("staff should claim") + } + if !auth.Can([]string{"admin-role"}, ActionClaim, ticket) { + t.Error("admin should claim") + } + if auth.Can([]string{"mod-role"}, ActionClaim, ticket) { + t.Error("wrong-staff should not claim support ticket") + } + if auth.Can([]string{"random"}, ActionClaim, ticket) { + t.Error("non-staff should not claim") + } +} + +func TestCanTicketActions(t *testing.T) { + auth := NewAuthService(makeProvider()) + ticket := supportTicket() + for _, action := range []Action{ActionClose, ActionAdd, ActionRemove, ActionRename, ActionTranscript} { + if !auth.Can([]string{"staff-role"}, action, ticket) { + t.Errorf("staff should be able to action %d", action) + } + if !auth.Can([]string{"admin-role"}, action, ticket) { + t.Errorf("admin should be able to action %d", action) + } + if auth.Can([]string{"random"}, action, ticket) { + t.Errorf("random should not be able to action %d", action) + } + } +} + +func TestCanConvocation(t *testing.T) { + auth := NewAuthService(makeProvider()) + // any staff role across all panels + if !auth.Can([]string{"staff-role"}, ActionConvocation, nil) { + t.Error("support staff should convocation") + } + if !auth.Can([]string{"mod-role"}, ActionConvocation, nil) { + t.Error("mod staff should convocation") + } + if !auth.Can([]string{"admin-role"}, ActionConvocation, nil) { + t.Error("admin should convocation") + } + if auth.Can([]string{"random"}, ActionConvocation, nil) { + t.Error("non-staff should not convocation") + } +} + +func TestCanNilTicket(t *testing.T) { + auth := NewAuthService(makeProvider()) + // Should not panic and should return false for staff actions without ticket + if auth.Can([]string{"staff-role"}, ActionClaim, nil) { + t.Error("should not claim nil ticket") + } +} diff --git a/internal/tickets/service.go b/internal/tickets/service.go new file mode 100644 index 0000000..dd1d53a --- /dev/null +++ b/internal/tickets/service.go @@ -0,0 +1,152 @@ +package tickets + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + "time" + "unicode" + + "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 prevents double-click +} + +func NewService(repo *db.TicketRepo, auth *AuthService, s *discordgo.Session, cfg *config.Provider) *Service { + return &Service{db: repo, auth: auth, session: s, cfg: cfg} +} + +var ErrAlreadyOpen = fmt.Errorf("already_open") + +// Open creates a ticket channel and inserts into DB. Returns the created Ticket. +// Returns ErrAlreadyOpen (wrapped with channel ID) if user already has an open ticket of the same type. +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) + + existing, err := svc.db.HasOpenTicket(ctx, userID, ticketType) + if err != nil { + return nil, fmt.Errorf("check existing: %w", err) + } + if existing != nil { + return existing, fmt.Errorf("%w:%s", ErrAlreadyOpen, 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) + } + + // Reserve a placeholder to get the ticket number atomically + ticket := &db.Ticket{ + UserID: userID, + Panel: panelName, + Type: ticketType, + ChannelID: fmt.Sprintf("pending-%d", time.Now().UnixNano()), + OpenedAt: time.Now(), + } + 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 { + slog.Error("create channel", "ticket_id", ticket.ID, "err", err) + 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 +} + +// AddMember adds a Discord user to a ticket channel with full read/send permissions. +func (svc *Service) AddMember(ctx context.Context, channelID, userID string) error { + return svc.session.ChannelPermissionSet(channelID, userID, + discordgo.PermissionOverwriteTypeMember, + discordgo.PermissionViewChannel|discordgo.PermissionSendMessages| + discordgo.PermissionReadMessageHistory|discordgo.PermissionAttachFiles, + 0) +} + +// RemoveMember removes a Discord user from a ticket channel. +func (svc *Service) RemoveMember(ctx context.Context, channelID, userID string) error { + return svc.session.ChannelPermissionDelete(channelID, userID) +} + +// Rename sanitizes and renames a ticket channel. Returns error if name is invalid. +func (svc *Service) Rename(ctx context.Context, channelID, name string) error { + safe := sanitizeChannelName(name) + if safe == "" { + return fmt.Errorf("invalid channel name after sanitization") + } + if len(safe) > 100 { + safe = safe[:100] + } + _, err := svc.session.ChannelEdit(channelID, &discordgo.ChannelEdit{Name: safe}) + return err +} + +// ClaimChannel adds a staff member to a ticket channel. +func (svc *Service) ClaimChannel(ctx context.Context, channelID, staffID string) error { + return svc.AddMember(ctx, channelID, staffID) +} + +// DeleteChannel permanently deletes a Discord channel. +func (svc *Service) DeleteChannel(channelID string) error { + _, err := svc.session.ChannelDelete(channelID) + return err +} + +func sanitizeChannelName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + case unicode.IsSpace(r): + b.WriteRune('-') + } + } + return strings.Trim(b.String(), "-") +} diff --git a/internal/transcript/generator.go b/internal/transcript/generator.go new file mode 100644 index 0000000..5abbf53 --- /dev/null +++ b/internal/transcript/generator.go @@ -0,0 +1,147 @@ +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 maxUploadSize = 25 * 1024 * 1024 // 25MB + +type Message struct { + AuthorName string + AuthorAvatar string + AuthorInitial string + Content template.HTML + Timestamp string + Attachments []Attachment +} + +type Attachment struct { + Name string + URL string +} + +type Generator struct { + OutputDir string + Session *discordgo.Session +} + +func NewGenerator(outputDir string, s *discordgo.Session) *Generator { + return &Generator{OutputDir: outputDir, Session: s} +} + +// Generate fetches all messages from the ticket channel and writes the HTML transcript. +// Returns the output file path. +func (g *Generator) Generate(ticket *db.Ticket) (string, error) { + messages, err := g.fetchAll(ticket.ChannelID) + if err != nil { + return "", fmt.Errorf("fetch messages: %w", err) + } + + rendered := make([]Message, 0, len(messages)) + for _, m := range messages { + rendered = append(rendered, renderMessage(m)) + } + + tmpl, err := template.New("transcript").Parse(HTMLTemplate) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + + data := struct { + Ticket *db.Ticket + Messages []Message + Generated string + }{ + Ticket: ticket, + Messages: rendered, + Generated: time.Now().UTC().Format("02/01/2006 à 15:04 UTC"), + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "", fmt.Errorf("render template: %w", err) + } + + if err := os.MkdirAll(g.OutputDir, 0755); err != nil { + return "", fmt.Errorf("create output dir: %w", err) + } + + outPath := filepath.Join(g.OutputDir, + fmt.Sprintf("%s-%04d-%s.html", ticket.Type, ticket.TicketNumber, ticket.ChannelID)) + + if buf.Len() > maxUploadSize { + slog.Warn("transcript exceeds 25MB, saving locally only", "path", outPath, "size_mb", buf.Len()/1024/1024) + } + + if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { + return "", fmt.Errorf("write transcript: %w", err) + } + return outPath, nil +} + +func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) { + var all []*discordgo.Message + var before string + for { + batch, err := g.Session.ChannelMessages(channelID, 100, before, "", "") + if err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < 100 { + break + } + before = batch[len(batch)-1].ID + } + // Reverse to chronological order + for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { + all[i], all[j] = all[j], all[i] + } + return all, nil +} + +func renderMessage(m *discordgo.Message) Message { + name := m.Author.Username + avatar := m.Author.AvatarURL("64") + initial := "?" + if len(name) > 0 { + initial = strings.ToUpper(string([]rune(name)[0])) + } + + // Escape all user content — XSS protection + content := template.HTMLEscapeString(m.Content) + // Preserve newlines as
+ contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "
")) + + ts := "" + if !m.Timestamp.IsZero() { + ts = m.Timestamp.UTC().Format("02/01/2006 15:04:05") + } + + var attachments []Attachment + for _, a := range m.Attachments { + attachments = append(attachments, Attachment{ + Name: template.HTMLEscapeString(a.Filename), + URL: a.URL, + }) + } + + return Message{ + AuthorName: template.HTMLEscapeString(name), + AuthorAvatar: avatar, + AuthorInitial: initial, + Content: contentHTML, + Timestamp: ts, + Attachments: attachments, + } +} diff --git a/internal/transcript/generator_test.go b/internal/transcript/generator_test.go new file mode 100644 index 0000000..9e3adc4 --- /dev/null +++ b/internal/transcript/generator_test.go @@ -0,0 +1,120 @@ +package transcript + +import ( + "bytes" + "html/template" + "os" + "path/filepath" + "testing" + "time" + + "github.com/leolionad58/ticketbot/internal/db" +) + +func makeTicket() *db.Ticket { + return &db.Ticket{ + ID: 1, + TicketNumber: 1, + Type: "support", + Panel: "support_panel", + ChannelID: "ch123456", + UserID: "user123", + OpenedAt: time.Now(), + Status: "open", + } +} + +func TestTemplateRender(t *testing.T) { + ticket := makeTicket() + tmpl, err := template.New("t").Parse(HTMLTemplate) + if err != nil { + t.Fatalf("parse template: %v", err) + } + + messages := []Message{ + { + AuthorName: "TestUser", + AuthorInitial: "T", + Content: template.HTML("Hello world"), + Timestamp: "01/01/2024 12:00:00", + }, + } + + data := struct { + Ticket *db.Ticket + Messages []Message + Generated string + }{Ticket: ticket, Messages: messages, 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-ch123456.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 name") + } + if !bytes.Contains(content, []byte("Hello world")) { + t.Error("missing message content") + } +} + +func TestXSSEscaping(t *testing.T) { + // renderMessage must escape all user-controlled content + // We test the escaping logic by simulating a message with XSS payload + msg := &struct { + Content string + Username string + }{ + Content: "", + Username: "user", + } + + escapedContent := template.HTMLEscapeString(msg.Content) + escapedName := template.HTMLEscapeString(msg.Username) + + if escapedContent == msg.Content { + t.Error("content should be escaped") + } + if escapedName == msg.Username { + t.Error("username should be escaped") + } + if bytes.Contains([]byte(escapedContent), []byte("")), + Timestamp: "01/01/2024 12:00:00", + }, + } + + data := struct { + Ticket *db.Ticket + Messages []Message + Generated string + }{Ticket: ticket, Messages: messages, Generated: "test"} + + var buf bytes.Buffer + tmpl.Execute(&buf, data) + + if bytes.Contains(buf.Bytes(), []byte("