save first version
This commit is contained in:
+14
@@ -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"]
|
||||||
+156
@@ -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
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(), "-")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "<evil>",
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}} | Ouvert le : {{.Ticket.OpenedAt.Format "02/01/2006 à 15:04 UTC"}}</p>
|
||||||
|
<p>Généré le {{.Generated}} | {{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>`
|
||||||
Reference in New Issue
Block a user