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
+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)
}
}