save first version

This commit is contained in:
lfirmin
2026-05-02 02:55:53 +02:00
parent 65d91d8de2
commit 8482f0cc06
35 changed files with 5579 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
package discord
import (
"fmt"
"log/slog"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
)
type Bot struct {
Session *discordgo.Session
Config *config.Provider
Tickets *db.TicketRepo
Claims *db.ClaimMessageRepo
GuildID string
}
func New(token string, cfg *config.Provider, tickets *db.TicketRepo, claims *db.ClaimMessageRepo, guildID string) (*Bot, error) {
s, err := discordgo.New("Bot " + token)
if err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
s.Identify.Intents = discordgo.IntentsGuilds |
discordgo.IntentsGuildMembers |
discordgo.IntentsGuildMessages |
discordgo.IntentsMessageContent
return &Bot{
Session: s,
Config: cfg,
Tickets: tickets,
Claims: claims,
GuildID: guildID,
}, nil
}
func (b *Bot) Open() error {
return b.Session.Open()
}
func (b *Bot) Close() {
b.Session.Close()
}
func (b *Bot) RegisterCommands(appID string) error {
cmds := ApplicationCommands()
for _, cmd := range cmds {
_, err := b.Session.ApplicationCommandCreate(appID, b.GuildID, cmd)
if err != nil {
return fmt.Errorf("register %s: %w", cmd.Name, err)
}
slog.Info("registered command", "name", cmd.Name)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package discord
import "github.com/bwmarrin/discordgo"
func ApplicationCommands() []*discordgo.ApplicationCommand {
return []*discordgo.ApplicationCommand{
{
Name: "panel_send",
Description: "Envoie un panel de tickets dans un channel",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "panel", Description: "Nom du panel", Required: true},
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Channel cible", Required: true},
},
},
{
Name: "ticket",
Description: "Commandes de gestion de ticket",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "close",
Description: "Ferme le ticket",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Raison"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add",
Description: "Ajoute un user au ticket",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à ajouter", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove",
Description: "Retire un user du ticket",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à retirer", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "rename",
Description: "Renomme le channel du ticket",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "name", Description: "Nouveau nom", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "transcript",
Description: "Génère le transcript HTML",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "reclaim",
Description: "Reprend un ticket claimé par un staff parti",
},
},
},
{
Name: "convocation",
Description: "Crée une convocation",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "User à convoquer", Required: true},
{Type: discordgo.ApplicationCommandOptionString, Name: "reason", Description: "Raison", Required: true},
},
},
}
}
+187
View File
@@ -0,0 +1,187 @@
package commands
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
)
type ConvocationCommand struct {
Config *config.Provider
TicketRepo *db.TicketRepo
Auth *tickets.AuthService
LogSvc *logger.DiscordLogger
}
// Handle handles /convocation <user> <reason>.
func (c *ConvocationCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
memberRoles := memberRoleIDs(i)
staffID := interactionUserID(i)
if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("convocation denied", "user_id", staffID)
return
}
data := i.ApplicationCommandData()
target := data.Options[0].UserValue(s)
reason := data.Options[1].StringValue()
if target == nil {
ephemeral(s, i, "Utilisateur introuvable.")
return
}
if target.Bot {
ephemeral(s, i, "Tu ne peux pas convoquer un bot.")
return
}
if target.ID == staffID {
ephemeral(s, i, "Tu ne peux pas te convoquer toi-même.")
return
}
// Defer — channel creation can take time
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cfg := c.Config.Get()
if cfg.Bot.ConvocationCategory == "" {
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ //nolint
Content: "La catégorie de convocation n'est pas configurée.",
Flags: discordgo.MessageFlagsEphemeral,
})
return
}
// Get next convocation number
n, err := c.TicketRepo.NextConvocationNumber(ctx)
if err != nil {
slog.Error("convocation: next number", "err", err)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur interne.", Flags: discordgo.MessageFlagsEphemeral}) //nolint
return
}
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocName(target.Username), n)
if len(channelName) > 100 {
channelName = channelName[:100]
}
ch, err := s.GuildChannelCreateComplex(i.GuildID, discordgo.GuildChannelCreateData{
Name: channelName,
Type: discordgo.ChannelTypeGuildText,
ParentID: cfg.Bot.ConvocationCategory,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: i.GuildID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: discordgo.PermissionViewChannel,
},
{
ID: staffID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory |
discordgo.PermissionAttachFiles,
},
{
ID: target.ID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory,
},
},
})
if err != nil {
slog.Error("convocation: create channel", "err", err)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la création du channel.", Flags: discordgo.MessageFlagsEphemeral}) //nolint
return
}
// Insert in DB as type=convocation
ticket := &db.Ticket{
UserID: target.ID,
Panel: "convocation",
Type: "convocation",
ChannelID: ch.ID,
OpenedAt: time.Now(),
Status: "open",
}
ticket.TicketNumber = n
// Manual insert without number increment (we already computed it)
if err := insertConvocation(ctx, c.TicketRepo, ticket); err != nil {
slog.Error("convocation: insert DB", "err", err)
}
// Custom delete button ID encodes staff ID for auth
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID)
if len(deleteCustomID) > 100 {
deleteCustomID = "convoc:delete:" + ch.ID
}
// Post embed in convocation channel
s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
Content: fmt.Sprintf("<@%s>", target.ID),
Embeds: []*discordgo.MessageEmbed{
{
Title: "Convocation",
Description: fmt.Sprintf("Tu as été convoqué par <@%s>.\n\n**Raison :** %s", staffID, reason),
Color: 0x5865f2,
},
},
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Supprimer",
Style: discordgo.DangerButton,
CustomID: deleteCustomID,
},
}},
},
})
c.LogSvc.LogConvocationOpened(staffID, target.ID, reason, ch.ID)
slog.Info("convocation created", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.ID)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ //nolint
Content: fmt.Sprintf("Convocation créée : <#%s>", ch.ID),
Flags: discordgo.MessageFlagsEphemeral,
})
}
func sanitizeConvocName(username string) string {
var out []byte
for _, b := range []byte(username) {
if (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '-' {
out = append(out, b)
} else if b >= 'A' && b <= 'Z' {
out = append(out, b+32)
}
}
if len(out) == 0 {
return "user"
}
if len(out) > 20 {
out = out[:20]
}
return string(out)
}
// insertConvocation inserts a convocation ticket that already has its number set.
func insertConvocation(ctx context.Context, repo *db.TicketRepo, ticket *db.Ticket) error {
return repo.InsertWithNumber(ctx, ticket)
}
+63
View File
@@ -0,0 +1,63 @@
package commands
import (
"os"
"strconv"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/config"
)
func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func memberRoleIDs(i *discordgo.InteractionCreate) []string {
if i.Member != nil {
return i.Member.Roles
}
return nil
}
func interactionUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
func parseColor(hex string) int {
if len(hex) > 0 && hex[0] == '#' {
hex = hex[1:]
}
v, _ := strconv.ParseInt(hex, 16, 32)
return int(v)
}
func openFile(path string) (*os.File, error) {
return os.Open(path)
}
func buttonStyle(color config.ButtonColor) discordgo.ButtonStyle {
switch color {
case config.ColorPrimary:
return discordgo.PrimaryButton
case config.ColorSecondary:
return discordgo.SecondaryButton
case config.ColorSuccess:
return discordgo.SuccessButton
case config.ColorDanger:
return discordgo.DangerButton
default:
return discordgo.PrimaryButton
}
}
+74
View File
@@ -0,0 +1,74 @@
package commands
import (
"log/slog"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/tickets"
)
type PanelCommand struct {
Config *config.Provider
Auth *tickets.AuthService
}
func (c *PanelCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
memberRoles := memberRoleIDs(i)
if !c.Auth.Can(memberRoles, tickets.ActionAdmin, nil) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("panel_send denied", "user_id", interactionUserID(i))
return
}
data := i.ApplicationCommandData()
panelName := data.Options[0].StringValue()
channelID := data.Options[1].ChannelValue(s).ID
cfg := c.Config.Get()
panel, ok := cfg.Panels[panelName]
if !ok {
ephemeral(s, i, "Panel introuvable : "+panelName)
return
}
components := buildPanelButtons(panelName, panel)
embed := &discordgo.MessageEmbed{
Title: panel.EmbedTitle,
Description: panel.EmbedDescription,
Color: parseColor(panel.EmbedColor),
}
if _, err := s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{embed},
Components: components,
}); err != nil {
slog.Error("panel_send: send message", "err", err)
ephemeral(s, i, "Erreur lors de l'envoi du panel.")
return
}
ephemeral(s, i, "Panel envoyé dans <#"+channelID+">.")
}
func buildPanelButtons(panelName string, panel config.Panel) []discordgo.MessageComponent {
var buttons []discordgo.MessageComponent
for typeName, t := range panel.Types {
customID := "panel:open:" + panelName + ":" + typeName
if len(customID) > 100 {
customID = customID[:100]
}
btn := discordgo.Button{
Label: t.ButtonLabel,
Style: buttonStyle(t.ButtonColor),
CustomID: customID,
}
if t.ButtonEmoji != "" {
emoji := discordgo.ComponentEmoji{Name: t.ButtonEmoji}
btn.Emoji = &emoji
}
buttons = append(buttons, btn)
}
return []discordgo.MessageComponent{
discordgo.ActionsRow{Components: buttons},
}
}
+255
View File
@@ -0,0 +1,255 @@
package commands
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/claim"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
"github.com/leolionad58/ticketbot/internal/transcript"
)
type TicketCommand struct {
TicketSvc *tickets.Service
TicketRepo *db.TicketRepo
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Transcript *transcript.Generator
Auth *tickets.AuthService
}
// Handle dispatches /ticket subcommands.
func (c *TicketCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
ephemeral(s, i, "Sous-commande manquante.")
return
}
sub := data.Options[0]
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// All /ticket commands must be used inside a ticket channel
ticket, err := c.TicketRepo.GetByChannelID(ctx, i.ChannelID)
if err != nil || ticket == nil {
ephemeral(s, i, "Cette commande doit être utilisée dans un channel de ticket.")
return
}
if ticket.Status == "closed" {
ephemeral(s, i, "Ce ticket est déjà fermé.")
return
}
memberRoles := memberRoleIDs(i)
staffID := interactionUserID(i)
switch sub.Name {
case "close":
c.handleClose(ctx, s, i, ticket, memberRoles, staffID, sub)
case "add":
c.handleAdd(ctx, s, i, ticket, memberRoles, sub)
case "remove":
c.handleRemove(ctx, s, i, ticket, memberRoles, staffID, sub)
case "rename":
c.handleRename(ctx, s, i, ticket, memberRoles, sub)
case "transcript":
c.handleTranscript(ctx, s, i, ticket, memberRoles)
case "reclaim":
c.handleReclaim(ctx, s, i, ticket, memberRoles, staffID)
default:
ephemeral(s, i, "Sous-commande inconnue.")
}
}
func (c *TicketCommand) handleClose(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string, sub *discordgo.ApplicationCommandInteractionDataOption) {
if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("ticket close denied", "user_id", staffID, "channel_id", ticket.ChannelID)
return
}
reason := "Fermé via /ticket close"
if len(sub.Options) > 0 {
reason = sub.Options[0].StringValue()
}
// Defer — transcript + channel delete takes time
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
// Generate transcript
transcriptPath := ""
if tp, err := c.Transcript.Generate(ticket); err != nil {
slog.Error("close: generate transcript", "ticket_id", ticket.ID, "err", err)
} else {
transcriptPath = tp
}
if err := c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, reason, transcriptPath, time.Now()); err != nil {
slog.Error("close: set closed", "ticket_id", ticket.ID, "err", err)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la fermeture.", Flags: discordgo.MessageFlagsEphemeral}) //nolint
return
}
c.ClaimMgr.Stop(ticket.ID)
c.ClaimMgr.DeleteClaimMessage(ctx, s, ticket.ID)
ticket.TranscriptPath.String = transcriptPath
ticket.TranscriptPath.Valid = transcriptPath != ""
c.LogSvc.LogTicketClosed(ticket, staffID, reason, transcriptPath)
if transcriptPath != "" {
sendTranscriptFile(s, c.LogSvc.Config.Get().Bot.LogsChannel, transcriptPath, ticket)
}
slog.Info("ticket closed", "ticket_id", ticket.ID, "staff_id", staffID)
c.TicketSvc.DeleteChannel(ticket.ChannelID) //nolint
}
func (c *TicketCommand) handleAdd(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, sub *discordgo.ApplicationCommandInteractionDataOption) {
if !c.Auth.Can(memberRoles, tickets.ActionAdd, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
target := sub.Options[0].UserValue(s)
if target == nil {
ephemeral(s, i, "Utilisateur introuvable.")
return
}
// Check if already present by trying to get channel permission overwrites
ch, err := s.Channel(ticket.ChannelID)
if err == nil {
for _, ow := range ch.PermissionOverwrites {
if ow.ID == target.ID && ow.Type == discordgo.PermissionOverwriteTypeMember {
ephemeral(s, i, fmt.Sprintf("<@%s> est déjà présent dans ce ticket.", target.ID))
return
}
}
}
if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, target.ID); err != nil {
slog.Error("ticket add: add member", "err", err)
ephemeral(s, i, "Erreur lors de l'ajout.")
return
}
ephemeral(s, i, fmt.Sprintf("<@%s> a été ajouté au ticket.", target.ID))
}
func (c *TicketCommand) handleRemove(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string, sub *discordgo.ApplicationCommandInteractionDataOption) {
if !c.Auth.Can(memberRoles, tickets.ActionRemove, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
target := sub.Options[0].UserValue(s)
if target == nil {
ephemeral(s, i, "Utilisateur introuvable.")
return
}
if target.ID == ticket.UserID {
ephemeral(s, i, "Tu ne peux pas retirer l'utilisateur qui a ouvert le ticket.")
return
}
if target.ID == staffID {
ephemeral(s, i, "Tu ne peux pas te retirer toi-même du ticket.")
return
}
if err := c.TicketSvc.RemoveMember(ctx, ticket.ChannelID, target.ID); err != nil {
slog.Error("ticket remove: remove member", "err", err)
ephemeral(s, i, "Erreur lors de la suppression.")
return
}
ephemeral(s, i, fmt.Sprintf("<@%s> a été retiré du ticket.", target.ID))
}
func (c *TicketCommand) handleRename(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, sub *discordgo.ApplicationCommandInteractionDataOption) {
if !c.Auth.Can(memberRoles, tickets.ActionRename, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
name := sub.Options[0].StringValue()
if err := c.TicketSvc.Rename(ctx, ticket.ChannelID, name); err != nil {
slog.Error("ticket rename", "err", err)
ephemeral(s, i, "Nom invalide ou erreur lors du renommage : "+err.Error())
return
}
ephemeral(s, i, "Channel renommé.")
}
func (c *TicketCommand) handleTranscript(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string) {
if !c.Auth.Can(memberRoles, tickets.ActionTranscript, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
// Defer — message fetching takes time
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
tp, err := c.Transcript.Generate(ticket)
if err != nil {
slog.Error("transcript: generate", "ticket_id", ticket.ID, "err", err)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Erreur lors de la génération du transcript.", Flags: discordgo.MessageFlagsEphemeral}) //nolint
return
}
sendTranscriptFile(s, ticket.ChannelID, tp, ticket)
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{Content: "Transcript généré.", Flags: discordgo.MessageFlagsEphemeral}) //nolint
}
func (c *TicketCommand) handleReclaim(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, memberRoles []string, staffID string) {
if !c.Auth.Can(memberRoles, tickets.ActionReclaim, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
if ticket.Status != "claimed" {
ephemeral(s, i, "Ce ticket n'est pas claim.")
return
}
if err := c.TicketRepo.SetClaimed(ctx, ticket.ID, staffID, time.Now()); err != nil {
slog.Error("reclaim: set claimed", "err", err)
ephemeral(s, i, "Erreur lors du reclaim.")
return
}
if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, staffID); err != nil {
slog.Error("reclaim: add member", "err", err)
}
s.ChannelMessageSend(ticket.ChannelID, fmt.Sprintf("Ticket reclaim par <@%s>.", staffID)) //nolint
c.LogSvc.LogTicketClaimed(ticket, staffID)
ephemeral(s, i, "Tu as reclaim ce ticket.")
}
func sendTranscriptFile(s *discordgo.Session, channelID, path string, ticket *db.Ticket) {
if channelID == "" || path == "" {
return
}
f, err := openFile(path)
if err != nil {
slog.Error("send transcript: open file", "path", path, "err", err)
return
}
defer f.Close()
name := fmt.Sprintf("%s-%04d-transcript.html", ticket.Type, ticket.TicketNumber)
if _, err := s.ChannelFileSendWithMessage(channelID,
fmt.Sprintf("Transcript — %s-%04d", ticket.Type, ticket.TicketNumber),
name, f); err != nil {
slog.Error("send transcript: upload", "err", err)
}
}
+53
View File
@@ -0,0 +1,53 @@
package components
import (
"strconv"
"github.com/bwmarrin/discordgo"
)
func ephemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func followup(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ //nolint
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
})
}
func interactionUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}
func interactionGuildID(i *discordgo.InteractionCreate) string {
return i.GuildID
}
func parseColor(hex string) int {
if len(hex) > 0 && hex[0] == '#' {
hex = hex[1:]
}
v, _ := strconv.ParseInt(hex, 16, 32)
return int(v)
}
func memberRoleIDs(i *discordgo.InteractionCreate) []string {
if i.Member != nil {
return i.Member.Roles
}
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package components
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/claim"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
)
type PanelComponent struct {
TicketSvc *tickets.Service
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Config *config.Provider
}
// Handle handles clicks on panel open buttons (custom_id: panel:open:<panel>:<type>).
func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
if len(parts) != 4 {
return
}
panelName, ticketType := parts[2], parts[3]
// Defer — channel creation can take >3s
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
uID := interactionUserID(i)
ticket, err := c.TicketSvc.Open(ctx, interactionGuildID(i), uID, panelName, ticketType)
if err != nil {
if errors.Is(err, tickets.ErrAlreadyOpen) {
chID := strings.TrimPrefix(err.Error(), fmt.Sprintf("%s:", tickets.ErrAlreadyOpen))
followup(s, i, fmt.Sprintf("Tu as déjà un ticket ouvert : <#%s>", chID))
return
}
slog.Error("open ticket", "user_id", uID, "panel", panelName, "type", ticketType, "err", err)
followup(s, i, "Erreur lors de la création du ticket.")
return
}
cfg := c.Config.Get()
var embedTitle, embedText, embedColor string
if panel, ok := cfg.Panels[panelName]; ok {
if typeCfg, ok := panel.Types[ticketType]; ok {
embedTitle = typeCfg.EmbedTitle
embedText = typeCfg.EmbedText
embedColor = typeCfg.EmbedColor
}
}
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{
{Title: embedTitle, Description: embedText, Color: parseColor(embedColor)},
},
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Supprimer le ticket",
Style: discordgo.DangerButton,
CustomID: deleteCustomID,
},
}},
},
}); err != nil {
slog.Error("open ticket: post welcome embed", "channel_id", ticket.ChannelID, "err", err)
}
c.ClaimMgr.StartTicket(ctx, s, ticket)
c.LogSvc.LogTicketOpened(ticket, uID)
slog.Info("ticket opened", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID))
}
+291
View File
@@ -0,0 +1,291 @@
package components
import (
"context"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/claim"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
"github.com/leolionad58/ticketbot/internal/transcript"
)
type TicketComponent struct {
TicketSvc *tickets.Service
TicketRepo *db.TicketRepo
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Transcript *transcript.Generator
Auth *tickets.AuthService
}
// HandleDeleteConfirm handles the "Supprimer le ticket" button — shows Yes/Cancel confirmation.
// custom_id: ticket:delete:confirm:<channel_id>
func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
if len(parts) != 4 {
return
}
channelID := parts[3]
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID)
if err != nil || ticket == nil {
ephemeral(s, i, "Ticket introuvable.")
return
}
if ticket.Status == "closed" {
ephemeral(s, i, "Ce ticket est déjà fermé.")
return
}
memberRoles := memberRoleIDs(i)
if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("delete denied", "user_id", interactionUserID(i), "channel_id", channelID)
return
}
yesID := "ticket:delete:yes:" + channelID
cancelID := "ticket:delete:cancel"
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "Es-tu sûr de vouloir supprimer ce ticket ? Cette action est irréversible.",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID},
discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID},
}},
},
},
})
}
// HandleDeleteYes handles the "Oui, supprimer" confirmation button.
// custom_id: ticket:delete:yes:<channel_id>
func (c *TicketComponent) HandleDeleteYes(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
if len(parts) != 4 {
return
}
channelID := parts[3]
// Defer — transcript generation + channel delete can take time
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredMessageUpdate,
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID)
if err != nil || ticket == nil {
followup(s, i, "Ticket introuvable.")
return
}
if ticket.Status == "closed" {
followup(s, i, "Ce ticket est déjà fermé.")
return
}
memberRoles := memberRoleIDs(i)
staffID := interactionUserID(i)
if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) {
followup(s, i, "Tu n'as pas la permission d'effectuer cette action.")
return
}
c.closeTicket(ctx, s, i, ticket, staffID, "Fermé via bouton")
}
// HandleDeleteCancel dismisses the confirmation (no-op update).
// custom_id: ticket:delete:cancel
func (c *TicketComponent) HandleDeleteCancel(s *discordgo.Session, i *discordgo.InteractionCreate) {
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: "Suppression annulée.",
Components: []discordgo.MessageComponent{},
},
})
}
// HandleClaim handles the Claim button in the claim channel.
// custom_id: claim:take:<ticket_id>
func (c *TicketComponent) HandleClaim(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3)
if len(parts) != 3 {
return
}
var ticketID int64
fmt.Sscanf(parts[2], "%d", &ticketID)
// Defer — DB + permission ops
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ticket, err := c.TicketRepo.GetByID(ctx, ticketID)
if err != nil || ticket == nil {
followup(s, i, "Ticket introuvable.")
return
}
staffID := interactionUserID(i)
memberRoles := memberRoleIDs(i)
if ticket.Status == "claimed" {
// Already claimed — clean up claim message if still there
c.ClaimMgr.DeleteClaimMessage(ctx, s, ticketID)
followup(s, i, fmt.Sprintf("Ce ticket a déjà été claim par <@%s>.", ticket.ClaimedBy.String))
return
}
if ticket.Status == "closed" {
followup(s, i, "Ce ticket est déjà fermé.")
return
}
if !c.Auth.Can(memberRoles, tickets.ActionClaim, ticket) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("claim denied", "user_id", staffID, "ticket_id", ticketID)
return
}
if err := c.TicketRepo.SetClaimed(ctx, ticketID, staffID, time.Now()); err != nil {
slog.Error("claim: set claimed", "ticket_id", ticketID, "err", err)
followup(s, i, "Erreur lors du claim.")
return
}
ticket.Status = "claimed"
// Stop re-up loop and delete claim message
c.ClaimMgr.Stop(ticketID)
c.ClaimMgr.DeleteClaimMessage(ctx, s, ticketID)
// Add staff to ticket channel
if err := c.TicketSvc.AddMember(ctx, ticket.ChannelID, staffID); err != nil {
slog.Error("claim: add staff to channel", "channel_id", ticket.ChannelID, "err", err)
}
// Post in ticket channel
if _, err := s.ChannelMessageSend(ticket.ChannelID,
fmt.Sprintf("Ticket claim par <@%s>.", staffID)); err != nil {
slog.Error("claim: post in ticket", "channel_id", ticket.ChannelID, "err", err)
}
c.LogSvc.LogTicketClaimed(ticket, staffID)
slog.Info("ticket claimed", "ticket_id", ticketID, "staff_id", staffID)
followup(s, i, fmt.Sprintf("Tu as claim le ticket <#%s>.", ticket.ChannelID))
}
// CloseTicket is exported for use by slash commands.
func (c *TicketComponent) CloseTicket(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, staffID, reason string) {
c.closeTicket(ctx, s, i, ticket, staffID, reason)
}
func (c *TicketComponent) closeTicket(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, ticket *db.Ticket, staffID, reason string) {
// Generate transcript
transcriptPath := ""
tp, err := c.Transcript.Generate(ticket)
if err != nil {
slog.Error("close: generate transcript", "ticket_id", ticket.ID, "err", err)
} else {
transcriptPath = tp
}
// Update DB
if err := c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, reason, transcriptPath, time.Now()); err != nil {
slog.Error("close: set closed", "ticket_id", ticket.ID, "err", err)
}
// Stop claim loop if still running
c.ClaimMgr.Stop(ticket.ID)
c.ClaimMgr.DeleteClaimMessage(ctx, s, ticket.ID)
// Log with transcript attachment
ticket.TranscriptPath.String = transcriptPath
ticket.TranscriptPath.Valid = transcriptPath != ""
c.LogSvc.LogTicketClosed(ticket, staffID, reason, transcriptPath)
// Send transcript to logs_channel as attachment
if transcriptPath != "" {
sendTranscriptAttachment(s, c.LogSvc.Config.Get().Bot.LogsChannel, transcriptPath, ticket)
}
slog.Info("ticket closed", "ticket_id", ticket.ID, "staff_id", staffID, "reason", reason)
// Delete channel (this will trigger ChannelDelete handler which sets closed again — idempotent)
if err := c.TicketSvc.DeleteChannel(ticket.ChannelID); err != nil {
slog.Error("close: delete channel", "channel_id", ticket.ChannelID, "err", err)
}
}
// HandleConvocationDelete handles the Delete button on convocation channels.
// custom_id: convoc:delete:<channel_id>:<creator_staff_id>
func (c *TicketComponent) HandleConvocationDelete(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
if len(parts) < 3 {
return
}
channelID := parts[2]
creatorStaffID := ""
if len(parts) == 4 {
creatorStaffID = parts[3]
}
staffID := interactionUserID(i)
memberRoles := memberRoleIDs(i)
// Only the creator staff or admin can delete
isAdmin := c.Auth.Can(memberRoles, tickets.ActionAdmin, nil)
if staffID != creatorStaffID && !isAdmin {
ephemeral(s, i, "Tu n'as pas la permission de supprimer cette convocation.")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID)
if err == nil && ticket != nil {
c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, "Convocation supprimée", "", time.Now()) //nolint
}
if err := c.TicketSvc.DeleteChannel(channelID); err != nil {
slog.Error("convoc delete: delete channel", "channel_id", channelID, "err", err)
}
}
func sendTranscriptAttachment(s *discordgo.Session, logsChannel, path string, ticket *db.Ticket) {
if logsChannel == "" || path == "" {
return
}
f, err := os.Open(path)
if err != nil {
slog.Error("open transcript for upload", "path", path, "err", err)
return
}
defer f.Close()
name := fmt.Sprintf("%s-%04d-transcript.html", ticket.Type, ticket.TicketNumber)
if _, err := s.ChannelFileSendWithMessage(logsChannel,
fmt.Sprintf("Transcript du ticket %s-%04d", ticket.Type, ticket.TicketNumber),
name, f); err != nil {
slog.Error("upload transcript", "path", path, "err", err)
}
}
+22
View File
@@ -0,0 +1,22 @@
package events
import (
"context"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/db"
)
type ChannelDeleteHandler struct {
Tickets *db.TicketRepo
}
func (h *ChannelDeleteHandler) Handle(s *discordgo.Session, c *discordgo.ChannelDelete) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.Tickets.SetClosedByChannel(ctx, c.ID, "channel deleted manually"); err != nil {
slog.Error("channel_delete: update ticket", "channel_id", c.ID, "err", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package events
import (
"context"
"log/slog"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/claim"
"github.com/leolionad58/ticketbot/internal/db"
)
type ReadyEvent struct {
TicketRepo *db.TicketRepo
ClaimRepo *db.ClaimMessageRepo
ClaimMgr *claim.Manager
}
func (h *ReadyEvent) Handle(s *discordgo.Session, r *discordgo.Ready) {
slog.Info("bot ready", "user", r.User.Username, "guilds", len(r.Guilds))
h.resumeReupLoops(s)
}
func (h *ReadyEvent) resumeReupLoops(s *discordgo.Session) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
openTickets, err := h.TicketRepo.ListOpen(ctx)
if err != nil {
slog.Error("ready: list open tickets", "err", err)
return
}
resumed := 0
for _, ticket := range openTickets {
cm, err := h.ClaimRepo.Get(ctx, ticket.ID)
lastReupAt := ticket.OpenedAt
if err == nil && cm != nil {
lastReupAt = cm.LastReupAt
}
h.ClaimMgr.ResumeTicket(s, ticket, lastReupAt)
resumed++
}
slog.Info("ready: resumed re-up loops", "count", resumed)
}
+75
View File
@@ -0,0 +1,75 @@
package discord
import (
"log/slog"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/discord/commands"
"github.com/leolionad58/ticketbot/internal/discord/components"
)
// Router dispatches Discord interactions to the appropriate handler.
type Router struct {
PanelCmd *commands.PanelCommand
TicketCmd *commands.TicketCommand
ConvocCmd *commands.ConvocationCommand
PanelComp *components.PanelComponent
TicketComp *components.TicketComponent
}
func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
switch i.Type {
case discordgo.InteractionApplicationCommand:
r.routeCommand(s, i)
case discordgo.InteractionMessageComponent:
r.routeComponent(s, i)
}
}
func (r *Router) routeCommand(s *discordgo.Session, i *discordgo.InteractionCreate) {
name := i.ApplicationCommandData().Name
slog.Debug("command received", "name", name, "user_id", memberUserID(i), "guild_id", i.GuildID)
switch name {
case "panel_send":
r.PanelCmd.Handle(s, i)
case "ticket":
r.TicketCmd.Handle(s, i)
case "convocation":
r.ConvocCmd.Handle(s, i)
default:
slog.Warn("unknown command", "name", name)
}
}
func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
customID := i.MessageComponentData().CustomID
slog.Debug("component received", "custom_id", customID, "user_id", memberUserID(i))
switch {
case strings.HasPrefix(customID, "panel:open:"):
r.PanelComp.Handle(s, i)
case strings.HasPrefix(customID, "claim:take:"):
r.TicketComp.HandleClaim(s, i)
case strings.HasPrefix(customID, "ticket:delete:confirm:"):
r.TicketComp.HandleDeleteConfirm(s, i)
case strings.HasPrefix(customID, "ticket:delete:yes:"):
r.TicketComp.HandleDeleteYes(s, i)
case customID == "ticket:delete:cancel":
r.TicketComp.HandleDeleteCancel(s, i)
case strings.HasPrefix(customID, "convoc:delete:"):
r.TicketComp.HandleConvocationDelete(s, i)
default:
slog.Warn("unknown component", "custom_id", customID)
}
}
func memberUserID(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.User != nil {
return i.Member.User.ID
}
if i.User != nil {
return i.User.ID
}
return ""
}