v0.2
This commit is contained in:
@@ -23,6 +23,7 @@ type Message struct {
|
||||
Content template.HTML
|
||||
Timestamp string
|
||||
Attachments []Attachment
|
||||
Embeds []RenderedEmbed
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -30,6 +31,29 @@ type Attachment struct {
|
||||
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
|
||||
@@ -39,8 +63,6 @@ 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 {
|
||||
@@ -103,7 +125,6 @@ func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) {
|
||||
}
|
||||
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]
|
||||
}
|
||||
@@ -111,6 +132,7 @@ func (g *Generator) fetchAll(channelID string) ([]*discordgo.Message, error) {
|
||||
}
|
||||
|
||||
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 := "?"
|
||||
@@ -118,9 +140,7 @@ func renderMessage(m *discordgo.Message) Message {
|
||||
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 := ""
|
||||
@@ -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{
|
||||
AuthorName: template.HTMLEscapeString(name),
|
||||
AuthorAvatar: avatar,
|
||||
@@ -143,5 +168,76 @@ func renderMessage(m *discordgo.Message) Message {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 p{color:#b9bbbe;font-size:.85em;margin-top:2px;}
|
||||
.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;}
|
||||
.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;}
|
||||
@@ -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;}
|
||||
.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;}
|
||||
.text{color:#dcddde;word-break:break-word;}
|
||||
.attachment{margin-top:4px;}
|
||||
.attachment a{color:#00b0f4;text-decoration:none;font-size:.85em;}
|
||||
.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;}
|
||||
</style>
|
||||
</head>
|
||||
@@ -48,10 +76,54 @@ body{background:#36393f;color:#dcddde;font-family:'Segoe UI',Arial,sans-serif;fo
|
||||
{{range .Attachments}}
|
||||
<div class="attachment">📎 <a href="{{.URL}}" target="_blank" rel="noopener">{{.Name}}</a></div>
|
||||
{{end}}
|
||||
{{range .Embeds}}
|
||||
{{template "embed" .}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="footer">Bot Ticket — Transcript généré automatiquement</div>
|
||||
</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