save first version
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user