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 } type Attachment struct { Name string URL string } type Generator struct { OutputDir string Session *discordgo.Session } 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 { 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 } // 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] } return all, nil } func renderMessage(m *discordgo.Message) Message { name := m.Author.Username avatar := m.Author.AvatarURL("64") initial := "?" if len(name) > 0 { 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 := "" 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, }) } return Message{ AuthorName: template.HTMLEscapeString(name), AuthorAvatar: avatar, AuthorInitial: initial, Content: contentHTML, Timestamp: ts, Attachments: attachments, } }