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