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:
14
internal/elog/clock.go
Normal file
14
internal/elog/clock.go
Normal 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
78
internal/elog/format.go
Normal 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
59
internal/elog/globals.go
Normal 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
46
internal/elog/init.go
Normal 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
127
internal/elog/message.go
Normal 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
248
internal/elog/render.go
Normal 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: */
|
||||
24
internal/elog/retraction.go
Normal file
24
internal/elog/retraction.go
Normal 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
44
internal/elog/root.go
Normal 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
63
internal/elog/status.go
Normal 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
21
internal/elog/types.go
Normal 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
148
internal/elog/write.go
Normal 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: */
|
||||
Reference in New Issue
Block a user