This commit is contained in:
lfirmin
2026-05-10 18:07:51 +02:00
parent 50af2452b1
commit a5f888b3c1
59 changed files with 6682 additions and 108 deletions
+253
View File
@@ -0,0 +1,253 @@
package components
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
)
// ConvocationComponent handles the convocation panel button and modal.
type ConvocationComponent struct {
ConvocRepo *db.ConvocationConfigRepo
TicketRepo *db.TicketRepo
Auth *tickets.AuthService
LogSvc *logger.DiscordLogger
}
// HandlePanel handles clicks on convoc:panel:<guildID> buttons.
// Shows a modal asking for target user ID and reason.
func (c *ConvocationComponent) HandlePanel(s *discordgo.Session, i *discordgo.InteractionCreate) {
staffID := interactionUserID(i)
memberRoles := memberRoleIDs(i)
if !c.Auth.Can(memberRoles, tickets.ActionConvocation, nil) {
ephemeral(s, i, "Tu n'as pas la permission d'effectuer cette action.")
slog.Warn("convoc panel: denied", "user_id", staffID)
return
}
guildID := interactionGuildID(i)
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 3)
if len(parts) == 3 && parts[2] != "" {
guildID = parts[2]
}
modalID := "modal:convoc:" + guildID
if len(modalID) > 100 {
modalID = modalID[:100]
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: modalID,
Title: "Créer une convocation",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "target",
Label: "ID Discord de l'utilisateur",
Style: discordgo.TextInputShort,
Required: true,
MaxLength: 50,
Placeholder: "123456789012345678",
},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "reason",
Label: "Raison de la convocation",
Style: discordgo.TextInputParagraph,
Required: true,
MaxLength: 500,
Placeholder: "Motif de la convocation...",
},
}},
},
},
})
}
// HandleModalSubmit handles modal:convoc:<guildID> submissions.
func (c *ConvocationComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 3)
if len(parts) != 3 {
return
}
guildID := parts[2]
if guildID == "" {
guildID = interactionGuildID(i)
}
var targetID, reason string
for _, row := range i.ModalSubmitData().Components {
if ar, ok := row.(*discordgo.ActionsRow); ok {
for _, comp := range ar.Components {
if ti, ok := comp.(*discordgo.TextInput); ok {
switch ti.CustomID {
case "target":
targetID = strings.TrimSpace(ti.Value)
case "reason":
reason = strings.TrimSpace(ti.Value)
}
}
}
}
}
if targetID == "" || reason == "" {
ephemeral(s, i, "L'ID utilisateur et la raison sont requis.")
return
}
// Strip mention format <@ID> or <@!ID>
targetID = strings.TrimPrefix(targetID, "<@!")
targetID = strings.TrimPrefix(targetID, "<@")
targetID = strings.TrimSuffix(targetID, ">")
targetID = strings.TrimSpace(targetID)
staffID := interactionUserID(i)
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, err := c.ConvocRepo.Get(ctx, guildID)
if err != nil || cfg == nil || cfg.CategoryID == "" {
followup(s, i, "La catégorie de convocation n'est pas configurée.")
return
}
target, err := s.GuildMember(guildID, targetID)
if err != nil {
followup(s, i, fmt.Sprintf("Utilisateur introuvable dans ce serveur : %s", targetID))
return
}
if target.User.Bot {
followup(s, i, "Tu ne peux pas convoquer un bot.")
return
}
if target.User.ID == staffID {
followup(s, i, "Tu ne peux pas te convoquer toi-même.")
return
}
n, err := c.TicketRepo.NextConvocationNumber(ctx)
if err != nil {
slog.Error("convoc modal: next number", "err", err)
followup(s, i, "Erreur interne.")
return
}
username := target.User.Username
channelName := fmt.Sprintf("convoc-%s-%04d", sanitizeConvocN(username), n)
if len(channelName) > 100 {
channelName = channelName[:100]
}
ch, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: channelName,
Type: discordgo.ChannelTypeGuildText,
ParentID: cfg.CategoryID,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: guildID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: discordgo.PermissionViewChannel,
},
{
ID: staffID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory |
discordgo.PermissionAttachFiles,
},
{
ID: target.User.ID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: discordgo.PermissionViewChannel |
discordgo.PermissionSendMessages |
discordgo.PermissionReadMessageHistory,
},
},
})
if err != nil {
slog.Error("convoc modal: create channel", "err", err)
followup(s, i, "Erreur lors de la création du salon.")
return
}
deleteCustomID := fmt.Sprintf("convoc:delete:%s:%s", ch.ID, staffID)
if len(deleteCustomID) > 100 {
deleteCustomID = "convoc:delete:" + ch.ID
}
s.ChannelMessageSendComplex(ch.ID, &discordgo.MessageSend{ //nolint
Content: fmt.Sprintf("<@%s>", target.User.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,
},
}},
},
})
ticket := &db.Ticket{
UserID: target.User.ID,
Panel: "convocation",
Type: "convocation",
ChannelID: ch.ID,
GuildID: guildID,
LogChannelID: cfg.LogChannelID,
OpenedAt: time.Now(),
Status: "open",
TicketNumber: n,
}
if err := c.TicketRepo.InsertWithNumber(ctx, ticket); err != nil {
slog.Error("convoc modal: insert DB", "err", err)
}
c.LogSvc.LogConvocationOpened(staffID, target.User.ID, reason, ch.ID)
slog.Info("convocation created via panel", "channel_id", ch.ID, "staff_id", staffID, "target_id", target.User.ID)
followup(s, i, fmt.Sprintf("Convocation créée : <#%s>", ch.ID))
}
func sanitizeConvocN(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)
}
+48 -9
View File
@@ -19,6 +19,7 @@ import (
type PanelComponent struct {
TicketSvc *tickets.Service
TicketRepo *db.TicketRepo
PanelRepo *db.PanelConfigRepo
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Config *config.Provider
@@ -123,26 +124,48 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
ticket.TicketTitle = db.NullStr(ticketTitle)
ticket.TicketDescription = db.NullStr(ticketDescription)
cfg := c.Config.Get()
var embedTitle, embedText, embedColor string
if panel, ok := cfg.Panels[panelName]; ok {
var embedTitle, embedText, embedColor, thumbnailURL, imageURL string
yamlCfg := c.Config.Get()
if panel, ok := yamlCfg.Panels[panelName]; ok {
if typeCfg, ok := panel.Types[ticketType]; ok {
embedTitle = typeCfg.EmbedTitle
embedText = typeCfg.EmbedText
embedColor = typeCfg.EmbedColor
}
} else if c.PanelRepo != nil {
if dbPanel, err := c.PanelRepo.GetByName(context.Background(), panelName); err == nil && dbPanel != nil {
for _, t := range dbPanel.Types {
if t.Name == ticketType {
embedTitle = t.EmbedTitle
embedText = t.EmbedText
embedColor = t.EmbedColor
thumbnailURL = t.ThumbnailURL
imageURL = t.ImageURL
break
}
}
}
}
// a) Welcome embed (bot identity, no webhook)
welcomeEmbed := &discordgo.MessageEmbed{
Title: embedTitle,
Description: embedText,
Color: parseColor(embedColor),
}
if thumbnailURL != "" {
welcomeEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: thumbnailURL}
}
if imageURL != "" {
welcomeEmbed.Image = &discordgo.MessageEmbedImage{URL: imageURL}
}
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{
{Title: embedTitle, Description: embedText, Color: parseColor(embedColor)},
},
Embeds: []*discordgo.MessageEmbed{welcomeEmbed},
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{
Label: "Supprimer le ticket",
Label: "Fermer le ticket",
Style: discordgo.DangerButton,
CustomID: deleteCustomID,
},
@@ -155,8 +178,21 @@ func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.In
// b) Webhook impersonation message
postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, i.GuildID)
// c) Claim channel message (includes title + description)
c.ClaimMgr.StartTicket(ctx, s, ticket)
// c) Claim channel message — only if claim mode is enabled for this panel type
claimEnabled := true
if c.PanelRepo != nil {
if dbPanel, err2 := c.PanelRepo.GetByName(context.Background(), panelName); err2 == nil && dbPanel != nil {
for _, t := range dbPanel.Types {
if t.Name == ticketType {
claimEnabled = t.ClaimMode != 0
break
}
}
}
}
if claimEnabled {
c.ClaimMgr.StartTicket(ctx, s, ticket)
}
c.LogSvc.LogTicketOpened(ticket, uID)
slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
@@ -175,6 +211,9 @@ func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, t
user := member.User
avatarURL := user.AvatarURL("128")
displayName := user.Username
if user.GlobalName != "" {
displayName = user.GlobalName
}
if member.Nick != "" {
displayName = member.Nick
}
+2 -2
View File
@@ -60,11 +60,11 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
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.",
Content: "Es-tu sûr de vouloir fermer ce ticket ?",
Flags: discordgo.MessageFlagsEphemeral,
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.Button{Label: "Oui, supprimer", Style: discordgo.DangerButton, CustomID: yesID},
discordgo.Button{Label: "Oui, fermer", Style: discordgo.DangerButton, CustomID: yesID},
discordgo.Button{Label: "Annuler", Style: discordgo.SecondaryButton, CustomID: cancelID},
}},
},