v0.2
This commit is contained in:
+5
-4
@@ -74,10 +74,11 @@ func main() {
|
|||||||
|
|
||||||
// Components
|
// Components
|
||||||
panelComp := &components.PanelComponent{
|
panelComp := &components.PanelComponent{
|
||||||
TicketSvc: ticketSvc,
|
TicketSvc: ticketSvc,
|
||||||
ClaimMgr: claimMgr,
|
TicketRepo: ticketRepo,
|
||||||
LogSvc: logSvc,
|
ClaimMgr: claimMgr,
|
||||||
Config: cfgProvider,
|
LogSvc: logSvc,
|
||||||
|
Config: cfgProvider,
|
||||||
}
|
}
|
||||||
ticketComp := &components.TicketComponent{
|
ticketComp := &components.TicketComponent{
|
||||||
TicketSvc: ticketSvc,
|
TicketSvc: ticketSvc,
|
||||||
|
|||||||
+53
-18
@@ -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) {
|
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 {
|
if err != nil {
|
||||||
slog.Error("claim: post initial message", "ticket_id", ticket.ID, "err", err)
|
slog.Error("claim: post initial message", "ticket_id", ticket.ID, "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -146,18 +146,41 @@ func (m *Manager) postClaim(ctx context.Context, s *discordgo.Session, ticket *d
|
|||||||
return "", fmt.Errorf("claim_channel not configured")
|
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{
|
embed := &discordgo.MessageEmbed{
|
||||||
Title: "Nouveau ticket à claim",
|
Title: fmt.Sprintf("Nouveau ticket — %s", ticket.Type),
|
||||||
Color: 0x5865f2,
|
Color: embedColor,
|
||||||
Fields: []*discordgo.MessageEmbedField{
|
Fields: fields,
|
||||||
{Name: "Type", Value: ticket.Type, Inline: true},
|
Timestamp: ticket.OpenedAt.UTC().Format(time.RFC3339),
|
||||||
{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},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customID := fmt.Sprintf("claim:take:%d", ticket.ID)
|
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 {
|
// Always ping staff role
|
||||||
if panel, ok := cfg.Panels[ticket.Panel]; ok {
|
if withPing && staffRoleID != "" {
|
||||||
if t, ok := panel.Types[ticket.Type]; ok && t.StaffRole != "" {
|
send.Content = fmt.Sprintf("<@&%s>", staffRoleID)
|
||||||
send.Content = fmt.Sprintf("<@&%s>", t.StaffRole)
|
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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ func migrate(db *sql.DB) error {
|
|||||||
count INTEGER NOT NULL DEFAULT 0
|
count INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
INSERT OR IGNORE INTO convocation_counter(id,count) VALUES(1,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 {
|
for i, m := range migrations {
|
||||||
|
|||||||
+41
-24
@@ -8,20 +8,22 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Ticket struct {
|
type Ticket struct {
|
||||||
ID int64
|
ID int64
|
||||||
TicketNumber int
|
TicketNumber int
|
||||||
UserID string
|
UserID string
|
||||||
Panel string
|
Panel string
|
||||||
Type string
|
Type string
|
||||||
ChannelID string
|
ChannelID string
|
||||||
OpenedAt time.Time
|
OpenedAt time.Time
|
||||||
ClaimedBy sql.NullString
|
ClaimedBy sql.NullString
|
||||||
ClaimedAt sql.NullTime
|
ClaimedAt sql.NullTime
|
||||||
ClosedAt sql.NullTime
|
ClosedAt sql.NullTime
|
||||||
ClosedBy sql.NullString
|
ClosedBy sql.NullString
|
||||||
Reason sql.NullString
|
Reason sql.NullString
|
||||||
TranscriptPath sql.NullString
|
TranscriptPath sql.NullString
|
||||||
Status string
|
Status string
|
||||||
|
TicketTitle sql.NullString
|
||||||
|
TicketDescription sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
type TicketRepo struct{ db *sql.DB }
|
type TicketRepo struct{ db *sql.DB }
|
||||||
@@ -51,9 +53,10 @@ func (r *TicketRepo) Insert(ctx context.Context, t *Ticket) error {
|
|||||||
t.TicketNumber = n
|
t.TicketNumber = n
|
||||||
|
|
||||||
res, err := tx.ExecContext(ctx, `
|
res, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status)
|
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
|
||||||
VALUES(?,?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||||
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
n, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
||||||
|
t.TicketTitle, t.TicketDescription,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert ticket: %w", err)
|
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).
|
// InsertWithNumber inserts a ticket where TicketNumber is already set (e.g. convocations).
|
||||||
func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
|
func (r *TicketRepo) InsertWithNumber(ctx context.Context, t *Ticket) error {
|
||||||
res, err := r.db.ExecContext(ctx, `
|
res, err := r.db.ExecContext(ctx, `
|
||||||
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status)
|
INSERT INTO tickets(ticket_number,user_id,panel,type,channel_id,opened_at,status,ticket_title,ticket_description)
|
||||||
VALUES(?,?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||||
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
t.TicketNumber, t.UserID, t.Panel, t.Type, t.ChannelID, t.OpenedAt.UTC(), "open",
|
||||||
|
t.TicketTitle, t.TicketDescription,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("insert ticket with number: %w", err)
|
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) {
|
func (r *TicketRepo) GetByChannelID(ctx context.Context, channelID string) (*Ticket, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
|
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)
|
FROM tickets WHERE channel_id=?`, channelID)
|
||||||
return scanTicket(row)
|
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) {
|
func (r *TicketRepo) GetByID(ctx context.Context, id int64) (*Ticket, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
|
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)
|
FROM tickets WHERE id=?`, id)
|
||||||
return scanTicket(row)
|
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) {
|
func (r *TicketRepo) HasOpenTicket(ctx context.Context, userID, ticketType string) (*Ticket, error) {
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
|
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')
|
FROM tickets WHERE user_id=? AND type=? AND status IN('open','claimed')
|
||||||
LIMIT 1`, userID, ticketType)
|
LIMIT 1`, userID, ticketType)
|
||||||
t, err := scanTicket(row)
|
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) {
|
func (r *TicketRepo) ListOpen(ctx context.Context) ([]*Ticket, error) {
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT id,ticket_number,user_id,panel,type,channel_id,opened_at,
|
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'`)
|
FROM tickets WHERE status='open'`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -160,11 +164,24 @@ func (r *TicketRepo) NextConvocationNumber(ctx context.Context) (int, error) {
|
|||||||
return n, tx.Commit()
|
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) {
|
func scanTicket(row *sql.Row) (*Ticket, error) {
|
||||||
var t Ticket
|
var t Ticket
|
||||||
err := row.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
|
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.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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -177,7 +194,7 @@ func scanTickets(rows *sql.Rows) ([]*Ticket, error) {
|
|||||||
var t Ticket
|
var t Ticket
|
||||||
if err := rows.Scan(&t.ID, &t.TicketNumber, &t.UserID, &t.Panel, &t.Type, &t.ChannelID,
|
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.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
|
return nil, err
|
||||||
}
|
}
|
||||||
result = append(result, &t)
|
result = append(result, &t)
|
||||||
|
|||||||
@@ -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) {
|
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.")
|
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)
|
slog.Warn("ticket close denied", "user_id", staffID, "channel_id", ticket.ChannelID)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,18 +11,21 @@ import (
|
|||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/leolionad58/ticketbot/internal/claim"
|
"github.com/leolionad58/ticketbot/internal/claim"
|
||||||
"github.com/leolionad58/ticketbot/internal/config"
|
"github.com/leolionad58/ticketbot/internal/config"
|
||||||
|
"github.com/leolionad58/ticketbot/internal/db"
|
||||||
"github.com/leolionad58/ticketbot/internal/logger"
|
"github.com/leolionad58/ticketbot/internal/logger"
|
||||||
"github.com/leolionad58/ticketbot/internal/tickets"
|
"github.com/leolionad58/ticketbot/internal/tickets"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PanelComponent struct {
|
type PanelComponent struct {
|
||||||
TicketSvc *tickets.Service
|
TicketSvc *tickets.Service
|
||||||
ClaimMgr *claim.Manager
|
TicketRepo *db.TicketRepo
|
||||||
LogSvc *logger.DiscordLogger
|
ClaimMgr *claim.Manager
|
||||||
Config *config.Provider
|
LogSvc *logger.DiscordLogger
|
||||||
|
Config *config.Provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle handles clicks on panel open buttons (custom_id: panel:open:<panel>:<type>).
|
// 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) {
|
func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||||
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
|
parts := strings.SplitN(i.MessageComponentData().CustomID, ":", 4)
|
||||||
if len(parts) != 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]
|
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
|
s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ //nolint
|
||||||
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
|
||||||
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
|
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()
|
defer cancel()
|
||||||
|
|
||||||
uID := interactionUserID(i)
|
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))
|
followup(s, i, fmt.Sprintf("Tu as déjà un ticket ouvert : <#%s>", chID))
|
||||||
return
|
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.")
|
followup(s, i, "Erreur lors de la création du ticket.")
|
||||||
return
|
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()
|
cfg := c.Config.Get()
|
||||||
var embedTitle, embedText, embedColor string
|
var embedTitle, embedText, embedColor string
|
||||||
if panel, ok := cfg.Panels[panelName]; ok {
|
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
|
deleteCustomID := "ticket:delete:confirm:" + ticket.ChannelID
|
||||||
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
|
if _, err := s.ChannelMessageSendComplex(ticket.ChannelID, &discordgo.MessageSend{
|
||||||
Embeds: []*discordgo.MessageEmbed{
|
Embeds: []*discordgo.MessageEmbed{
|
||||||
@@ -77,12 +149,74 @@ func (c *PanelComponent) Handle(s *discordgo.Session, i *discordgo.InteractionCr
|
|||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
}); err != nil {
|
}); 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.ClaimMgr.StartTicket(ctx, s, ticket)
|
||||||
c.LogSvc.LogTicketOpened(ticket, uID)
|
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))
|
followup(s, i, fmt.Sprintf("Ticket créé : <#%s>", ticket.ChannelID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// postImpersonationMessage creates a temporary webhook on the ticket channel and posts
|
||||||
|
// the user's title+description as if it came from the user themselves.
|
||||||
|
func postImpersonationMessage(s *discordgo.Session, ticket *db.Ticket, userID, title, description string, color int, guildID string) {
|
||||||
|
member, err := s.GuildMember(guildID, userID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("impersonation: get member", "user_id", userID, "err", err)
|
||||||
|
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := member.User
|
||||||
|
avatarURL := user.AvatarURL("128")
|
||||||
|
displayName := user.Username
|
||||||
|
if member.Nick != "" {
|
||||||
|
displayName = member.Nick
|
||||||
|
}
|
||||||
|
|
||||||
|
wh, err := s.WebhookCreate(ticket.ChannelID, "ticket-impersonation", "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("impersonation: create webhook", "channel_id", ticket.ChannelID, "err", err)
|
||||||
|
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, execErr := s.WebhookExecute(wh.ID, wh.Token, true, &discordgo.WebhookParams{
|
||||||
|
Username: displayName,
|
||||||
|
AvatarURL: avatarURL,
|
||||||
|
Embeds: []*discordgo.MessageEmbed{
|
||||||
|
{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Color: color,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if execErr != nil {
|
||||||
|
slog.Warn("impersonation: execute webhook", "err", execErr)
|
||||||
|
postImpersonationFallback(s, ticket.ChannelID, userID, title, description, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete webhook — channel delete at close cleans up anyway, but we keep it tidy
|
||||||
|
if delErr := s.WebhookDelete(wh.ID); delErr != nil {
|
||||||
|
slog.Debug("impersonation: delete webhook", "err", delErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postImpersonationFallback(s *discordgo.Session, channelID, userID, title, description string, color int) {
|
||||||
|
s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ //nolint
|
||||||
|
Embeds: []*discordgo.MessageEmbed{
|
||||||
|
{
|
||||||
|
Title: title,
|
||||||
|
Description: description,
|
||||||
|
Color: color,
|
||||||
|
Author: &discordgo.MessageEmbedAuthor{Name: fmt.Sprintf("Demande de <@%s>", userID)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ func (c *TicketComponent) HandleDeleteConfirm(s *discordgo.Session, i *discordgo
|
|||||||
}
|
}
|
||||||
|
|
||||||
memberRoles := memberRoleIDs(i)
|
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.")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ func (c *TicketComponent) HandleDeleteYes(s *discordgo.Session, i *discordgo.Int
|
|||||||
|
|
||||||
memberRoles := memberRoleIDs(i)
|
memberRoles := memberRoleIDs(i)
|
||||||
staffID := interactionUserID(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.")
|
followup(s, i, "Tu n'as pas la permission d'effectuer cette action.")
|
||||||
return
|
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)
|
slog.Error("claim: add staff to channel", "channel_id", ticket.ChannelID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post in ticket channel
|
// Post public claim embed in ticket channel
|
||||||
if _, err := s.ChannelMessageSend(ticket.ChannelID,
|
if _, err := s.ChannelMessageSendEmbed(ticket.ChannelID, &discordgo.MessageEmbed{
|
||||||
fmt.Sprintf("Ticket claim par <@%s>.", staffID)); err != nil {
|
Title: "Ticket pris en charge",
|
||||||
slog.Error("claim: post in ticket", "channel_id", ticket.ChannelID, "err", err)
|
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)
|
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)
|
ticket, err := c.TicketRepo.GetByChannelID(ctx, channelID)
|
||||||
if err == nil && ticket != nil {
|
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 {
|
if err := c.TicketSvc.DeleteChannel(channelID); err != nil {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func (r *Router) Handle(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
r.routeCommand(s, i)
|
r.routeCommand(s, i)
|
||||||
case discordgo.InteractionMessageComponent:
|
case discordgo.InteractionMessageComponent:
|
||||||
r.routeComponent(s, i)
|
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 {
|
func memberUserID(i *discordgo.InteractionCreate) string {
|
||||||
if i.Member != nil && i.Member.User != nil {
|
if i.Member != nil && i.Member.User != nil {
|
||||||
return i.Member.User.ID
|
return i.Member.User.ID
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ func NewAuthService(cfg *config.Provider) *AuthService {
|
|||||||
return &AuthService{Config: cfg}
|
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.
|
// 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).
|
// 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 {
|
func (a *AuthService) Can(memberRoles []string, action Action, ticket *db.Ticket) bool {
|
||||||
|
|||||||
@@ -88,8 +88,40 @@ func TestCanConvocation(t *testing.T) {
|
|||||||
|
|
||||||
func TestCanNilTicket(t *testing.T) {
|
func TestCanNilTicket(t *testing.T) {
|
||||||
auth := NewAuthService(makeProvider())
|
auth := NewAuthService(makeProvider())
|
||||||
// Should not panic and should return false for staff actions without ticket
|
|
||||||
if auth.Can([]string{"staff-role"}, ActionClaim, nil) {
|
if auth.Can([]string{"staff-role"}, ActionClaim, nil) {
|
||||||
t.Error("should not claim nil ticket")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Message struct {
|
|||||||
Content template.HTML
|
Content template.HTML
|
||||||
Timestamp string
|
Timestamp string
|
||||||
Attachments []Attachment
|
Attachments []Attachment
|
||||||
|
Embeds []RenderedEmbed
|
||||||
}
|
}
|
||||||
|
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
@@ -30,6 +31,29 @@ type Attachment struct {
|
|||||||
URL string
|
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 {
|
type Generator struct {
|
||||||
OutputDir string
|
OutputDir string
|
||||||
Session *discordgo.Session
|
Session *discordgo.Session
|
||||||
@@ -39,8 +63,6 @@ func NewGenerator(outputDir string, s *discordgo.Session) *Generator {
|
|||||||
return &Generator{OutputDir: outputDir, Session: s}
|
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) {
|
func (g *Generator) Generate(ticket *db.Ticket) (string, error) {
|
||||||
messages, err := g.fetchAll(ticket.ChannelID)
|
messages, err := g.fetchAll(ticket.ChannelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,7 +125,6 @@ func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) {
|
|||||||
}
|
}
|
||||||
before = batch[len(batch)-1].ID
|
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 {
|
for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 {
|
||||||
all[i], all[j] = all[j], all[i]
|
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 {
|
func renderMessage(m *discordgo.Message) Message {
|
||||||
|
// Webhook messages use WebhookID — author is the impersonated user
|
||||||
name := m.Author.Username
|
name := m.Author.Username
|
||||||
avatar := m.Author.AvatarURL("64")
|
avatar := m.Author.AvatarURL("64")
|
||||||
initial := "?"
|
initial := "?"
|
||||||
@@ -118,9 +140,7 @@ func renderMessage(m *discordgo.Message) Message {
|
|||||||
initial = strings.ToUpper(string([]rune(name)[0]))
|
initial = strings.ToUpper(string([]rune(name)[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape all user content — XSS protection
|
|
||||||
content := template.HTMLEscapeString(m.Content)
|
content := template.HTMLEscapeString(m.Content)
|
||||||
// Preserve newlines as <br>
|
|
||||||
contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "<br>"))
|
contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "<br>"))
|
||||||
|
|
||||||
ts := ""
|
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{
|
return Message{
|
||||||
AuthorName: template.HTMLEscapeString(name),
|
AuthorName: template.HTMLEscapeString(name),
|
||||||
AuthorAvatar: avatar,
|
AuthorAvatar: avatar,
|
||||||
@@ -143,5 +168,76 @@ func renderMessage(m *discordgo.Message) Message {
|
|||||||
Content: contentHTML,
|
Content: contentHTML,
|
||||||
Timestamp: ts,
|
Timestamp: ts,
|
||||||
Attachments: attachments,
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 h1{color:#fff;font-size:1.1em;margin-bottom:4px;}
|
||||||
.header p{color:#b9bbbe;font-size:.85em;margin-top:2px;}
|
.header p{color:#b9bbbe;font-size:.85em;margin-top:2px;}
|
||||||
.messages{padding:8px 0;}
|
.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;}
|
.message:hover{background:#32353b;}
|
||||||
.avatar{width:40px;height:40px;border-radius:50%;flex-shrink:0;object-fit:cover;}
|
.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;}
|
.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;}
|
.msg-header{display:flex;align-items:baseline;gap:8px;margin-bottom:2px;}
|
||||||
.author{font-weight:700;color:#fff;font-size:.9em;}
|
.author{font-weight:700;color:#fff;font-size:.9em;}
|
||||||
.timestamp{color:#72767d;font-size:.75em;}
|
.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{margin-top:4px;}
|
||||||
.attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;}
|
.attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;}
|
||||||
.attachment a:hover{text-decoration:underline;}
|
.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;}
|
.footer{text-align:center;color:#72767d;font-size:.8em;padding:12px;border-top:1px solid #202225;margin-top:8px;}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -48,10 +76,54 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo
|
|||||||
{{range .Attachments}}
|
{{range .Attachments}}
|
||||||
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
|
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{range .Embeds}}
|
||||||
|
{{template "embed" .}}
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
|
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
|
||||||
</body>
|
</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}}`
|
||||||
|
|||||||
Reference in New Issue
Block a user