save first version
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
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 <br>
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
return Message{
|
||||
AuthorName: template.HTMLEscapeString(name),
|
||||
AuthorAvatar: avatar,
|
||||
AuthorInitial: initial,
|
||||
Content: contentHTML,
|
||||
Timestamp: ts,
|
||||
Attachments: attachments,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package transcript
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/leolionad58/ticketbot/internal/db"
|
||||
)
|
||||
|
||||
func makeTicket() *db.Ticket {
|
||||
return &db.Ticket{
|
||||
ID: 1,
|
||||
TicketNumber: 1,
|
||||
Type: "support",
|
||||
Panel: "support_panel",
|
||||
ChannelID: "ch123456",
|
||||
UserID: "user123",
|
||||
OpenedAt: time.Now(),
|
||||
Status: "open",
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRender(t *testing.T) {
|
||||
ticket := makeTicket()
|
||||
tmpl, err := template.New("t").Parse(HTMLTemplate)
|
||||
if err != nil {
|
||||
t.Fatalf("parse template: %v", err)
|
||||
}
|
||||
|
||||
messages := []Message{
|
||||
{
|
||||
AuthorName: "TestUser",
|
||||
AuthorInitial: "T",
|
||||
Content: template.HTML("Hello world"),
|
||||
Timestamp: "01/01/2024 12:00:00",
|
||||
},
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Ticket *db.Ticket
|
||||
Messages []Message
|
||||
Generated string
|
||||
}{Ticket: ticket, Messages: messages, Generated: "test"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
t.Fatalf("execute template: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
outPath := filepath.Join(dir, "support-0001-ch123456.html")
|
||||
os.WriteFile(outPath, buf.Bytes(), 0644)
|
||||
|
||||
content, _ := os.ReadFile(outPath)
|
||||
if !bytes.Contains(content, []byte("support-0001")) {
|
||||
t.Error("missing ticket reference")
|
||||
}
|
||||
if !bytes.Contains(content, []byte("TestUser")) {
|
||||
t.Error("missing author name")
|
||||
}
|
||||
if !bytes.Contains(content, []byte("Hello world")) {
|
||||
t.Error("missing message content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestXSSEscaping(t *testing.T) {
|
||||
// renderMessage must escape all user-controlled content
|
||||
// We test the escaping logic by simulating a message with XSS payload
|
||||
msg := &struct {
|
||||
Content string
|
||||
Username string
|
||||
}{
|
||||
Content: "<script>alert('xss')</script>",
|
||||
Username: "<b>user</b>",
|
||||
}
|
||||
|
||||
escapedContent := template.HTMLEscapeString(msg.Content)
|
||||
escapedName := template.HTMLEscapeString(msg.Username)
|
||||
|
||||
if escapedContent == msg.Content {
|
||||
t.Error("content should be escaped")
|
||||
}
|
||||
if escapedName == msg.Username {
|
||||
t.Error("username should be escaped")
|
||||
}
|
||||
if bytes.Contains([]byte(escapedContent), []byte("<script>")) {
|
||||
t.Error("XSS payload not escaped in content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateNoXSS(t *testing.T) {
|
||||
ticket := makeTicket()
|
||||
tmpl, _ := template.New("t").Parse(HTMLTemplate)
|
||||
|
||||
messages := []Message{
|
||||
{
|
||||
AuthorName: "<evil>",
|
||||
AuthorInitial: "E",
|
||||
Content: template.HTML(template.HTMLEscapeString("<script>alert(1)</script>")),
|
||||
Timestamp: "01/01/2024 12:00:00",
|
||||
},
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Ticket *db.Ticket
|
||||
Messages []Message
|
||||
Generated string
|
||||
}{Ticket: ticket, Messages: messages, Generated: "test"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
tmpl.Execute(&buf, data)
|
||||
|
||||
if bytes.Contains(buf.Bytes(), []byte("<script>alert")) {
|
||||
t.Error("XSS: raw script tag in output")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package transcript
|
||||
|
||||
const HTMLTemplate = `<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Transcript {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;font-size:14px;line-height:1.5;}
|
||||
.header{background:#2f3136;padding:16px 24px;border-bottom:1px solid #202225;}
|
||||
.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: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;}
|
||||
.msg-body{flex:1;min-width:0;}
|
||||
.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;}
|
||||
.attachment{margin-top:4px;}
|
||||
.attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;}
|
||||
.attachment a:hover{text-decoration:underline;}
|
||||
.footer{text-align:center;color:#72767d;font-size:.8em;padding:12px;border-top:1px solid #202225;margin-top:8px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Transcript — {{.Ticket.Type}}-{{printf "%04d" .Ticket.TicketNumber}}</h1>
|
||||
<p>Utilisateur : {{.Ticket.UserID}} | Ouvert le : {{.Ticket.OpenedAt.Format "02/01/2006 à 15:04 UTC"}}</p>
|
||||
<p>Généré le {{.Generated}} | {{len .Messages}} messages</p>
|
||||
</div>
|
||||
<div class="messages">
|
||||
{{range .Messages}}
|
||||
<div class="message">
|
||||
{{if .AuthorAvatar}}<img class="avatar" src="{{.AuthorAvatar}}" alt="" onerror="this.style.display='none'">
|
||||
{{else}}<div class="avatar-placeholder">{{.AuthorInitial}}</div>{{end}}
|
||||
<div class="msg-body">
|
||||
<div class="msg-header">
|
||||
<span class="author">{{.AuthorName}}</span>
|
||||
<span class="timestamp">{{.Timestamp}}</span>
|
||||
</div>
|
||||
{{if .Content}}<div class="text">{{.Content}}</div>{{end}}
|
||||
{{range .Attachments}}
|
||||
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
|
||||
</body>
|
||||
</html>`
|
||||
Reference in New Issue
Block a user