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
This commit is contained in:
eleksir
2026-10-20 12:20:16 +03:00
parent 2f467af6cf
commit 0c34b6e781
1597 changed files with 6440 additions and 648118 deletions

14
internal/elog/clock.go Normal file
View File

@@ -0,0 +1,14 @@
package elog
import "time"
// nowUTC is the single clock every path and timestamp derives from.
//
// It is not time.Now at each call site: a log line stamped in local time beside a
// directory named for UTC is a bug report waiting to happen, and a file that rolls
// over at midnight local time while its lines are stamped UTC is worse.
func nowUTC() time.Time {
return time.Now().UTC()
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

78
internal/elog/format.go Normal file
View File

@@ -0,0 +1,78 @@
package elog
// The value formatters: small conversions shared by the write path (which builds
// path components) and the renderer (which builds inline fields). They are shared
// by two topics, so they get a file of their own rather than living in one of them
// and making the other look in the wrong place.
import (
"strconv"
"strings"
)
// field renders "key=value", or empty when the value is empty. Empty fields are
// omitted: a bare "username=@" is worse than nothing.
//
// It lives here rather than in render.go because sizeField, a formatter, is one of
// its callers — and a helper two topics use belongs to neither.
func field(key string, value string) string {
if value == "" {
return ""
}
return key + "=" + value
}
// joinFields drops empty fragments and joins the rest with single spaces.
func joinFields(parts []string) string {
kept := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
kept = append(kept, p)
}
}
return strings.Join(kept, " ")
}
// itoa renders an int64 for a path component or an inline field.
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
// itoaInt renders an int for a rendered field.
func itoaInt(v int) string {
return strconv.Itoa(v)
}
// itoaFloat renders a float for a rendered field.
func itoaFloat(v float64) string {
return strconv.FormatFloat(v, 'g', -1, 64)
}
// atUsername renders a username with the conventional @ prefix, or empty.
func atUsername(name string) string {
if name == "" {
return ""
}
return "@" + name
}
// seconds renders a duration in seconds with its unit.
func seconds(n int) string {
return itoaInt(n) + "s"
}
// sizeField renders a byte count, omitted when unknown. A size of 0 means
// Telegram did not tell us, not that the file was empty.
func sizeField(n int64) string {
if n <= 0 {
return ""
}
return field("size", itoa(n))
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

59
internal/elog/globals.go Normal file
View File

@@ -0,0 +1,59 @@
package elog
import (
"errors"
"sync"
"time"
)
const (
// chatLockIdleTimeout is how long a chat's lock entry may sit unused before it
// is evicted. Without it the registry grows by one entry per chat ever seen.
chatLockIdleTimeout = time.Hour
// stampLayout is the per-line timestamp. GMT+0 with millisecond precision.
stampLayout = "15:04:05.000"
// logFilePerm is the mode for a newly created chat log file.
logFilePerm = 0o644
// logDirPerm is the mode for the chat, year and month directories.
//
// It is deliberately not logFilePerm. A directory needs the execute bit to
// be traversable, and 0o644 has none, so creating the tree with the file mode
// makes every subsequent open inside it fail with EACCES rather than ENOENT
// — a failure reportFailure would swallow, leaving chat logging to silently
// produce no files at all.
//
// The two live in one block on purpose: they are a pair that must never be
// confused with each other, and keeping them adjacent is what makes confusing
// them hard. They are here, in globals.go, because that is where every
// package-level const belongs — the pair argument was weighed against rule 2
// and rule 2 won, once, on the record.
logDirPerm = 0o755
)
var (
// errNotDirectory marks a chat_logs path that exists but is a regular file.
errNotDirectory = errors.New("chat_logs path is not a directory")
// root is the configured chat-logs directory. Empty means chat logging is
// disabled and every Log* call is a no-op.
root string
// failing records that a write error has been reported and not yet cleared,
// so a persistent failure (a full disk) logs once instead of once per event.
failing bool
// stateMu guards root and failing.
stateMu sync.RWMutex
// registryMu guards locks. It is never held while a chat lock is acquired,
// or the whole logger would serialise.
registryMu sync.Mutex
// locks is the per-chat lock registry.
locks = map[int64]*chatLock{}
)
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

46
internal/elog/init.go Normal file
View File

@@ -0,0 +1,46 @@
package elog
import (
"fmt"
"os"
)
// Init switches chat logging on.
//
// An empty root disables it, and every Log* call becomes a no-op — chat logging
// is optional. A non-empty root must already exist: a path that does not is a
// typo, and silently logging nowhere is the worst possible response to one, so
// this returns an error and moon.ParseConfig treats it as fatal.
func Init(rootDir string) error {
if rootDir == "" {
setRoot("")
return nil
}
info, err := os.Stat(rootDir)
if err != nil {
return fmt.Errorf("chat_logs directory %s is not usable: %w", rootDir, err)
}
if !info.IsDir() {
return fmt.Errorf("%w: %s", errNotDirectory, rootDir)
}
setRoot(rootDir)
return nil
}
// Close disables chat logging.
//
// Nothing is held open between lines, so there are no handles to close; this
// exists so shutdown has a symmetric counterpart to Init and so a misbehaving
// caller can stop writing.
func Close() error {
setRoot("")
return nil
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

127
internal/elog/message.go Normal file
View File

@@ -0,0 +1,127 @@
package elog
import (
"github.com/NicoNex/echotron/v3"
)
// LogMessage records a chat message.
//
// It handles both ordinary messages — text, media, captions, replies — and the
// service messages Telegram delivers as a Message with a field set instead of
// text: joins, leaves, title changes, pins, migrations, forum topics. A message
// with nothing describable is skipped rather than logged as noise.
func LogMessage(msg *echotron.Message) {
logMessage(msg, "")
}
// LogEditedMessage records an edited message. The line is marked "edited" so a
// reader can tell it apart from the original.
func LogEditedMessage(msg *echotron.Message) {
logMessage(msg, "edited ")
}
// logMessage is the shared body of LogMessage and LogEditedMessage.
func logMessage(msg *echotron.Message, prefix string) {
if _, on := chatLogRoot(); !on || msg == nil {
return
}
for _, body := range describeMessage(msg) {
appendLine(msg.Chat.ID, prefix+renderLine(msg.Chat, msg.From, body))
}
}
// describeMessage returns one rendered body per line to write. It returns more
// than one only for a multi-member join, where one line per newcomer keeps each
// fact greppable instead of cramming every newcomer onto one line.
func describeMessage(msg *echotron.Message) []string {
if bodies := describeService(msg); len(bodies) > 0 {
return bodies
}
body := describePayload(msg)
if body == "" {
return nil
}
if msg.ReplyToMessage != nil {
body += " reply_to=" + itoaInt(msg.ReplyToMessage.ID)
}
return []string{body}
}
// describeService renders the service-message fields, newest-first, and returns
// nil when the message is not a service message.
func describeService(msg *echotron.Message) []string {
var bodies []string
// A join with N newcomers writes N lines.
for _, u := range msg.NewChatMembers {
if u == nil {
continue
}
bodies = append(bodies, describeReference(u)+" joined the chat")
}
if msg.LeftChatMember != nil {
bodies = append(bodies, describeReference(msg.LeftChatMember)+" left the chat")
}
if msg.NewChatTitle != "" {
bodies = append(bodies, "chat title changed to "+msg.NewChatTitle)
}
if len(msg.NewChatPhoto) > 0 {
bodies = append(bodies, "chat photo changed")
}
if msg.PinnedMessage != nil {
bodies = append(bodies, "pinned message "+itoaInt(msg.PinnedMessage.ID))
}
if msg.GroupChatCreated {
bodies = append(bodies, "group chat created")
}
if msg.MigrateToChatID != 0 {
bodies = append(bodies, "chat migrated to "+itoa(int64(msg.MigrateToChatID)))
}
if msg.MigrateFromChatID != 0 {
bodies = append(bodies, "chat migrated from "+itoa(int64(msg.MigrateFromChatID)))
}
if msg.ConnectedWebsite != "" {
bodies = append(bodies, "connected website "+msg.ConnectedWebsite)
}
if msg.ForumTopicCreated != nil {
bodies = append(bodies, "forum topic "+msg.ForumTopicCreated.Name+" created")
}
if msg.ForumTopicClosed != nil {
bodies = append(bodies, "forum topic closed")
}
if msg.ForumTopicReopened != nil {
bodies = append(bodies, "forum topic reopened")
}
if msg.GeneralForumTopicHidden != nil {
bodies = append(bodies, "general forum topic hidden")
}
if msg.GeneralForumTopicUnhidden != nil {
bodies = append(bodies, "general forum topic unhidden")
}
if msg.VideoChatScheduled != nil {
bodies = append(bodies, "video chat scheduled")
}
return bodies
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

248
internal/elog/render.go Normal file
View File

@@ -0,0 +1,248 @@
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: */

View File

@@ -0,0 +1,24 @@
package elog
import (
"github.com/NicoNex/echotron/v3"
)
// LogGreetingRetracted records that we deleted a greeting of our own — one the
// addressed member never answered, because they were banned for the silence.
//
// The log is append-only, so the question's original line stays where it was and
// this line says what became of it. A reader must be able to tell that the room
// asked a question and later removed it, rather than finding a line that
// describes a message which no longer exists.
func LogGreetingRetracted(chat *echotron.Chat, user *echotron.User, messageID int) {
if _, on := chatLogRoot(); !on || chat == nil {
return
}
body := "greeting " + itoaInt(messageID) + " retracted, member banned without answering"
appendLine(chat.ID, renderLine(*chat, user, body))
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

44
internal/elog/root.go Normal file
View File

@@ -0,0 +1,44 @@
package elog
// The chat-logs root: where the tree lives, and whether logging is on at all.
//
// The state itself is in globals.go with the rest of the package-level variables;
// this file holds only the accessors, because "is it on and where" is one question
// with one answer and must not be asked in two pieces.
// ChatLoggingEnabled reports whether chat logging is currently switched on.
//
// Exported so another package can assert the post-shutdown state instead of
// inferring it from the absence of files.
func ChatLoggingEnabled() (string, bool) {
return chatLogRoot()
}
// chatLogRoot returns the configured root and reports whether chat logging is
// on, in a *single* read.
//
// The two must not be asked separately: a Close landing between an enabled()
// check and a rootDir() read would leave the caller with an empty root, and
// filepath.Join("", ...) silently yields a path relative to the working
// directory — chat logs would be written wherever the supervisor happened to
// start the process.
func chatLogRoot() (string, bool) {
stateMu.RLock()
defer stateMu.RUnlock()
return root, root != ""
}
// setRoot switches chat logging on or off.
func setRoot(dir string) {
stateMu.Lock()
defer stateMu.Unlock()
root = dir
if dir == "" {
failing = false
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

63
internal/elog/status.go Normal file
View File

@@ -0,0 +1,63 @@
package elog
import (
"strings"
"github.com/NicoNex/echotron/v3"
)
// LogUserStatus records a change to a chat member's status — join, leave, ban,
// promote, demote, restrict — as carried by Update.ChatMember.
func LogUserStatus(ev *echotron.ChatMemberUpdated) {
if _, on := chatLogRoot(); !on || ev == nil {
return
}
who := ev.NewChatMember.User
if who == nil {
who = ev.OldChatMember.User
}
var b strings.Builder
// The subject is already rendered by renderLine's "user <id> (<desc>)"
// prefix, so the body starts at the transition itself.
b.WriteString("status ")
b.WriteString(orUnknown(ev.OldChatMember.Status))
b.WriteString(" -> ")
b.WriteString(orUnknown(ev.NewChatMember.Status))
if ev.NewChatMember.CustomTitle != "" {
b.WriteString(" custom_title=")
b.WriteString(ev.NewChatMember.CustomTitle)
}
b.WriteString(" by ")
b.WriteString(describeUserRef(&ev.From))
appendLine(ev.Chat.ID, renderLine(ev.Chat, who, b.String()))
}
// describeUserRef renders a value-type User reference.
func describeUserRef(u *echotron.User) string {
if u == nil {
return "user unknown"
}
if desc := describeUser(u); desc != "" {
return "user " + itoa(u.ID) + " (" + desc + ")"
}
return "user " + itoa(u.ID)
}
// orUnknown renders a status, or a placeholder when Telegram sent none.
func orUnknown(status string) string {
if status == "" {
return "unknown"
}
return status
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

21
internal/elog/types.go Normal file
View File

@@ -0,0 +1,21 @@
package elog
import (
"sync"
"time"
)
// chatLock serialises writes for one chat so that two goroutines cannot splice
// their lines into the same file.
type chatLock struct {
mu sync.Mutex
// refs counts goroutines holding or waiting for this entry. It is only
// ever read or written while registryMu is held, which is what makes it a
// usable proof of "nobody is holding this" during eviction.
refs int
lastUsed time.Time
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */

148
internal/elog/write.go Normal file
View File

@@ -0,0 +1,148 @@
package elog
import (
"os"
"path/filepath"
"time"
"bunny-bot/internal/mylog"
)
// acquire returns the chat's lock with this goroutine holding a reference.
func acquire(chatID int64) *chatLock {
registryMu.Lock()
l, ok := locks[chatID]
if !ok {
l = &chatLock{}
locks[chatID] = l
}
l.refs++
l.lastUsed = time.Now()
evictIdleLocked()
registryMu.Unlock()
l.mu.Lock()
return l
}
// release drops this goroutine's reference and unlocks the chat.
func release(l *chatLock) {
l.mu.Unlock()
registryMu.Lock()
l.refs--
l.lastUsed = time.Now()
registryMu.Unlock()
}
// evictIdleLocked drops registry entries nobody is holding and that have been
// idle for longer than chatLockIdleTimeout.
//
// Safety rests on refs: it is only ever changed while registryMu is held, so
// observing refs == 0 here is a proof that no goroutine holds the entry, not a
// guess. Evicting a held entry would let a second writer take a *different*
// mutex for the same chat, which is precisely the interleaving the lock exists
// to prevent. The caller must hold registryMu.
func evictIdleLocked() {
cutoff := time.Now().Add(-chatLockIdleTimeout)
for id, l := range locks {
if l.refs == 0 && l.lastUsed.Before(cutoff) {
delete(locks, id)
}
}
}
// appendLine writes one line to the chat's log for the day containing now.
//
// The whole protocol is set_mutex -> MkdirAll -> open|create -> write -> close,
// with unset_mutex deferred. Nothing is held open between lines.
func appendLine(chatID int64, line string) {
// One read of both the root and whether logging is on, so a Close racing
// this call can never leave us with an empty root and a relative path.
dir, on := chatLogRoot()
if !on {
return
}
lock := acquire(chatID)
defer release(lock)
// One clock for both the path and the line: using Message.Date for one and
// time.Now() for the other would let a line carry a date its own path
// disagrees with.
now := nowUTC()
dayDir := filepath.Join(dir, itoa(chatID), now.Format("2006"), now.Format("01"))
path := filepath.Join(dayDir, now.Format("02")+".txt")
// MkdirAll creates every missing parent, so this covers the chat directory
// and the year too. No need to pre-create anything.
if err := os.MkdirAll(dayDir, logDirPerm); err != nil {
reportFailure(err)
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, logFilePerm)
if err != nil {
reportFailure(err)
return
}
_, writeErr := f.WriteString(line + "\n")
closeErr := f.Close()
if writeErr != nil {
reportFailure(writeErr)
return
}
if closeErr != nil {
reportFailure(closeErr)
return
}
reportRecovered()
}
// reportFailure sends a write failure to the main log, but only the first one
// until a write succeeds again. A full disk would otherwise produce one error
// per chat event and drown the very log meant to explain it.
func reportFailure(err error) {
stateMu.Lock()
already := failing
failing = true
stateMu.Unlock()
if already {
return
}
mylog.Errorf("CHAT LOG WRITE FAILED, further failures suppressed until a write succeeds: %s", err)
}
// reportRecovered clears the suppression after a successful write.
func reportRecovered() {
stateMu.Lock()
was := failing
failing = false
stateMu.Unlock()
if was {
mylog.Info("Chat logging recovered")
}
}
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */