v0.2
This commit is contained in:
@@ -68,7 +68,7 @@ func (c *TicketCommand) Handle(s *discordgo.Session, i *discordgo.InteractionCre
|
||||
}
|
||||
|
||||
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) {
|
||||
if !c.Auth.CanClose(memberRoles, staffID, 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
|
||||
|
||||
@@ -11,18 +11,21 @@ import (
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/leolionad58/ticketbot/internal/claim"
|
||||
"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 PanelComponent struct {
|
||||
TicketSvc *tickets.Service
|
||||
ClaimMgr *claim.Manager
|
||||
LogSvc *logger.DiscordLogger
|
||||
Config *config.Provider
|
||||
TicketSvc *tickets.Service
|
||||
TicketRepo *db.TicketRepo
|
||||
ClaimMgr *claim.Manager
|
||||
LogSvc *logger.DiscordLogger
|
||||
Config *config.Provider
|
||||
}
|
||||
|
||||
// Handle handles clicks on panel open buttons (custom_id: panel:open:<panel>:<type>).
|
||||
// Opens a modal asking for ticket title and description.
|
||||
func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
|
||||
if len(parts) != 4 {
|
||||
@@ -30,13 +33,74 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
}
|
||||
panelName, ticketType := parts[2], parts[3]
|
||||
|
||||
// Defer — channel creation can take >3s
|
||||
modalID := "modal:ticket:" + panelName + ":" + ticketType
|
||||
if len(modalID) > 100 {
|
||||
modalID = modalID[:100]
|
||||
}
|
||||
|
||||
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||
Type: discordgo.InteractionResponseModal,
|
||||
Data: &discordgo.InteractionResponseData{
|
||||
CustomID: modalID,
|
||||
Title: "Ouvrir un ticket",
|
||||
Components: []discordgo.MessageComponent{
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "title",
|
||||
Label: "Titre",
|
||||
Style: discordgo.TextInputShort,
|
||||
Required: true,
|
||||
MaxLength: 100,
|
||||
Placeholder: "Résume ton problème en quelques mots",
|
||||
},
|
||||
}},
|
||||
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
|
||||
discordgo.TextInput{
|
||||
CustomID: "description",
|
||||
Label: "Description",
|
||||
Style: discordgo.TextInputParagraph,
|
||||
Required: true,
|
||||
MaxLength: 1000,
|
||||
Placeholder: "Décris ton problème en détail...",
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// HandleModalSubmit handles modal:ticket:<panel>:<type> submissions.
|
||||
func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 4)
|
||||
if len(parts) != 4 {
|
||||
return
|
||||
}
|
||||
panelName, ticketType := parts[2], parts[3]
|
||||
|
||||
// Extract modal field values
|
||||
var ticketTitle, ticketDescription 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 "title":
|
||||
ticketTitle = ti.Value
|
||||
case "description":
|
||||
ticketDescription = ti.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Defer — channel creation takes 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)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
uID := interactionUserID(i)
|
||||
@@ -47,11 +111,18 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
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)
|
||||
slog.Error("open ticket (modal)", "user_id", uID, "panel", panelName, "type", ticketType, "err", err)
|
||||
followup(s, i, "Erreur lors de la création du ticket.")
|
||||
return
|
||||
}
|
||||
|
||||
// Save title and description
|
||||
if err := c.TicketRepo.SetTitleDescription(ctx, ticket.ID, ticketTitle, ticketDescription); err != nil {
|
||||
slog.Error("modal: set title/description", "ticket_id", ticket.ID, "err", err)
|
||||
}
|
||||
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 {
|
||||
@@ -62,6 +133,7 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
}
|
||||
}
|
||||
|
||||
// a) Welcome embed (bot identity, no webhook)
|
||||
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
|
||||
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
@@ -77,12 +149,74 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
}},
|
||||
},
|
||||
}); err != nil {
|
||||
slog.Error("open ticket: post welcome embed", "channel_id", ticket.ChannelID, "err", err)
|
||||
slog.Error("modal: post welcome embed", "channel_id", ticket.ChannelID, "err", err)
|
||||
}
|
||||
|
||||
// b) Webhook impersonation message
|
||||
postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, parseColor(embedColor), i.GuildID)
|
||||
|
||||
// c) Claim channel message (includes title + description)
|
||||
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)
|
||||
slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
|
||||
followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID))
|
||||
}
|
||||
|
||||
// postImpersonationMessage creates a temporary webhook on the ticket channel and posts
|
||||
// the user's title+description as if it came from the user themselves.
|
||||
func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, title, description string, color int, guildID string) {
|
||||
member, err := s.GuildMember(guildID, userID)
|
||||
if err != nil {
|
||||
slog.Warn("impersonation: get member", "user_id", userID, "err", err)
|
||||
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||
return
|
||||
}
|
||||
user := member.User
|
||||
avatarURL := user.AvatarURL("128")
|
||||
displayName := user.Username
|
||||
if member.Nick != "" {
|
||||
displayName = member.Nick
|
||||
}
|
||||
|
||||
wh, err := s.WebhookCreate(ticket.ChannelID, "ticket-impersonation", "")
|
||||
if err != nil {
|
||||
slog.Warn("impersonation: create webhook", "channel_id", ticket.ChannelID, "err", err)
|
||||
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||
return
|
||||
}
|
||||
|
||||
_, execErr := s.WebhookExecute(wh.ID, wh.Token, true, &discordgo.WebhookParams{
|
||||
Username: displayName,
|
||||
AvatarURL: avatarURL,
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
})
|
||||
if execErr != nil {
|
||||
slog.Warn("impersonation: execute webhook", "err", execErr)
|
||||
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||
}
|
||||
|
||||
// Delete webhook — channel delete at close cleans up anyway, but we keep it tidy
|
||||
if delErr := s.WebhookDelete(wh.ID); delErr != nil {
|
||||
slog.Debug("impersonation: delete webhook", "err", delErr)
|
||||
}
|
||||
}
|
||||
|
||||
func postImpersonationFallback(s *discordgo.Session, channelID, userID, title, description string, color int) {
|
||||
s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ //nolint
|
||||
Embeds: []*discordgo.MessageEmbed{
|
||||
{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Color: color,
|
||||
Author: &discordgo.MessageEmbedAuthor{Name: fmt.Sprintf("Demande de <@%s>", userID)},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
|
||||
}
|
||||
|
||||
memberRoles := memberRoleIDs(i)
|
||||
if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) {
|
||||
uID := interactionUserID(i)
|
||||
if !c.Auth.CanClose(memberRoles, uID, 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)
|
||||
slog.Warn("delete denied", "user_id", uID, "channel_id", channelID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ func (c *TicketComponent) HandleDeleteYes(s *discordgo.Session, i *discordgo.Int
|
||||
|
||||
memberRoles := memberRoleIDs(i)
|
||||
staffID := interactionUserID(i)
|
||||
if !c.Auth.Can(memberRoles, tickets.ActionClose, ticket) {
|
||||
if !c.Auth.CanClose(memberRoles, staffID, ticket) {
|
||||
followup(s, i, "Tu n'as pas la permission d'effectuer cette action.")
|
||||
return
|
||||
}
|
||||
@@ -182,10 +183,14 @@ func (c *TicketComponent) HandleClaim(s *discordgo.Session, i *discordgo.Interac
|
||||
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)
|
||||
// Post public claim embed in ticket channel
|
||||
if _, err := s.ChannelMessageSendEmbed(ticket.ChannelID, &discordgo.MessageEmbed{
|
||||
Title: "Ticket pris en charge",
|
||||
Description: fmt.Sprintf("<@%s> a pris ce ticket en charge.", staffID),
|
||||
Color: 0x57f287,
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
slog.Error("claim: post embed in ticket", "channel_id", ticket.ChannelID, "err", err)
|
||||
}
|
||||
|
||||
c.LogSvc.LogTicketClaimed(ticket, staffID)
|
||||
@@ -263,7 +268,21 @@ func (c *TicketComponent) HandleConvocationDelete(s *discordgo.Session, i *disco
|
||||
|
||||
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
|
||||
// Generate transcript before closing
|
||||
transcriptPath := ""
|
||||
if tp, tErr := c.Transcript.Generate(ticket); tErr != nil {
|
||||
slog.Error("convoc delete: generate transcript", "ticket_id", ticket.ID, "err", tErr)
|
||||
} else {
|
||||
transcriptPath = tp
|
||||
}
|
||||
|
||||
c.TicketRepo.SetClosed(ctx, ticket.ID, staffID, "Convocation supprimée", transcriptPath, time.Now()) //nolint
|
||||
c.LogSvc.LogTicketClosed(ticket, staffID, "Convocation supprimée", transcriptPath)
|
||||
|
||||
if transcriptPath != "" {
|
||||
sendTranscriptAttachment(s, c.LogSvc.Config.Get().Bot.LogsChannel, transcriptPath, ticket)
|
||||
}
|
||||
slog.Info("convocation closed", "ticket_id", ticket.ID, "staff_id", staffID)
|
||||
}
|
||||
|
||||
if err := c.TicketSvc.DeleteChannel(channelID); err != nil {
|
||||
|
||||
@@ -24,6 +24,8 @@ func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
r.routeCommand(s, i)
|
||||
case discordgo.InteractionMessageComponent:
|
||||
r.routeComponent(s, i)
|
||||
case discordgo.InteractionModalSubmit:
|
||||
r.routeModal(s, i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +66,17 @@ func (r *Router) routeComponent(s *discordgo.Session, i *discordgo.InteractionCr
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) routeModal(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
customID := i.ModalSubmitData().CustomID
|
||||
slog.Debug("modal received", "custom_id", customID, "user_id", memberUserID(i))
|
||||
switch {
|
||||
case strings.HasPrefix(customID, "modal:ticket:"):
|
||||
r.PanelComp.HandleModalSubmit(s, i)
|
||||
default:
|
||||
slog.Warn("unknown modal", "custom_id", customID)
|
||||
}
|
||||
}
|
||||
|
||||
func memberUserID(i *discordgo.InteractionCreate) string {
|
||||
if i.Member != nil && i.Member.User != nil {
|
||||
return i.Member.User.ID
|
||||
|
||||
Reference in New Issue
Block a user