This commit is contained in:
lfirmin
2026-05-02 03:23:51 +02:00
parent aeb9b3d96f
commit 181b8ed454
12 changed files with 508 additions and 73 deletions
+53 -18
View File
@@ -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)
}
+3
View File
@@ -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 {
+41 -24
View File
@@ -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)
+1 -1
View File
@@ -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
+143 -9
View File
@@ -11,18 +11,21 @@ import (
"github.com/bwmarrin/discordgo"
"github.com/leolionad58/ticketbot/internal/claim"
"github.com/leolionad58/ticketbot/internal/config"
"github.com/leolionad58/ticketbot/internal/db"
"github.com/leolionad58/ticketbot/internal/logger"
"github.com/leolionad58/ticketbot/internal/tickets"
)
type PanelComponent struct {
TicketSvc *tickets.Service
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Config *config.Provider
TicketSvc *tickets.Service
TicketRepo *db.TicketRepo
ClaimMgr *claim.Manager
LogSvc *logger.DiscordLogger
Config *config.Provider
}
// Handle handles clicks on panel open buttons (custom_id: panel:open:<panel>:<type>).
// Opens a modal asking for ticket title and description.
func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
if len(parts) != 4 {
@@ -30,13 +33,74 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
}
panelName, ticketType := parts[2], parts[3]
// Defer — channel creation can take >3s
modalID := "modal:ticket:" + panelName + ":" + ticketType
if len(modalID) > 100 {
modalID = modalID[:100]
}
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseModal,
Data: &discordgo.InteractionResponseData{
CustomID: modalID,
Title: "Ouvrir un ticket",
Components: []discordgo.MessageComponent{
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "title",
Label: "Titre",
Style: discordgo.TextInputShort,
Required: true,
MaxLength: 100,
Placeholder: "Résume ton problème en quelques mots",
},
}},
discordgo.ActionsRow{Components: []discordgo.MessageComponent{
discordgo.TextInput{
CustomID: "description",
Label: "Description",
Style: discordgo.TextInputParagraph,
Required: true,
MaxLength: 1000,
Placeholder: "Décris ton problème en détail...",
},
}},
},
},
})
}
// HandleModalSubmit handles modal:ticket:<panel>:<type> submissions.
func (c *PanelComponent) HandleModalSubmit(s *discordgo.Session, i *discordgo.InteractionCreate) {
parts := strings.SplitN(i.ModalSubmitData().CustomID, ":", 4)
if len(parts) != 4 {
return
}
panelName, ticketType := parts[2], parts[3]
// Extract modal field values
var ticketTitle, ticketDescription string
for _, row := range i.ModalSubmitData().Components {
if ar, ok := row.(*discordgo.ActionsRow); ok {
for _, comp := range ar.Components {
if ti, ok := comp.(*discordgo.TextInput); ok {
switch ti.CustomID {
case "title":
ticketTitle = ti.Value
case "description":
ticketDescription = ti.Value
}
}
}
}
}
// Defer — channel creation takes time
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
})
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
uID := interactionUserID(i)
@@ -47,11 +111,18 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
followup(s, i, fmt.Sprintf("Tu as déjà un ticket ouvert : <#%s>", chID))
return
}
slog.Error("open ticket", "user_id", uID, "panel", panelName, "type", ticketType, "err", err)
slog.Error("open ticket (modal)", "user_id", uID, "panel", panelName, "type", ticketType, "err", err)
followup(s, i, "Erreur lors de la création du ticket.")
return
}
// Save title and description
if err := c.TicketRepo.SetTitleDescription(ctx, ticket.ID, ticketTitle, ticketDescription); err != nil {
slog.Error("modal: set title/description", "ticket_id", ticket.ID, "err", err)
}
ticket.TicketTitle = db.NullStr(ticketTitle)
ticket.TicketDescription = db.NullStr(ticketDescription)
cfg := c.Config.Get()
var embedTitle, embedText, embedColor string
if panel, ok := cfg.Panels[panelName]; ok {
@@ -62,6 +133,7 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
}
}
// a) Welcome embed (bot identity, no webhook)
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
Embeds: []*discordgo.MessageEmbed{
@@ -77,12 +149,74 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
}},
},
}); err != nil {
slog.Error("open ticket: post welcome embed", "channel_id", ticket.ChannelID, "err", err)
slog.Error("modal: post welcome embed", "channel_id", ticket.ChannelID, "err", err)
}
// b) Webhook impersonation message
postImpersonationMessage(s, ticket, uID, ticketTitle, ticketDescription, parseColor(embedColor), i.GuildID)
// c) Claim channel message (includes title + description)
c.ClaimMgr.StartTicket(ctx, s, ticket)
c.LogSvc.LogTicketOpened(ticket, uID)
slog.Info("ticket opened", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
slog.Info("ticket opened via modal", "ticket_id", ticket.ID, "channel_id", ticket.ChannelID, "user_id", uID)
followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID))
}
// postImpersonationMessage creates a temporary webhook on the ticket channel and posts
// the user's title+description as if it came from the user themselves.
func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, title, description string, color int, guildID string) {
member, err := s.GuildMember(guildID, userID)
if err != nil {
slog.Warn("impersonation: get member", "user_id", userID, "err", err)
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
return
}
user := member.User
avatarURL := user.AvatarURL("128")
displayName := user.Username
if member.Nick != "" {
displayName = member.Nick
}
wh, err := s.WebhookCreate(ticket.ChannelID, "ticket-impersonation", "")
if err != nil {
slog.Warn("impersonation: create webhook", "channel_id", ticket.ChannelID, "err", err)
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
return
}
_, execErr := s.WebhookExecute(wh.ID, wh.Token, true, &discordgo.WebhookParams{
Username: displayName,
AvatarURL: avatarURL,
Embeds: []*discordgo.MessageEmbed{
{
Title: title,
Description: description,
Color: color,
},
},
})
if execErr != nil {
slog.Warn("impersonation: execute webhook", "err", execErr)
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
}
// Delete webhook — channel delete at close cleans up anyway, but we keep it tidy
if delErr := s.WebhookDelete(wh.ID); delErr != nil {
slog.Debug("impersonation: delete webhook", "err", delErr)
}
}
func postImpersonationFallback(s *discordgo.Session, channelID, userID, title, description string, color int) {
s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ //nolint
Embeds: []*discordgo.MessageEmbed{
{
Title: title,
Description: description,
Color: color,
Author: &discordgo.MessageEmbedAuthor{Name: fmt.Sprintf("Demande de <@%s>", userID)},
},
},
})
}
+27 -8
View File
@@ -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 {
+13
View File
@@ -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
+13
View File
@@ -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 {
+33 -1
View File
@@ -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")
}
}
+101 -5
View File
@@ -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 <br>
contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "<br>"))
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", "<br>"))
}
// 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", "<br>")),
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
}
+75 -3
View File
@@ -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;}
</style>
</head>
@@ -48,10 +76,54 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo
{{range .Attachments}}
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
{{end}}
{{range .Embeds}}
{{template "embed" .}}
{{end}}
</div>
</div>
{{end}}
</div>
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
</body>
</html>`
</html>
{{define "embed"}}
<div class="embed" style="border-left-color:{{.Color}}">
<div class="embed-inner">
{{if .Empty}}<span class="embed-empty">[Embed vide]</span>{{else}}
{{if .AuthorName}}
<div class="embed-author">
{{if .AuthorIcon}}<img class="embed-author-icon" src="{{.AuthorIcon}}" alt="">{{end}}
<span class="embed-author-name">{{if .AuthorURL}}<a href="{{.AuthorURL}}" target="_blank" rel="noopener">{{.AuthorName}}</a>{{else}}{{.AuthorName}}{{end}}</span>
</div>
{{end}}
<div class="embed-header">
<div class="embed-title-wrap">
{{if .Title}}<div class="embed-title">{{if .TitleURL}}<a href="{{.TitleURL}}" target="_blank" rel="noopener">{{.Title}}</a>{{else}}{{.Title}}{{end}}</div>{{end}}
{{if .Description}}<div class="embed-description">{{.Description}}</div>{{end}}
</div>
{{if .ThumbURL}}<img class="embed-thumb" src="{{.ThumbURL}}" alt="">{{end}}
</div>
{{if .Fields}}
<div class="embed-fields">
{{range .Fields}}
<div class="embed-field {{if .Inline}}inline{{else}}block{{end}}">
<div class="embed-field-name">{{.Name}}</div>
<div class="embed-field-value">{{.Value}}</div>
</div>
{{end}}
</div>
{{end}}
{{if .ImageURL}}<div class="embed-image"><img src="{{.ImageURL}}" alt=""></div>{{end}}
{{if or .FooterText .Timestamp}}
<div class="embed-footer">
{{if .FooterIcon}}<img class="embed-footer-icon" src="{{.FooterIcon}}" alt="">{{end}}
{{if .FooterText}}<span class="embed-footer-text">{{.FooterText}}</span>{{end}}
{{if and .FooterText .Timestamp}}<span class="embed-footer-sep">•</span>{{end}}
{{if .Timestamp}}<span class="embed-footer-text">{{.Timestamp}}</span>{{end}}
</div>
{{end}}
{{end}}
</div>
</div>
{{end}}`