diff --git a/cmd/bot/main.go b/cmd/bot/main.go index 0927059..887aa25 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -74,10 +74,11 @@ func main() { // Components panelComp := &components.PanelComponent{ - TicketSvc: ticketSvc, - ClaimMgr: claimMgr, - LogSvc: logSvc, - Config: cfgProvider, + TicketSvc: ticketSvc, + TicketRepo: ticketRepo, + ClaimMgr: claimMgr, + LogSvc: logSvc, + Config: cfgProvider, } ticketComp := &components.TicketComponent{ TicketSvc: ticketSvc, diff --git a/internal/claim/manager.go b/internal/claim/manager.go index f15b8cb..b047c71 100644 --- a/internal/claim/manager.go +++ b/internal/claim/manager.go @@ -29,9 +29,9 @@ func NewManager(cfg *config.Provider, repo *db.TicketRepo, claims *db.ClaimMessa } } -// StartTicket posts the initial claim message and starts the re-up goroutine. +// StartTicket posts the initial claim message (with staff ping) and starts the re-up goroutine. func (m *Manager) StartTicket(ctx context.Context, s *discordgo.Session, ticket *db.Ticket) { - msgID, err := m.postClaim(ctx, s, ticket, false) + msgID, err := m.postClaim(ctx, s, ticket, true) if err != nil { slog.Error("claim: post initial message", "ticket_id", ticket.ID, "err", err) } else { @@ -146,18 +146,41 @@ func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *d return "", fmt.Errorf("claim_channel not configured") } - elapsed := time.Since(ticket.OpenedAt).Round(time.Second) + // Determine embed color from type config, fallback to blurple + embedColor := 0x5865f2 + var staffRoleID string + if panel, ok := cfg.Panels[ticket.Panel]; ok { + if t, ok := panel.Types[ticket.Type]; ok { + if c := parseHexColor(t.EmbedColor); c != 0 { + embedColor = c + } + staffRoleID = t.StaffRole + } + } + + fields := []*discordgo.MessageEmbedField{ + {Name: "Auteur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true}, + {Name: "Numéro", Value: fmt.Sprintf("#%04d", ticket.TicketNumber), Inline: true}, + {Name: "Salon", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, + } + + if ticket.TicketTitle.Valid && ticket.TicketTitle.String != "" { + fields = append(fields, &discordgo.MessageEmbedField{Name: "Titre", Value: ticket.TicketTitle.String, Inline: false}) + } + if ticket.TicketDescription.Valid && ticket.TicketDescription.String != "" { + desc := ticket.TicketDescription.String + if len([]rune(desc)) > 500 { + runes := []rune(desc) + desc = string(runes[:497]) + "..." + } + fields = append(fields, &discordgo.MessageEmbedField{Name: "Description", Value: desc, Inline: false}) + } + embed := &discordgo.MessageEmbed{ - Title: "Nouveau ticket à claim", - Color: 0x5865f2, - Fields: []*discordgo.MessageEmbedField{ - {Name: "Type", Value: ticket.Type, Inline: true}, - {Name: "Panel", Value: ticket.Panel, Inline: true}, - {Name: "Utilisateur", Value: fmt.Sprintf("<@%s>", ticket.UserID), Inline: true}, - {Name: "Numéro", Value: fmt.Sprintf("%04d", ticket.TicketNumber), Inline: true}, - {Name: "Channel", Value: fmt.Sprintf("<#%s>", ticket.ChannelID), Inline: true}, - {Name: "Ouvert il y a", Value: elapsed.String(), Inline: true}, - }, + Title: fmt.Sprintf("Nouveau ticket — %s", ticket.Type), + Color: embedColor, + Fields: fields, + Timestamp: ticket.OpenedAt.UTC().Format(time.RFC3339), } customID := fmt.Sprintf("claim:take:%d", ticket.ID) @@ -174,11 +197,11 @@ func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *d }, } - if withPing { - if panel, ok := cfg.Panels[ticket.Panel]; ok { - if t, ok := panel.Types[ticket.Type]; ok && t.StaffRole != "" { - send.Content = fmt.Sprintf("<@&%s>", t.StaffRole) - } + // Always ping staff role + if withPing && staffRoleID != "" { + send.Content = fmt.Sprintf("<@&%s>", staffRoleID) + send.AllowedMentions = &discordgo.MessageAllowedMentions{ + Roles: []string{staffRoleID}, } } @@ -188,3 +211,15 @@ func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *d } return msg.ID, nil } + +func parseHexColor(hex string) int { + if len(hex) > 0 && hex[0] == '#' { + hex = hex[1:] + } + if len(hex) == 0 { + return 0 + } + var v int64 + fmt.Sscanf(hex, "%x", &v) + return int(v) +} diff --git a/internal/db/db.go b/internal/db/db.go index 65cdcb8..09ed907 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -60,6 +60,9 @@ func migrate(db *sql.DB) error { count INTEGER NOT NULL DEFAULT 0 ); INSERT OR IGNORE INTO convocation_counter(id,count) VALUES(1,0);`, + // v2: user-supplied ticket title and description (from modal) + `ALTER TABLE tickets ADD COLUMN ticket_title TEXT; + ALTER TABLE tickets ADD COLUMN ticket_description TEXT;`, } for i, m := range migrations { diff --git a/internal/db/tickets.go b/internal/db/tickets.go index 439040f..39da9d8 100644 --- a/internal/db/tickets.go +++ b/internal/db/tickets.go @@ -8,20 +8,22 @@ import ( ) type Ticket struct { - ID int64 - TicketNumber int - UserID string - Panel string - Type string - ChannelID string - OpenedAt time.Time - ClaimedBy sql.NullString - ClaimedAt sql.NullTime - ClosedAt sql.NullTime - ClosedBy sql.NullString - Reason sql.NullString - TranscriptPath sql.NullString - Status string + ID int64 + TicketNumber int + UserID string + Panel string + Type string + ChannelID string + OpenedAt time.Time + ClaimedBy sql.NullString + ClaimedAt sql.NullTime + ClosedAt sql.NullTime + ClosedBy sql.NullString + Reason sql.NullString + TranscriptPath sql.NullString + Status string + TicketTitle sql.NullString + TicketDescription sql.NullString } type TicketRepo struct{ db *sql.DB } @@ -51,9 +53,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error { t.TicketNumber = n res, err := tx.ExecContext(ctx, ` - INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status) - VALUES(?,?,?,?,?,?,?)`, + INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) + VALUES(?,?,?,?,?,?,?,?,?)`, n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", + t.TicketTitle, t.TicketDescription, ) if err != nil { return fmt.Errorf("insert ticket: %w", err) @@ -66,9 +69,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error { // InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations). func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error { res, err := r.db.ExecContext(ctx, ` - INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status) - VALUES(?,?,?,?,?,?,?)`, + INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description) + VALUES(?,?,?,?,?,?,?,?,?)`, t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open", + t.TicketTitle, t.TicketDescription, ) if err != nil { return fmt.Errorf("insert ticket with number: %w", err) @@ -81,7 +85,7 @@ func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error { func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status + claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE channel_id=?`, channelID) return scanTicket(row) } @@ -89,7 +93,7 @@ func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Tic func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status + claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE id=?`, id) return scanTicket(row) } @@ -97,7 +101,7 @@ func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) { func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) { row := r.db.QueryRowContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status + claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed') LIMIT 1`, userID, ticketType) t, err := scanTicket(row) @@ -133,7 +137,7 @@ func (r *TicketRepo) SetClosedByChannel(ctx context.Context, channelID, reason s func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at, - claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status + claimed_by,claimed_at,closed_at,closed_by,reason,transcript_path,status,ticket_title,ticket_description FROM tickets WHERE status='open'`) if err != nil { return nil, err @@ -160,11 +164,24 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) { return n, tx.Commit() } +// NullStr returns a valid NullString for non-empty strings, invalid (NULL) for empty. +func NullStr(s string) sql.NullString { + return sql.NullString{String: s, Valid: s != ""} +} + +// SetTitleDescription updates the title and description of a ticket after modal submission. +func (r *TicketRepo) SetTitleDescription(ctx context.Context, id int64, title, description string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE tickets SET ticket_title=?, ticket_description=? WHERE id=?`, + title, description, id) + return err +} + func scanTicket(row *sql.Row) (*Ticket, error) { var t Ticket err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, - &t.Reason, &t.TranscriptPath, &t.Status) + &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription) if err != nil { return nil, err } @@ -177,7 +194,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) { var t Ticket if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID, &t.OpenedAt, &t.ClaimedBy, &t.ClaimedAt, &t.ClosedAt, &t.ClosedBy, - &t.Reason, &t.TranscriptPath, &t.Status); err != nil { + &t.Reason, &t.TranscriptPath, &t.Status, &t.TicketTitle, &t.TicketDescription); err != nil { return nil, err } result = append(result, &t) diff --git a/internal/discord/commands/ticket.go b/internal/discord/commands/ticket.go index 08363fe..faae8a8 100644 --- a/internal/discord/commands/ticket.go +++ b/internal/discord/commands/ticket.go @@ -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 diff --git a/internal/discord/components/panel.go b/internal/discord/components/panel.go index 2e49cc8..9a51350 100644 --- a/internal/discord/components/panel.go +++ b/internal/discord/components/panel.go @@ -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::). +// 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:: 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)}, + }, + }, + }) +} diff --git a/internal/discord/components/ticket.go b/internal/discord/components/ticket.go index 0feb709..764dbd7 100644 --- a/internal/discord/components/ticket.go +++ b/internal/discord/components/ticket.go @@ -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 { diff --git a/internal/discord/router.go b/internal/discord/router.go index 34ab5a5..7930ee3 100644 --- a/internal/discord/router.go +++ b/internal/discord/router.go @@ -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 diff --git a/internal/tickets/auth.go b/internal/tickets/auth.go index 7e8ac98..606effd 100644 --- a/internal/tickets/auth.go +++ b/internal/tickets/auth.go @@ -27,6 +27,19 @@ func NewAuthService(cfg *config.Provider) *AuthService { return &AuthService{Config: cfg} } +// CanClose returns true if the user can close the given ticket. +// Ticket owner can close if status is 'open' (not yet claimed). Staff/admin can always close. +func (a *AuthService) CanClose(memberRoles []string, userID string, ticket *db.Ticket) bool { + if ticket == nil { + return false + } + // Owner can close their own unclaimed ticket + if userID == ticket.UserID && ticket.Status == "open" { + return true + } + return a.Can(memberRoles, ActionClose, ticket) +} + // Can returns true if the member (by their role IDs) is authorized to perform action on ticket. // ticket may be nil for actions that don't require a specific ticket context (e.g. ActionAdmin, ActionConvocation). func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket) bool { diff --git a/internal/tickets/auth_test.go b/internal/tickets/auth_test.go index 447ce6f..48a4f63 100644 --- a/internal/tickets/auth_test.go +++ b/internal/tickets/auth_test.go @@ -88,8 +88,40 @@ func TestCanConvocation(t *testing.T) { func TestCanNilTicket(t *testing.T) { auth := NewAuthService(makeProvider()) - // Should not panic and should return false for staff actions without ticket if auth.Can([]string{"staff-role"}, ActionClaim, nil) { t.Error("should not claim nil ticket") } } + +func TestCanCloseOwner(t *testing.T) { + auth := NewAuthService(makeProvider()) + ticket := &db.Ticket{Panel: "support_panel", Type: "support", UserID: "owner-123", Status: "open"} + + // Owner can close if open + if !auth.CanClose([]string{}, "owner-123", ticket) { + t.Error("owner should close open ticket") + } + + // Owner cannot close once claimed + ticket.Status = "claimed" + if auth.CanClose([]string{}, "owner-123", ticket) { + t.Error("owner should not close claimed ticket") + } + + // Non-owner without staff role cannot close + ticket.Status = "open" + if auth.CanClose([]string{}, "other-user", ticket) { + t.Error("non-owner without staff should not close") + } + + // Staff can still close claimed tickets + ticket.Status = "claimed" + if !auth.CanClose([]string{"staff-role"}, "other-user", ticket) { + t.Error("staff should close claimed ticket") + } + + // Admin can always close + if !auth.CanClose([]string{"admin-role"}, "other-user", ticket) { + t.Error("admin should always close") + } +} diff --git a/internal/transcript/generator.go b/internal/transcript/generator.go index 5abbf53..ae90592 100644 --- a/internal/transcript/generator.go +++ b/internal/transcript/generator.go @@ -23,6 +23,7 @@ type Message struct { Content template.HTML Timestamp string Attachments []Attachment + Embeds []RenderedEmbed } type Attachment struct { @@ -30,6 +31,29 @@ type Attachment struct { URL string } +type RenderedEmbed struct { + Color template.CSS // CSS hex e.g. "#5865f2" + AuthorName string + AuthorIcon string + AuthorURL string + Title string + TitleURL string + Description template.HTML + Fields []EmbedField + ImageURL string + ThumbURL string + FooterText string + FooterIcon string + Timestamp string + Empty bool // true if nothing visible to show +} + +type EmbedField struct { + Name string + Value template.HTML + Inline bool +} + type Generator struct { OutputDir string Session *discordgo.Session @@ -39,8 +63,6 @@ func NewGenerator(outputDir string, s *discordgo.Session) *Generator { return &Generator{OutputDir: outputDir, Session: s} } -// Generate fetches all messages from the ticket channel and writes the HTML transcript. -// Returns the output file path. func (g *Generator) Generate(ticket *db.Ticket) (string, error) { messages, err := g.fetchAll(ticket.ChannelID) if err != nil { @@ -103,7 +125,6 @@ func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) { } before = batch[len(batch)-1].ID } - // Reverse to chronological order for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { all[i], all[j] = all[j], all[i] } @@ -111,6 +132,7 @@ func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) { } func renderMessage(m *discordgo.Message) Message { + // Webhook messages use WebhookID — author is the impersonated user name := m.Author.Username avatar := m.Author.AvatarURL("64") initial := "?" @@ -118,9 +140,7 @@ func renderMessage(m *discordgo.Message) Message { initial = strings.ToUpper(string([]rune(name)[0])) } - // Escape all user content — XSS protection content := template.HTMLEscapeString(m.Content) - // Preserve newlines as
contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "
")) ts := "" @@ -136,6 +156,11 @@ func renderMessage(m *discordgo.Message) Message { }) } + var embeds []RenderedEmbed + for _, e := range m.Embeds { + embeds = append(embeds, renderEmbed(e)) + } + return Message{ AuthorName: template.HTMLEscapeString(name), AuthorAvatar: avatar, @@ -143,5 +168,76 @@ func renderMessage(m *discordgo.Message) Message { Content: contentHTML, Timestamp: ts, Attachments: attachments, + Embeds: embeds, } } + +func renderEmbed(e *discordgo.MessageEmbed) RenderedEmbed { + re := RenderedEmbed{} + + // Color + if e.Color != 0 { + re.Color = template.CSS(fmt.Sprintf("#%06x", e.Color)) + } else { + re.Color = "#4f545c" + } + + // Author + if e.Author != nil { + re.AuthorName = template.HTMLEscapeString(e.Author.Name) + re.AuthorIcon = e.Author.IconURL + re.AuthorURL = e.Author.URL + } + + // Title + if e.Title != "" { + re.Title = template.HTMLEscapeString(e.Title) + re.TitleURL = e.URL + } + + // Description + if e.Description != "" { + escaped := template.HTMLEscapeString(e.Description) + re.Description = template.HTML(strings.ReplaceAll(escaped, "\n", "
")) + } + + // Fields + for _, f := range e.Fields { + val := template.HTMLEscapeString(f.Value) + re.Fields = append(re.Fields, EmbedField{ + Name: template.HTMLEscapeString(f.Name), + Value: template.HTML(strings.ReplaceAll(val, "\n", "
")), + Inline: f.Inline, + }) + } + + // Image + if e.Image != nil { + re.ImageURL = e.Image.URL + } + + // Thumbnail + if e.Thumbnail != nil { + re.ThumbURL = e.Thumbnail.URL + } + + // Footer + if e.Footer != nil { + re.FooterText = template.HTMLEscapeString(e.Footer.Text) + re.FooterIcon = e.Footer.IconURL + } + + // Timestamp + if e.Timestamp != "" { + if t, err := time.Parse(time.RFC3339, e.Timestamp); err == nil { + re.Timestamp = t.UTC().Format("02/01/2006 15:04") + } + } + + // Detect empty embed (nothing visible) + re.Empty = re.AuthorName == "" && re.Title == "" && + re.Description == "" && len(re.Fields) == 0 && + re.ImageURL == "" && re.FooterText == "" + + return re +} diff --git a/internal/transcript/template.go b/internal/transcript/template.go index 8ddfa4a..e3f0c57 100644 --- a/internal/transcript/template.go +++ b/internal/transcript/template.go @@ -13,7 +13,7 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo .header h1{color:#fff;font-size:1.1em;margin-bottom:4px;} .header p{color:#b9bbbe;font-size:.85em;margin-top:2px;} .messages{padding:8px 0;} -.message{display:flex;padding:6px 16px;gap:12px;} +.message{display:flex;padding:6px 16px;gap:12px;min-width:0;} .message:hover{background:#32353b;} .avatar{width:40px;height:40px;border-radius:50%;flex-shrink:0;object-fit:cover;} .avatar-placeholder{width:40px;height:40px;border-radius:50%;flex-shrink:0;background:#5865f2;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:1.1em;} @@ -21,10 +21,38 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo .msg-header{display:flex;align-items:baseline;gap:8px;margin-bottom:2px;} .author{font-weight:700;color:#fff;font-size:.9em;} .timestamp{color:#72767d;font-size:.75em;} -.text{color:#dcddde;word-break:break-word;white-space:pre-wrap;} +.text{color:#dcddde;word-break:break-word;} .attachment{margin-top:4px;} .attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;} .attachment a:hover{text-decoration:underline;} + +/* Embeds */ +.embed{background:#2f3136;border-radius:0 4px 4px 0;margin-top:6px;padding:8px 12px 12px 12px;max-width:520px;border-left:4px solid #4f545c;} +.embed-author{display:flex;align-items:center;gap:6px;margin-bottom:4px;} +.embed-author-icon{width:20px;height:20px;border-radius:50%;object-fit:cover;} +.embed-author-name{font-size:.85em;font-weight:600;color:#fff;} +.embed-author-name a{color:#fff;text-decoration:none;} +.embed-author-name a:hover{text-decoration:underline;} +.embed-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;} +.embed-title-wrap{flex:1;} +.embed-title{font-weight:700;font-size:.95em;color:#fff;margin-bottom:4px;} +.embed-title a{color:#00b0f4;text-decoration:none;} +.embed-title a:hover{text-decoration:underline;} +.embed-thumb{width:80px;height:80px;border-radius:4px;object-fit:cover;flex-shrink:0;} +.embed-description{color:#dcddde;font-size:.9em;margin-bottom:8px;word-break:break-word;} +.embed-fields{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;} +.embed-field{min-width:0;} +.embed-field.inline{flex:1 1 140px;max-width:calc(33% - 6px);} +.embed-field.block{flex:1 1 100%;} +.embed-field-name{font-weight:700;font-size:.85em;color:#fff;margin-bottom:2px;} +.embed-field-value{font-size:.85em;color:#dcddde;word-break:break-word;} +.embed-image{margin-top:8px;} +.embed-image img{max-width:400px;max-height:300px;border-radius:4px;display:block;} +.embed-footer{display:flex;align-items:center;gap:6px;margin-top:8px;} +.embed-footer-icon{width:16px;height:16px;border-radius:50%;object-fit:cover;} +.embed-footer-text{color:#72767d;font-size:.75em;} +.embed-footer-sep{color:#72767d;font-size:.75em;} +.embed-empty{color:#72767d;font-style:italic;font-size:.85em;} .footer{text-align:center;color:#72767d;font-size:.8em;padding:12px;border-top:1px solid #202225;margin-top:8px;} @@ -48,10 +76,54 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo {{range .Attachments}} {{end}} + {{range .Embeds}} + {{template "embed" .}} + {{end}} {{end}} -` + + +{{define "embed"}} +
+
+ {{if .Empty}}[Embed vide]{{else}} + {{if .AuthorName}} +
+ {{if .AuthorIcon}}{{end}} + {{if .AuthorURL}}{{.AuthorName}}{{else}}{{.AuthorName}}{{end}} +
+ {{end}} +
+
+ {{if .Title}}
{{if .TitleURL}}{{.Title}}{{else}}{{.Title}}{{end}}
{{end}} + {{if .Description}}
{{.Description}}
{{end}} +
+ {{if .ThumbURL}}{{end}} +
+ {{if .Fields}} +
+ {{range .Fields}} +
+
{{.Name}}
+
{{.Value}}
+
+ {{end}} +
+ {{end}} + {{if .ImageURL}}
{{end}} + {{if or .FooterText .Timestamp}} + + {{end}} + {{end}} +
+
+{{end}}`