Files
bunny-bot/internal/elog/render.go
eleksir 0c34b6e781 Global code ovehaul - remove CAS and other, add 15 minute greet check
- Remove CAS, as it no longer works for us
- Remove other checks
- Implement 15 minute greet timeout check
- Fix chat logos
- Ditch thirdparty logger, use stdlib log facility wrapper for logging
2026-10-20 12:20:16 +03:00

249 lines
6.0 KiB
Go

package elog
import (
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
)
// renderLine assembles one log line:
//
// 21:33:07.123 in chat -1001 (title=…) user 42 (first_name=…) says …
//
// Empty field groups are dropped entirely rather than printed as empty parens.
func renderLine(chat echotron.Chat, user *echotron.User, body string) string {
var b strings.Builder
b.WriteString(nowUTC().Format(stampLayout))
b.WriteString(" in chat ")
b.WriteString(itoa(chat.ID))
if desc := describeChat(&chat); desc != "" {
b.WriteString(" (")
b.WriteString(desc)
b.WriteString(")")
}
if user != nil {
b.WriteString(" user ")
b.WriteString(itoa(user.ID))
if desc := describeUser(user); desc != "" {
b.WriteString(" (")
b.WriteString(desc)
b.WriteString(")")
}
}
if body != "" {
b.WriteString(" ")
b.WriteString(body)
}
return b.String()
}
// describeChat renders a chat's identity fields. Type is deliberately omitted:
// the user-specified format has no slot for it, and which of title / first_name
// is present already distinguishes group from private chat.
func describeChat(c *echotron.Chat) string {
if c == nil {
return ""
}
return joinFields([]string{
field("title", c.Title),
field("username", atUsername(c.Username)),
field("first_name", c.FirstName),
field("last_name", c.LastName),
})
}
// describeUser renders a user's identity fields.
func describeUser(u *echotron.User) string {
if u == nil {
return ""
}
return joinFields([]string{
field("first_name", u.FirstName),
field("last_name", u.LastName),
field("username", atUsername(u.Username)),
})
}
// describePayload renders what a message carries: media first, then a caption,
// because "post photo (…) caption" reads better than the reverse.
func describePayload(m *echotron.Message) string {
kind, details := describeMedia(m)
var b strings.Builder
if kind != "" {
b.WriteString("post ")
b.WriteString(kind)
if details != "" {
b.WriteString(" (")
b.WriteString(details)
b.WriteString(")")
}
}
// A caption accompanies the media rather than replacing it: "post photo (…)"
// alone loses what the user actually said.
if m.Text != "" {
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString("says ")
b.WriteString(m.Text)
}
return b.String()
}
// describeMedia returns the media kind and its key info. The first media field
// set on the message wins, in the order listed in the SPEC.
func describeMedia(m *echotron.Message) (string, string) {
switch {
case len(m.Photo) > 0:
// The array is ordered smallest first, so the last entry is the largest.
best := m.Photo[len(m.Photo)-1]
return "photo", joinFields([]string{
dims(best.Width, best.Height),
sizeField(int64(best.FileSize)),
})
case m.Video != nil:
return "video", joinFields([]string{
field("file_name", m.Video.FileName),
field("mime", m.Video.MimeType),
dims(m.Video.Width, m.Video.Height),
field("duration", seconds(m.Video.Duration)),
sizeField(m.Video.FileSize),
})
case m.Animation != nil:
return "animation", joinFields([]string{
field("file_name", m.Animation.FileName),
field("mime", m.Animation.MimeType),
dims(m.Animation.Width, m.Animation.Height),
field("duration", seconds(m.Animation.Duration)),
sizeField(m.Animation.FileSize),
})
case m.Audio != nil:
return "audio", joinFields([]string{
field("title", m.Audio.Title),
field("file_name", m.Audio.FileName),
field("mime", m.Audio.MimeType),
field("duration", seconds(m.Audio.Duration)),
sizeField(m.Audio.FileSize),
})
case m.Voice != nil:
return "voice", joinFields([]string{
field("mime", m.Voice.MimeType),
field("duration", seconds(m.Voice.Duration)),
sizeField(m.Voice.FileSize),
})
case m.VideoNote != nil:
return "video_note", joinFields([]string{
dims(m.VideoNote.Length, m.VideoNote.Length),
field("duration", seconds(m.VideoNote.Duration)),
sizeField(int64(m.VideoNote.FileSize)),
})
case m.Sticker != nil:
return "sticker", joinFields([]string{
field("emoji", m.Sticker.Emoji),
field("set", m.Sticker.SetName),
dims(m.Sticker.Width, m.Sticker.Height),
field("video", itoaBool(m.Sticker.IsVideo)),
field("animated", itoaBool(m.Sticker.IsAnimated)),
})
case m.Document != nil:
return "document", joinFields([]string{
field("file_name", m.Document.FileName),
field("mime", m.Document.MimeType),
sizeField(m.Document.FileSize),
})
case m.Contact != nil:
return "contact", joinFields([]string{
field("phone", m.Contact.PhoneNumber),
field("first_name", m.Contact.FirstName),
field("last_name", m.Contact.LastName),
field("user_id", itoaInt(m.Contact.UserID)),
})
case m.Location != nil:
return "location", joinFields([]string{
field("lat", itoaFloat(m.Location.Latitude)),
field("lon", itoaFloat(m.Location.Longitude)),
})
case m.Venue != nil:
parts := []string{
field("title", m.Venue.Title),
field("address", m.Venue.Address),
}
if m.Venue.Location != nil {
parts = append(parts,
field("lat", itoaFloat(m.Venue.Location.Latitude)),
field("lon", itoaFloat(m.Venue.Location.Longitude)))
}
return "venue", joinFields(parts)
case m.Dice != nil:
return "dice", joinFields([]string{
field("emoji", m.Dice.Emoji),
field("value", itoaInt(m.Dice.Value)),
})
}
return "", ""
}
// dims renders "WxH", or empty when the dimensions are unknown.
func dims(w int, h int) string {
if w <= 0 || h <= 0 {
return ""
}
return itoaInt(w) + "x" + itoaInt(h)
}
// itoaBool renders a boolean for an inline field.
func itoaBool(b bool) string {
if b {
return "true"
}
return "false"
}
// describeReference renders "user 42 (first_name=Ann)", used for the newcomer in
// a service message and for the member in a status change.
func describeReference(u *echotron.User) string {
if u == nil {
return "user unknown"
}
if desc := describeUser(u); desc != "" {
return fmt.Sprintf("user %d (%s)", u.ID, desc)
}
return fmt.Sprintf("user %d", u.ID)
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */