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>
145 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|