Files
2026-05-02 03:23:51 +02:00

244 lines
5.4 KiB
Go

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", "<br>"))
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", "<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
}