package transcript import ( "bytes" "fmt" "html/template" "log/slog" "os" "path/filepath" "strings" "time" "github.com/bwmarrin/discordgo" "github.com/leolionad58/ticketbot/internal/db" ) const maxUploadSize = 25 * 1024 * 1024 // 25MB type Message struct { AuthorName string AuthorAvatar string AuthorInitial string Content template.HTML Timestamp string Attachments []Attachment Embeds []RenderedEmbed } type Attachment struct { Name 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 { OutputDir string Session *discordgo.Session } func NewGenerator(outputDir string, s *discordgo.Session) *Generator { return &Generator{OutputDir: outputDir, Session: s} } func (g *Generator) Generate(ticket *db.Ticket) (string, error) { messages, err := g.fetchAll(ticket.ChannelID) if err != nil { return "", fmt.Errorf("fetch messages: %w", err) } rendered := make([]Message, 0, len(messages)) for _, m := range messages { rendered = append(rendered, renderMessage(m)) } tmpl, err := template.New("transcript").Parse(HTMLTemplate) if err != nil { return "", fmt.Errorf("parse template: %w", err) } data := struct { Ticket *db.Ticket Messages []Message Generated string }{ Ticket: ticket, Messages: rendered, Generated: time.Now().UTC().Format("02/01/2006 à 15:04 UTC"), } var buf bytes.Buffer if err := tmpl.Execute(&buf, data); err != nil { return "", fmt.Errorf("render template: %w", err) } if err := os.MkdirAll(g.OutputDir, 0755); err != nil { return "", fmt.Errorf("create output dir: %w", err) } outPath := filepath.Join(g.OutputDir, fmt.Sprintf("%s-%04d-%s.html", ticket.Type, ticket.TicketNumber, ticket.ChannelID)) if buf.Len() > maxUploadSize { slog.Warn("transcript exceeds 25MB, saving locally only", "path", outPath, "size_mb", buf.Len()/1024/1024) } if err := os.WriteFile(outPath, buf.Bytes(), 0644); err != nil { return "", fmt.Errorf("write transcript: %w", err) } return outPath, nil } func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) { var all []*discordgo.Message var before string for { batch, err := g.Session.ChannelMessages(channelID, 100, before, "", "") if err != nil { return nil, err } all = append(all, batch...) if len(batch) < 100 { break } before = batch[len(batch)-1].ID } for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { all[i], all[j] = all[j], all[i] } return all, nil } 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 := "?" if len(name) > 0 { initial = strings.ToUpper(string([]rune(name)[0])) } content := template.HTMLEscapeString(m.Content) contentHTML := template.HTML(strings.ReplaceAll(content, "\n", "
")) ts := "" if !m.Timestamp.IsZero() { ts = m.Timestamp.UTC().Format("02/01/2006 15:04:05") } var attachments []Attachment for _, a := range m.Attachments { attachments = append(attachments, Attachment{ Name: template.HTMLEscapeString(a.Filename), URL: a.URL, }) } var embeds []RenderedEmbed for _, e := range m.Embeds { embeds = append(embeds, renderEmbed(e)) } return Message{ AuthorName: template.HTMLEscapeString(name), AuthorAvatar: avatar, AuthorInitial: initial, 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 }