MosswartOverlord/go-services/tracker-go/aclog.go
Erik 5ade47dc64 feat: Go backend production cutover — website layer, ingest forwarding, alerts, live fixes
Completes the Go backend so it can fully replace Python in production:

tracker-go website layer (serves the unchanged frontend):
- static file serving + SPA fallback + /icons (website.go)
- login/logout with itsdangerous cookie ISSUING (bcrypt, Python-interop) and the
  /me handler (auth.go issueSessionCookie + website.go)
- admin user CRUD (website_admin.go) and the issue-board write side (website_issues.go)
- request-scoped user context + requireAdmin (auth.go)

cutover ingest (gated off during the parallel run, required for a clean cutover):
- inventory forwarding: full_inventory -> /process-inventory, inventory_delta ->
  item POST/DELETE, per-character serialized, fire-and-forget (inventory_forward.go)
- death/idle Discord alerts via DISCORD_ACLOG_WEBHOOK (aclog.go)
- SKIP_SCHEMA_INIT so write mode against the prod DBs runs no DDL (tracker-go + inventory-go)

two bugs found live and fixed:
- coerceNum: the plugin sends kills_per_hour/deaths/total_deaths/prismatic_taper_count
  as STRINGS; pydantic coerced them, Go's number helpers wrote null/0 (reads.go/ingest.go)
- telemetry is broadcast TYPELESS so the browser ignores it and uses the /live poll;
  broadcasting it typed flapped the per-player counters 0<->value (ingest.go stripType)

docker-compose.cutover.yml: reversible override flipping the Go services to write
mode against the production DBs and repointing the Discord bot at the Go /ws/live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:46:40 +02:00

145 lines
3.9 KiB
Go

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)
}
}
}