save first version

This commit is contained in:
lfirmin
2026-05-02 02:55:53 +02:00
parent 65d91d8de2
commit 8482f0cc06
35 changed files with 5579 additions and 0 deletions
+14
View File
@@ -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"]
BIN
View File
Binary file not shown.
+156
View File
@@ -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)
}
}
+22
View File
@@ -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"
+11
View File
@@ -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
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
)
+40
View File
@@ -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=
+190
View File
@@ -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
}
+93
View File
@@ -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)
}
+91
View File
@@ -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")
}
}
+57
View File
@@ -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
}
+58
View File
@@ -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()
}
+78
View File
@@ -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
}
+134
View File
@@ -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()
}
+186
View File
@@ -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()
}
+57
View File
@@ -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
}
+68
View File
@@ -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},
},
},
}
}
+187
View File
@@ -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 <user> <reason>.
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)
}
+63
View File
@@ -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
}
}
+74
View File
@@ -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},
}
}
+255
View File
@@ -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)
}
}
+53
View File
@@ -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
}
+88
View File
@@ -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:<panel>:<type>).
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))
}
+291
View File
@@ -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:<channel_id>
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:<channel_id>
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:<ticket_id>
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:<channel_id>:<creator_staff_id>
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)
}
}
+22
View File
@@ -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)
}
}
+45
View File
@@ -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)
}
+75
View File
@@ -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 ""
}
+89
View File
@@ -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,
})
}
+92
View File
@@ -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
}
}
+95
View File
@@ -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")
}
}
+152
View File
@@ -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(), "-")
}
+147
View File
@@ -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 <br>
contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "<br>"))
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,
}
}
+120
View File
@@ -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: "<script>alert('xss')</script>",
Username: "<b>user</b>",
}
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("<script>")) {
t.Error("XSS payload not escaped in content")
}
}
func TestTemplateNoXSS(t *testing.T) {
ticket := makeTicket()
tmpl, _ := template.New("t").Parse(HTMLTemplate)
messages := []Message{
{
AuthorName: "&lt;evil&gt;",
AuthorInitial: "E",
Content: template.HTML(template.HTMLEscapeString("<script>alert(1)</script>")),
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("<script>alert")) {
t.Error("XSS: raw script tag in output")
}
}
+57
View File
@@ -0,0 +1,57 @@
package transcript
const HTMLTemplate = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transcript {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}}</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;font-size:14px;line-height:1.5;}
.header{background:#2f3136;padding:16px 24px;border-bottom:1px solid #202225;}
.header h1{color:#fff;font-size:1.1em;margin-bottom:4px;}
.header p{color:#b9bbbe;font-size:.85em;margin-top:2px;}
.messages{padding:8px 0;}
.message{display:flex;padding:6px 16px;gap:12px;}
.message:hover{background:#32353b;}
.avatar{width:40px;height:40px;border-radius:50%;flex-shrink:0;object-fit:cover;}
.avatar-placeholder{width:40px;height:40px;border-radius:50%;flex-shrink:0;background:#5865f2;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:1.1em;}
.msg-body{flex:1;min-width:0;}
.msg-header{display:flex;align-items:baseline;gap:8px;margin-bottom:2px;}
.author{font-weight:700;color:#fff;font-size:.9em;}
.timestamp{color:#72767d;font-size:.75em;}
.text{color:#dcddde;word-break:break-word;white-space:pre-wrap;}
.attachment{margin-top:4px;}
.attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;}
.attachment a:hover{text-decoration:underline;}
.footer{text-align:center;color:#72767d;font-size:.8em;padding:12px;border-top:1px solid #202225;margin-top:8px;}
</style>
</head>
<body>
<div class="header">
<h1>Transcript — {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}}</h1>
<p>Utilisateur : {{.Ticket.UserID}} &nbsp;|&nbsp; Ouvert le : {{.Ticket.OpenedAt.Format "02/01/2006 à 15:04 UTC"}}</p>
<p>Généré le {{.Generated}} &nbsp;|&nbsp; {{len .Messages}} messages</p>
</div>
<div class="messages">
{{range .Messages}}
<div class="message">
{{if .AuthorAvatar}}<img class="avatar" src="{{.AuthorAvatar}}" alt="" onerror="this.style.display='none'">
{{else}}<div class="avatar-placeholder">{{.AuthorInitial}}</div>{{end}}
<div class="msg-body">
<div class="msg-header">
<span class="author">{{.AuthorName}}</span>
<span class="timestamp">{{.Timestamp}}</span>
</div>
{{if .Content}}<div class="text">{{.Content}}</div>{{end}}
{{range .Attachments}}
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
{{end}}
</div>
</div>
{{end}}
</div>
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
</body>
</html>`