package main import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "net/http" "strconv" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" ) // trimFloat formats a vitae value without a trailing ".0" for whole numbers. func trimFloat(f float64) string { if f == float64(int64(f)) { return strconv.FormatInt(int64(f), 10) } return strconv.FormatFloat(f, 'f', -1, 64) } // aclogPoster posts death + idle alerts to the #aclog Discord webhook, porting // main.py's _send_discord_aclog / death detection / _idle_detection_loop. nil // when DISCORD_ACLOG_WEBHOOK is unset (or in shadow mode). type aclogPoster struct { webhook string client *http.Client log *slog.Logger mu sync.Mutex deathAlerted map[string]time.Time // char -> last death alert (max 1 / 5min) idleSince map[string]time.Time // char -> first detected idle idleAlerted map[string]bool // char -> already alerted this idle period } func newACLogPoster(webhook string, log *slog.Logger) *aclogPoster { return &aclogPoster{ webhook: webhook, client: &http.Client{Timeout: 5 * time.Second}, log: log, deathAlerted: map[string]time.Time{}, idleSince: map[string]time.Time{}, idleAlerted: map[string]bool{}, } } func (a *aclogPoster) post(message string) { if a == nil || a.webhook == "" { return } body, _ := json.Marshal(map[string]any{"content": message}) resp, err := a.client.Post(a.webhook, "application/json", bytes.NewReader(body)) if err != nil { a.log.Debug("discord webhook failed", "err", err) return } drain(resp) } // maybeDeath fires a death alert when vitae crosses 0 -> >0, capped at 1 per // 5 minutes per character (main.py:3419). func (a *aclogPoster) maybeDeath(name string, vitae float64) { if a == nil || a.webhook == "" { return } a.mu.Lock() last, ok := a.deathAlerted[name] if ok && time.Since(last) <= 5*time.Minute { a.mu.Unlock() return } a.deathAlerted[name] = time.Now() a.mu.Unlock() go a.post(fmt.Sprintf("☠️ **%s** died! (vitae: %s%%)", name, trimFloat(vitae))) } // runIdleLoop polls online players every 60s and alerts on idle (main.py:2694). func (a *aclogPoster) runIdleLoop(ctx context.Context, pool *pgxpool.Pool) { if a == nil || a.webhook == "" { return } select { case <-time.After(30 * time.Second): // let telemetry arrive first case <-ctx.Done(): return } t := time.NewTicker(60 * time.Second) defer t.Stop() for { a.checkIdleOnce(ctx, pool) select { case <-t.C: case <-ctx.Done(): return } } } func (a *aclogPoster) checkIdleOnce(ctx context.Context, pool *pgxpool.Pool) { qctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() rows, err := pool.Query(qctx, ` SELECT DISTINCT ON (character_name) character_name, COALESCE(vt_state,''), COALESCE(kills_per_hour, 0) FROM telemetry_events WHERE COALESCE(received_at, timestamp) > now() - interval '30 seconds' ORDER BY character_name, timestamp DESC`) if err != nil { a.log.Debug("idle query failed", "err", err) return } defer rows.Close() now := time.Now() a.mu.Lock() defer a.mu.Unlock() for rows.Next() { var name, vtState string var kph float64 if rows.Scan(&name, &vtState, &kph) != nil { continue } s := strings.ToLower(vtState) kphi := int(kph) isIdle := s == "default" || s == "idle" || s == "" || ((s == "combat" || s == "hunt") && kphi == 0) if isIdle { if _, seen := a.idleSince[name]; !seen { a.idleSince[name] = now } else if !a.idleAlerted[name] && now.Sub(a.idleSince[name]) >= 5*time.Minute { a.idleAlerted[name] = true idleMins := int(now.Sub(a.idleSince[name]).Minutes()) stateText := vtState if stateText == "" { stateText = "idle" } go a.post(fmt.Sprintf("⚠️ **%s** appears idle for %dmin (state: %s, KPH: %d)", name, idleMins, stateText, kphi)) } } else { delete(a.idleAlerted, name) delete(a.idleSince, name) } } }