From b42beec34895978f743b5e82d50dad2c1654b89e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:53:25 -0700 Subject: [PATCH 1/3] Pete Adventure News: emit seam, !news command, cold-start backfill gogobee's game-side of Pete's push-based Adventure news feed. Emits structured event *facts* (not prose) to Pete's ingest endpoint over a durable queue; Pete owns voice/authoring/publishing. - internal/peteclient: durable pete_emit_queue + retry sender; Fact payload; FEATURE_PETE_NEWS master switch. - pete.go: emitFact enforces runtime kill-switch + per-player opt-out (anonymize, never drop); zone-clear + realm-first tiering; !news command (player optout/optin, admin on/off). - bootstrap_pete_news.go: cold-start backfill replays deaths + single-holder achievements + zone realm-firsts, backdated + NoPush, idempotent, fires only once the seam is live; seeds news_realm_firsts. - Emit sites: death, zone_first, rival_result, milestone, arrival. - db.go: pete_emit_queue, news_optout (+opted_out_at), news_realm_firsts. Unshipped. Deploy Pete first, then set FEATURE_PETE_NEWS + ingest env. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa --- internal/db/db.go | 32 +++ internal/peteclient/client.go | 224 +++++++++++++++++++++ internal/peteclient/client_test.go | 91 +++++++++ internal/plugin/achievements.go | 39 +++- internal/plugin/adventure.go | 8 + internal/plugin/adventure_duel.go | 16 ++ internal/plugin/bootstrap_pete_news.go | 233 ++++++++++++++++++++++ internal/plugin/dnd_combat.go | 27 +++ internal/plugin/dnd_expedition_extract.go | 3 + internal/plugin/dnd_setup.go | 17 ++ internal/plugin/pete.go | 225 +++++++++++++++++++++ internal/plugin/pete_test.go | 139 +++++++++++++ main.go | 9 + 13 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 internal/peteclient/client.go create mode 100644 internal/peteclient/client_test.go create mode 100644 internal/plugin/bootstrap_pete_news.go create mode 100644 internal/plugin/pete.go create mode 100644 internal/plugin/pete_test.go diff --git a/internal/db/db.go b/internal/db/db.go index 1e376d9..ccd2342 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -947,6 +947,38 @@ CREATE TABLE IF NOT EXISTS shade_optout ( user_id TEXT PRIMARY KEY ); +-- Pete adventure-news seam: durable outbound queue of game-event facts sent to +-- the Pete news bot. Emit enqueues (INSERT OR IGNORE on guid = idempotency); a +-- background sender drains rows where sent_at IS NULL. Keyed on the fact guid so +-- retries and duplicate emits collapse to one row. +CREATE TABLE IF NOT EXISTS pete_emit_queue ( + guid TEXT PRIMARY KEY, + payload TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + sent_at INTEGER +); + +-- Players who opted out of being named in Pete's adventure news. Enforced at +-- emit time (anonymize, never delete). Mirrors shade_optout. +CREATE TABLE IF NOT EXISTS news_optout ( + user_id TEXT PRIMARY KEY, + opted_out_at INTEGER NOT NULL DEFAULT (unixepoch()) +); + +-- Realm-first ledger for adventure news: the first (kind,target) occurrence +-- across all players. INSERT OR IGNORE returns rows-affected>0 exactly once, so +-- the first clear of a zone/boss fires as a PRIORITY realm-first and everyone +-- after as a BULLETIN personal clear. Seeded by the cold-start backfill so a +-- fresh deploy doesn't re-flag historically-cleared content as first-ever. +CREATE TABLE IF NOT EXISTS news_realm_firsts ( + kind TEXT NOT NULL, + target TEXT NOT NULL, + first_at INTEGER NOT NULL, + PRIMARY KEY (kind, target) +); + -- Birthdays CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go new file mode 100644 index 0000000..d05dda6 --- /dev/null +++ b/internal/peteclient/client.go @@ -0,0 +1,224 @@ +// Package peteclient is gogobee's outbound seam to the Pete news bot. +// +// gogobee is the source of game-event *facts* and owns delivery; Pete owns +// voice, authoring, and publishing. This package carries structured facts (not +// prose) to Pete's ingest endpoint over the tailnet, bearer-authed. +// +// Delivery is durable: Emit writes the fact to a SQLite queue and returns +// immediately, so a game-loop hook never blocks on the network and a Pete +// restart loses nothing. A background sender drains the queue with retry. +// Idempotency is on the fact GUID, so retries and duplicate emits are no-ops. +package peteclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "gogobee/internal/db" +) + +// Fact is the flat, pre-sanitized payload gogobee POSTs to Pete. Names must be +// character names only (never Matrix handles); Actors is the allow-list of the +// only names permitted to appear in Pete's rendered output. See +// pete_adventure_news_voice.md for the field contract. +type Fact struct { + GUID string `json:"guid"` // stable idempotency key, e.g. "death::" + EventType string `json:"event_type"` + Tier string `json:"tier"` // "priority" | "bulletin" + Actors []string `json:"actors"` + Subject string `json:"subject,omitempty"` + Opponent string `json:"opponent,omitempty"` + Boss string `json:"boss,omitempty"` + Zone string `json:"zone,omitempty"` + Region string `json:"region,omitempty"` + Level int `json:"level,omitempty"` + Count int `json:"count,omitempty"` + Outcome string `json:"outcome,omitempty"` + Stakes string `json:"stakes,omitempty"` + ClassRace string `json:"class_race,omitempty"` + Milestone string `json:"milestone,omitempty"` + OccurredAt int64 `json:"occurred_at"` + NoPush bool `json:"no_push,omitempty"` // backfill: suppress Pete web-push +} + +// Config controls the seam. Enabled=false makes Emit a durable no-op (nothing +// queued), matching the FEATURE_PETE_NEWS master switch that kills emission at +// the source. +type Config struct { + IngestURL string + Token string + Enabled bool +} + +// Client is the transport half. It is a package singleton initialized by Init, +// so emit hooks scattered across plugins (and free functions like +// markAdventureDead) can call Emit without threading a handle through. +type Client struct { + cfg Config + http *http.Client +} + +var std *Client + +// Tuning for the background sender. +const ( + senderTick = 15 * time.Second + senderBatch = 20 + maxAttempts = 8 // ~ up to a few hours of backoff, then park + backoffBase = 30 * time.Second + backoffCapSec = 3600 + sendTimeout = 15 * time.Second +) + +// Init wires the singleton from the environment. Mirrors the per-plugin config +// pattern (email_nag.go): PETE_INGEST_URL, PETE_INGEST_TOKEN, FEATURE_PETE_NEWS. +func Init() { + cfg := Config{ + IngestURL: strings.TrimRight(os.Getenv("PETE_INGEST_URL"), "/"), + Token: os.Getenv("PETE_INGEST_TOKEN"), + Enabled: strings.EqualFold(os.Getenv("FEATURE_PETE_NEWS"), "true"), + } + if cfg.Enabled && (cfg.IngestURL == "" || cfg.Token == "") { + slog.Warn("peteclient: FEATURE_PETE_NEWS=true but PETE_INGEST_URL/PETE_INGEST_TOKEN unset — disabling") + cfg.Enabled = false + } + std = &Client{cfg: cfg, http: &http.Client{Timeout: sendTimeout}} + if cfg.Enabled { + slog.Info("peteclient: adventure news emission enabled", "ingest", cfg.IngestURL) + } else { + slog.Info("peteclient: adventure news emission disabled (set FEATURE_PETE_NEWS=true)") + } +} + +// Enabled reports whether emission is on. Callers can skip building an +// (expensive) fact when it would be dropped anyway. +func Enabled() bool { return std != nil && std.cfg.Enabled } + +// Emit durably queues a fact for delivery to Pete. It never blocks on the +// network. A no-op (but safe) when the seam is disabled or the GUID was already +// queued — idempotency is on the GUID primary key. +func Emit(f Fact) { + if !Enabled() { + return + } + if f.GUID == "" { + slog.Error("peteclient: refusing to queue fact with empty guid", "event_type", f.EventType) + return + } + payload, err := json.Marshal(f) + if err != nil { + slog.Error("peteclient: marshal fact", "guid", f.GUID, "err", err) + return + } + // OR IGNORE gives GUID-idempotency: a re-emit of the same event is dropped. + db.Exec("pete emit enqueue", + `INSERT OR IGNORE INTO pete_emit_queue (guid, payload, created_at, attempts, next_attempt_at) + VALUES (?, ?, unixepoch(), 0, 0)`, + f.GUID, string(payload)) +} + +// StartSender launches the background drain loop. It runs until ctx is +// canceled. Safe to call when disabled — it simply idles. +func StartSender(ctx context.Context) { + if std == nil { + return + } + go func() { + t := time.NewTicker(senderTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if std.cfg.Enabled { + std.drain(ctx) + } + } + } + }() +} + +// drain sends up to senderBatch due rows, one at a time. +func (c *Client) drain(ctx context.Context) { + rows, err := db.Get().Query( + `SELECT guid, payload FROM pete_emit_queue + WHERE sent_at IS NULL AND attempts < ? AND next_attempt_at <= unixepoch() + ORDER BY created_at LIMIT ?`, + maxAttempts, senderBatch) + if err != nil { + slog.Error("peteclient: drain query", "err", err) + return + } + type item struct{ guid, payload string } + var batch []item + for rows.Next() { + var it item + if err := rows.Scan(&it.guid, &it.payload); err != nil { + slog.Error("peteclient: drain scan", "err", err) + continue + } + batch = append(batch, it) + } + rows.Close() + + for _, it := range batch { + if ctx.Err() != nil { + return + } + if err := c.send(ctx, []byte(it.payload)); err != nil { + db.Exec("pete emit retry", + `UPDATE pete_emit_queue + SET attempts = attempts + 1, next_attempt_at = unixepoch() + ? + WHERE guid = ?`, + backoffSec(it.guid), it.guid) + slog.Warn("peteclient: emit failed, will retry", "guid", it.guid, "err", err) + continue + } + db.Exec("pete emit sent", + `UPDATE pete_emit_queue SET sent_at = unixepoch() WHERE guid = ?`, it.guid) + } +} + +// send POSTs one payload to Pete's ingest endpoint with bearer auth. Mirrors the +// bearer-POST pattern in email_nag.go:sendCode. +func (c *Client) send(ctx context.Context, payload []byte) error { + url := c.cfg.IngestURL + "/api/ingest/adventure" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.cfg.Token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode/100 != 2 { + return fmt.Errorf("pete ingest status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +// backoffSec computes the retry delay for a row. It re-reads the current attempt +// count so the delay grows geometrically without needing it passed in. +func backoffSec(guid string) int { + var attempts int + _ = db.Get().QueryRow(`SELECT attempts FROM pete_emit_queue WHERE guid = ?`, guid).Scan(&attempts) + // attempts is the count *before* this failure's increment; delay off it. + delay := int(backoffBase.Seconds()) << attempts + if delay > backoffCapSec { + delay = backoffCapSec + } + return delay +} diff --git a/internal/peteclient/client_test.go b/internal/peteclient/client_test.go new file mode 100644 index 0000000..f09a57b --- /dev/null +++ b/internal/peteclient/client_test.go @@ -0,0 +1,91 @@ +package peteclient + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "gogobee/internal/db" +) + +// TestEmitDrainRoundTrip proves the durable path: Emit queues a fact, the sender +// POSTs it to Pete with bearer auth, and the row is marked sent (so it won't +// re-send), while a duplicate GUID collapses to one delivery. +func TestEmitDrainRoundTrip(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var got []Fact + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/ingest/adventure" { + t.Errorf("unexpected path %q", r.URL.Path) + } + body, _ := io.ReadAll(r.Body) + var f Fact + _ = json.Unmarshal(body, &f) + mu.Lock() + got = append(got, f) + gotAuth = r.Header.Get("Authorization") + mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + std = &Client{cfg: Config{IngestURL: srv.URL, Token: "tok", Enabled: true}, http: srv.Client()} + + Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) + Emit(Fact{GUID: "death:a:1", EventType: "death", Tier: "priority", Subject: "Brannigan", OccurredAt: 1}) // dup guid + + std.drain(context.Background()) + + mu.Lock() + defer mu.Unlock() + if len(got) != 1 { + t.Fatalf("delivered %d facts, want 1 (dup should collapse)", len(got)) + } + if got[0].Subject != "Brannigan" { + t.Errorf("subject = %q", got[0].Subject) + } + if gotAuth != "Bearer tok" { + t.Errorf("auth header = %q", gotAuth) + } + + var pending int + if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE sent_at IS NULL`).Scan(&pending); err != nil { + t.Fatal(err) + } + if pending != 0 { + t.Errorf("%d rows still pending after successful drain", pending) + } + + // A second drain re-sends nothing. + std.drain(context.Background()) + if len(got) != 1 { + t.Errorf("re-drained a sent row: %d deliveries", len(got)) + } +} + +// TestEmitDisabledNoQueue: when disabled, Emit is a durable no-op. +func TestEmitDisabledNoQueue(t *testing.T) { + if err := db.Init(t.TempDir()); err != nil { + t.Fatal(err) + } + std = &Client{cfg: Config{Enabled: false}, http: http.DefaultClient} + Emit(Fact{GUID: "disabled-guid", EventType: "death", OccurredAt: 1}) + var n int + // db.Init is a process singleton, so this may share state with other tests; + // scope the check to this fact's guid. + if err := db.Get().QueryRow(`SELECT count(*) FROM pete_emit_queue WHERE guid = 'disabled-guid'`).Scan(&n); err != nil { + t.Fatal(err) + } + if n != 0 { + t.Errorf("disabled Emit queued %d rows, want 0", n) + } +} diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index fdd353c..c8cff9e 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -9,6 +9,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" @@ -101,7 +102,7 @@ func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) { } func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) { - _, err := d.Exec( + res, err := d.Exec( `INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`, string(userID), achievementID, ) @@ -109,7 +110,43 @@ func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID st slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err) return } + // Only a genuinely new grant is news; a re-check conflict (rows==0) is not. + if n, _ := res.RowsAffected(); n == 0 { + return + } slog.Info("achievements: granted", "user", userID, "achievement", achievementID) + p.emitMilestoneNews(d, userID, achievementID) +} + +// emitMilestoneNews files a BULLETIN milestone to Pete for RARE achievements +// only — rarity-gated to a single holder (per the news selection thresholds) so +// routine unlocks don't flood the feed. Character name only; no-op unless the +// seam is enabled. +func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achievementID string) { + var holders int + if err := d.QueryRow(`SELECT count(*) FROM achievements WHERE achievement_id = ?`, achievementID).Scan(&holders); err != nil || holders != 1 { + return + } + name := charName(userID) + if name == "" { + return + } + label := achievementID + for _, a := range p.achievements { + if a.ID == achievementID { + label = a.Name + break + } + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("achv:%s:%s", userHash(userID), achievementID), + EventType: "milestone", + Tier: "bulletin", + Subject: name, + Milestone: label, + OccurredAt: ts, + }, userID, "") } func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 990cf41..6d23ef6 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -184,6 +184,7 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"}, {Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"}, {Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"}, + {Name: "news", Description: "Pete's Adventure News — opt out of being named, or opt back in", Usage: "!news [optout|optin]", Category: "Games"}, } } @@ -247,6 +248,10 @@ func (p *AdventurePlugin) Init() error { // arrival roll. Both idempotent via JobCompleted gates. bootstrapCasterSpellBackfill() bootstrapGrantStarterPet() + // Pete adventure-news cold-start: replay the back-catalogue (realm-firsts, + // deaths, single-holder achievements) the first boot the seam is live, so + // launch doesn't open onto an empty section. One-shot, kept (see gap #7). + p.bootstrapPeteNewsBackfill() // Phase R1 orphan-archive used to run here on every Init, but it // over-archived: it treats any active dnd_zone_run row not linked to // an active expedition as a legacy `!adventure dungeon` orphan, which @@ -438,6 +443,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "rivals") { return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals")) } + if p.IsCommand(ctx.Body, "news") { + return p.handleNewsCmd(ctx) + } // 1. Arena commands (work in rooms and DMs) if p.IsCommand(ctx.Body, "bail") { diff --git a/internal/plugin/adventure_duel.go b/internal/plugin/adventure_duel.go index 5ddc7fe..e5aff19 100644 --- a/internal/plugin/adventure_duel.go +++ b/internal/plugin/adventure_duel.go @@ -32,6 +32,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "github.com/google/uuid" "maunium.net/go/mautrix/id" @@ -613,6 +614,21 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c upsertRivalRecord(winnerID, loserID, true) upsertRivalRecord(loserID, winnerID, false) + // One BULLETIN dispatch per settled duel (from the winner's side, so it + // fires once). Character names only; no-op unless the seam is enabled. + if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" { + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("rival:%s:%s:%d", userHash(winnerID), userHash(loserID), ts), + EventType: "rival_result", + Tier: "bulletin", + Subject: wn, + Opponent: ln, + Outcome: "won", + OccurredAt: ts, + }, winnerID, loserID) + } + p.announceDuel(ctx, winnerID, loserID, ch.Stake, winnerShare, potShare, res.decisive) return nil } diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go new file mode 100644 index 0000000..05370a9 --- /dev/null +++ b/internal/plugin/bootstrap_pete_news.go @@ -0,0 +1,233 @@ +package plugin + +import ( + "fmt" + "log/slog" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// bootstrapPeteNewsBackfill replays the back-catalogue through the news emit +// path the first time the seam comes up enabled, so a launch doesn't open onto +// an empty section. It covers the three tractable, high-signal sources: +// +// 1. Realm-first zone clears — the earliest boss-defeated run of each zone. This +// also SEEDS news_realm_firsts, so a later live clear of an already-cleared +// zone tiers as a BULLETIN repeat, not a mis-announced "first ever". +// 2. Deaths — every player_meta row that carries a death (graveyard content). +// 3. Single-holder achievements — rarity-gated to one holder, matching the live +// milestone rule so routine unlocks don't flood the backlog. +// +// Backfilled facts are backdated to when they happened and marked NoPush (no +// web-push spam on launch). GUIDs are deterministic (keyed on the historical +// timestamp), so a re-run — or a later live emit of the same event — dedups on +// Pete's side. Guarded one-shot; kept (never deleted at close-out) so a fresh +// deploy doesn't ghost-town again. See pete_adventure_news_plan.md gap #7. +func (p *AdventurePlugin) bootstrapPeteNewsBackfill() { + const jobName = "pete_news_backfill_v1" + if db.JobCompleted(jobName, "once") { + return + } + // The backfill IS the launch content, so it should fire the first boot the + // seam is actually live — not get silently consumed on a boot where emission + // is off. Leave the job unmarked until both switches are on. + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + + firsts := p.backfillZoneFirsts() + deaths := backfillDeaths() + achv := p.backfillSoloAchievements() + + db.MarkJobCompleted(jobName, "once") + slog.Warn("bootstrap: pete news backfill complete", + "zone_firsts", firsts, "deaths", deaths, "achievements", achv) +} + +// backfillZoneFirsts seeds news_realm_firsts from history and emits one PRIORITY +// realm-first dispatch per zone, attributed to its earliest boss-defeating +// clearer. SQLite returns the user_id from the same row as MIN(completed_at) +// (bare-column min/max rule), so the (zone, first clearer, time) triple is +// consistent. Returns the count emitted. +func (p *AdventurePlugin) backfillZoneFirsts() int { + rows, err := db.Get().Query( + `SELECT zone_id, user_id, MIN(completed_at) + FROM dnd_zone_run + WHERE boss_defeated = 1 AND completed_at IS NOT NULL AND abandoned = 0 + GROUP BY zone_id`) + if err != nil { + slog.Error("backfill: zone-firsts query", "err", err) + return 0 + } + type first struct { + zoneID, userID, completedAt string + } + var firsts []first + for rows.Next() { + var f first + if err := rows.Scan(&f.zoneID, &f.userID, &f.completedAt); err != nil { + slog.Error("backfill: zone-firsts scan", "err", err) + continue + } + firsts = append(firsts, f) + } + rows.Close() + + n := 0 + for _, f := range firsts { + uid := id.UserID(f.userID) + // Seed the ledger regardless of whether we can render a dispatch, so the + // realm-first tiering is correct even for an unnamed straggler. + claimRealmFirst("zone", f.zoneID) + + name := charName(uid) + if name == "" { + continue + } + ts, ok := parseSQLiteTime(f.completedAt) + if !ok { + continue + } + zone := zoneOrFallback(ZoneID(f.zoneID)) + lvl := 0 + if dc, _ := LoadDnDCharacter(uid); dc != nil { + lvl = dc.Level + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(uid), f.zoneID, ts.Unix()), + EventType: "zone_first", + Tier: "priority", + Subject: name, + Zone: zone.Display, + Boss: zone.Boss.Name, + Level: lvl, + Outcome: "cleared", + OccurredAt: ts.Unix(), + NoPush: true, + }, uid, "") + n++ + } + return n +} + +// backfillDeaths emits one death dispatch per player_meta death record. Deaths +// are day-granular (last_death_date), so the dispatch is backdated to that day. +func backfillDeaths() int { + rows, err := db.Get().Query( + `SELECT user_id, last_death_date, death_location + FROM player_meta + WHERE last_death_date != ''`) + if err != nil { + slog.Error("backfill: deaths query", "err", err) + return 0 + } + type death struct { + userID, date, location string + } + var deaths []death + for rows.Next() { + var d death + if err := rows.Scan(&d.userID, &d.date, &d.location); err != nil { + slog.Error("backfill: deaths scan", "err", err) + continue + } + deaths = append(deaths, d) + } + rows.Close() + + n := 0 + for _, d := range deaths { + uid := id.UserID(d.userID) + name := charName(uid) + if name == "" { + continue + } + day, err := time.Parse("2006-01-02", d.date) + if err != nil { + continue + } + lvl := 0 + if dc, _ := LoadDnDCharacter(uid); dc != nil { + lvl = dc.Level + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("death:%s:%s", userHash(uid), d.date), + EventType: "death", + Tier: "priority", + Subject: name, + Zone: d.location, + Level: lvl, + Outcome: "lost", + OccurredAt: day.UTC().Unix(), + NoPush: true, + }, uid, "") + n++ + } + return n +} + +// backfillSoloAchievements emits a milestone dispatch for every achievement held +// by exactly one player — the same rarity gate the live path uses — so the +// backlog carries the genuinely distinctive unlocks, not routine ones. +func (p *AdventurePlugin) backfillSoloAchievements() int { + rows, err := db.Get().Query( + `SELECT achievement_id, user_id, unlocked_at + FROM achievements + GROUP BY achievement_id + HAVING COUNT(*) = 1`) + if err != nil { + slog.Error("backfill: achievements query", "err", err) + return 0 + } + type solo struct { + achID, userID string + unlockedAt int64 + } + var solos []solo + for rows.Next() { + var s solo + if err := rows.Scan(&s.achID, &s.userID, &s.unlockedAt); err != nil { + slog.Error("backfill: achievements scan", "err", err) + continue + } + solos = append(solos, s) + } + rows.Close() + + n := 0 + for _, s := range solos { + uid := id.UserID(s.userID) + name := charName(uid) + if name == "" { + continue + } + label := s.achID + if p.achievements != nil { + for _, a := range p.achievements.achievements { + if a.ID == s.achID { + label = a.Name + break + } + } + } + ts := s.unlockedAt + if ts == 0 { + ts = nowUnix() + } + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("achv:%s:%s", userHash(uid), s.achID), + EventType: "milestone", + Tier: "bulletin", + Subject: name, + Milestone: label, + OccurredAt: ts, + NoPush: true, + }, uid, "") + n++ + } + return n +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 792ca7f..1c621b2 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -6,6 +6,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) @@ -575,4 +576,30 @@ func markAdventureDead(userID id.UserID, source, location string) { if err := saveAdvCharacter(char); err != nil { slog.Error("dnd: kill on combat loss", "user", userID, "err", err) } + emitDeathNews(userID, location) +} + +// emitDeathNews files a PRIORITY death dispatch to Pete's adventure news. No-op +// unless the seam is enabled. Uses the character name (never the Matrix handle); +// skips silently if the name is unknown. +func emitDeathNews(userID id.UserID, location string) { + name := charName(userID) + if name == "" { + return + } + lvl := 0 + if dc, _ := LoadDnDCharacter(userID); dc != nil { + lvl = dc.Level + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("death:%s:%d", userHash(userID), ts), + EventType: "death", + Tier: "priority", + Subject: name, + Zone: location, + Level: lvl, + Outcome: "lost", + OccurredAt: ts, + }, userID, "") } diff --git a/internal/plugin/dnd_expedition_extract.go b/internal/plugin/dnd_expedition_extract.go index f37f319..051cab8 100644 --- a/internal/plugin/dnd_expedition_extract.go +++ b/internal/plugin/dnd_expedition_extract.go @@ -177,6 +177,9 @@ func (p *AdventurePlugin) finalizeExpeditionOnZoneClear(userID id.UserID, runID if sl := p.shadowOnPlayerZoneClear(userID, exp.ZoneID); sl != "" { lines = append(lines, sl) } + // News: boss down means the zone is cleared. Realm-first → PRIORITY, + // repeat → BULLETIN. No-op unless the Pete seam is enabled. + emitZoneClearNews(userID, exp) return lines } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index 25e5c20..f0914db 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -9,6 +9,7 @@ import ( "time" "gogobee/internal/db" + "gogobee/internal/peteclient" "maunium.net/go/mautrix/id" ) @@ -290,6 +291,22 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { // Idempotent — !setup confirm after a respec wipe will repopulate. _ = ensureSpellsForCharacter(c) + // A new character walked through the gates — file a welcome dispatch to + // Pete. GUID keyed on the user alone so a later respec/re-confirm can't + // re-announce the same arrival. No-op unless the seam is enabled. + if name := charName(ctx.Sender); name != "" { + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: "arrival:" + userHash(ctx.Sender), + EventType: "arrival", + Tier: "bulletin", + Subject: name, + ClassRace: classRaceLabel(c), + Level: c.Level, + OccurredAt: ts, + }, ctx.Sender, "") + } + return p.SendDM(ctx.Sender, renderSetupComplete(c)) } diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go new file mode 100644 index 0000000..70faf7d --- /dev/null +++ b/internal/plugin/pete.go @@ -0,0 +1,225 @@ +package plugin + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// This file is gogobee's game-side entry to Pete's adventure news. Plugins call +// emitFact at event chokepoints; it enforces the kill-switch + per-player +// opt-out, then hands a durable fact to peteclient. gogobee supplies facts only +// — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md. + +const ( + newsEnabledKey = "adventure_news_enabled" + // foreverTTL makes CacheGet a persistent flag: the row never "expires". + foreverTTL = 100 * 365 * 24 * 3600 + anonName = "an adventurer" +) + +// newsEmissionOn is the runtime kill-switch (default on). An operator flips it +// with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level +// master switch checked separately by peteclient.Enabled. +func newsEmissionOn() bool { + return db.CacheGet(newsEnabledKey, foreverTTL) != "0" +} + +// setNewsEmission persists the runtime kill-switch. +func setNewsEmission(on bool) { + v := "1" + if !on { + v = "0" + } + db.CacheSet(newsEnabledKey, v) +} + +// isNewsOptedOut reports whether a player asked not to be named in the news. +func isNewsOptedOut(userID id.UserID) bool { + var one int + err := db.Get().QueryRow(`SELECT 1 FROM news_optout WHERE user_id = ?`, string(userID)).Scan(&one) + return err == nil +} + +// setNewsOptout records or clears a player's opt-out. Opting out anonymizes +// their name in future dispatches ("an adventurer…"); it does not stop events +// being reported. Idempotent either direction. +func setNewsOptout(userID id.UserID, out bool) { + if out { + db.Exec("news optout set", + `INSERT OR IGNORE INTO news_optout (user_id, opted_out_at) VALUES (?, unixepoch())`, + string(userID)) + return + } + db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID)) +} + +// userHash is a stable, one-way digest of a Matrix user id. It keys per-player +// event GUIDs (which become PUBLIC permalink paths on Pete) WITHOUT leaking the +// Matrix handle. Not reversible, stable across restarts. +func userHash(userID id.UserID) string { + sum := sha256.Sum256([]byte(userID)) + return hex.EncodeToString(sum[:6]) // 12 hex chars — ample for 4–thousands of players +} + +// charName returns a player's in-game character name, never their Matrix handle. +// Empty when unknown — callers skip the emit rather than fall back to a handle. +func charName(userID id.UserID) string { + name, _ := loadDisplayName(userID) + return strings.TrimSpace(name) +} + +// classRaceLabel renders a character's race + class for the arrival fact, e.g. +// "Elf Ranger". +func classRaceLabel(c *DnDCharacter) string { + ri, _ := raceInfo(c.Race) + ci, _ := classInfo(c.Class) + return strings.TrimSpace(ri.Display + " " + ci.Display) +} + +// emitFact is the single game-side entry to Pete's news. It enforces the runtime +// kill-switch and per-player opt-out (anonymize the name, never drop the event), +// derives the actors allow-list from the FINAL names (so Pete's fact-guard and +// the rendered content agree), then durably queues the fact. +// +// subjectUser/opponentUser are the Matrix users behind Subject/Opponent (either +// may be empty for realm-level events). They are used ONLY for the opt-out +// lookup and are never sent. +func emitFact(f peteclient.Fact, subjectUser, opponentUser id.UserID) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + if subjectUser != "" && isNewsOptedOut(subjectUser) { + f.Subject = anonName + } + if opponentUser != "" && isNewsOptedOut(opponentUser) { + f.Opponent = anonName + } + var actors []string + for _, n := range []string{f.Subject, f.Opponent} { + if n != "" { + actors = append(actors, n) + } + } + f.Actors = actors + peteclient.Emit(f) +} + +// handleNewsCmd backs `!news`. Players toggle whether they're named in Pete's +// dispatches; admins flip the realm-wide emission kill-switch. Replies in-room +// so the effect is visible where it was invoked. +// +// !news → show your status + what the command does +// !news optout → anonymize your name in future dispatches +// !news optin → be named again +// !news on|off → (admin) master kill-switch for all emission +func (p *AdventurePlugin) handleNewsCmd(ctx MessageContext) error { + arg := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "news"))) + switch arg { + case "optout", "opt-out", "anonymize", "anon": + setNewsOptout(ctx.Sender, true) + return p.SendReply(ctx.RoomID, ctx.EventID, + "Done — Pete will refer to you as \"an adventurer\" in the news from now on. `!news optin` to be named again.") + case "optin", "opt-in": + setNewsOptout(ctx.Sender, false) + return p.SendReply(ctx.RoomID, ctx.EventID, + "You're back on the record — Pete will use your character name in future dispatches.") + case "on", "enable": + if !p.IsAdmin(ctx.Sender) { + return nil + } + setNewsEmission(true) + return p.SendReply(ctx.RoomID, ctx.EventID, "📣 Adventure news emission is now ON.") + case "off", "disable": + if !p.IsAdmin(ctx.Sender) { + return nil + } + setNewsEmission(false) + return p.SendReply(ctx.RoomID, ctx.EventID, "🔇 Adventure news emission is now OFF. No new dispatches will be sent.") + case "", "status", "help": + named := "using your character name" + if isNewsOptedOut(ctx.Sender) { + named = "anonymized (\"an adventurer\")" + } + msg := "📰 **Pete's Adventure News** reports realm happenings — deaths, first-clears, arrivals, duels.\n" + + "You are currently " + named + ".\n" + + "`!news optout` to be anonymized · `!news optin` to be named again." + if p.IsAdmin(ctx.Sender) { + state := "ON" + if !newsEmissionOn() { + state = "OFF" + } + msg += "\n_(admin)_ emission is **" + state + "** · `!news on` / `!news off` to toggle." + } + return p.SendReply(ctx.RoomID, ctx.EventID, msg) + default: + return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown option. Try `!news`, `!news optout`, or `!news optin`.") + } +} + +// nowUnix is the event timestamp helper (kept in one place so the emit sites +// read uniformly). +func nowUnix() int64 { return time.Now().Unix() } + +// claimRealmFirst records the first occurrence of a (kind,target) across the +// realm and reports whether THIS call was the first — true exactly once. Used to +// tier realm-firsts (PRIORITY) apart from repeat clears (BULLETIN). The backfill +// pre-seeds this ledger so historical firsts aren't re-announced after a deploy. +func claimRealmFirst(kind, target string) bool { + res := db.ExecResult("news realm-first claim", + `INSERT OR IGNORE INTO news_realm_firsts (kind, target, first_at) VALUES (?, ?, unixepoch())`, + kind, target) + if res == nil { + return false + } + n, err := res.RowsAffected() + return err == nil && n > 0 +} + +// emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The +// realm's first clear of a zone is PRIORITY; later clears are BULLETIN. Character +// name only; no-op unless the seam is enabled. +func emitZoneClearNews(userID id.UserID, exp *Expedition) { + if !peteclient.Enabled() || !newsEmissionOn() { + return + } + name := charName(userID) + if name == "" { + return + } + zone := zoneOrFallback(exp.ZoneID) + region := "" + if IsMultiRegionZone(exp.ZoneID) { + if r, ok := CurrentRegion(exp); ok { + region = r.Name + } + } + lvl := 0 + if dc, _ := LoadDnDCharacter(userID); dc != nil { + lvl = dc.Level + } + tier := "bulletin" + if claimRealmFirst("zone", string(exp.ZoneID)) { + tier = "priority" + } + ts := nowUnix() + emitFact(peteclient.Fact{ + GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(userID), exp.ZoneID, ts), + EventType: "zone_first", + Tier: tier, + Subject: name, + Zone: zone.Display, + Region: region, + Boss: zone.Boss.Name, + Level: lvl, + Outcome: "cleared", + OccurredAt: ts, + }, userID, "") +} diff --git a/internal/plugin/pete_test.go b/internal/plugin/pete_test.go new file mode 100644 index 0000000..ea6a11c --- /dev/null +++ b/internal/plugin/pete_test.go @@ -0,0 +1,139 @@ +package plugin + +import ( + "os" + "testing" + + "gogobee/internal/db" + "gogobee/internal/peteclient" + + "maunium.net/go/mautrix/id" +) + +// enablePeteSeam turns on the emit seam for a test and restores it to disabled +// afterwards, so a leaked-enabled singleton can't affect other tests. +func enablePeteSeam(t *testing.T) { + t.Helper() + os.Setenv("FEATURE_PETE_NEWS", "true") + os.Setenv("PETE_INGEST_URL", "http://127.0.0.1:0") + os.Setenv("PETE_INGEST_TOKEN", "tok") + peteclient.Init() + t.Cleanup(func() { + os.Unsetenv("FEATURE_PETE_NEWS") + os.Unsetenv("PETE_INGEST_URL") + os.Unsetenv("PETE_INGEST_TOKEN") + peteclient.Init() + }) +} + +func queuedCount(t *testing.T, likeGUID string) int { + t.Helper() + var n int + if err := db.Get().QueryRow( + `SELECT COUNT(*) FROM pete_emit_queue WHERE guid LIKE ?`, likeGUID).Scan(&n); err != nil { + t.Fatalf("queue count: %v", err) + } + return n +} + +// TestNewsOptoutRoundTrip: opting out records the player, opting in clears them, +// both idempotently. +func TestNewsOptoutRoundTrip(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + u := id.UserID("@zapp:example.org") + if isNewsOptedOut(u) { + t.Fatal("fresh player should not be opted out") + } + + setNewsOptout(u, true) + if !isNewsOptedOut(u) { + t.Error("opt-out not recorded") + } + setNewsOptout(u, true) // idempotent + if !isNewsOptedOut(u) { + t.Error("second opt-out flipped state") + } + + setNewsOptout(u, false) + if isNewsOptedOut(u) { + t.Error("opt-in did not clear") + } + setNewsOptout(u, false) // idempotent + if isNewsOptedOut(u) { + t.Error("second opt-in flipped state") + } +} + +// TestPeteNewsBackfill: the one-shot replays deaths + solo achievements + zone +// realm-firsts through the emit queue, seeds the realm-first ledger, and is +// idempotent on a second boot. +func TestPeteNewsBackfill(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + enablePeteSeam(t) + + // A named, dead adventurer. + db.Exec("seed death", `INSERT INTO player_meta (user_id, display_name, last_death_date, death_location) + VALUES (?, ?, ?, ?)`, "@zapp:x", "Zapp", "2026-06-01", "the Underforge") + // A single-holder achievement. + db.Exec("seed achv", `INSERT INTO achievements (user_id, achievement_id, unlocked_at) + VALUES (?, ?, ?)`, "@zapp:x", "adv_first_blood", 1717200000) + // A completed, boss-defeated zone run → realm-first. + db.Exec("seed run", `INSERT INTO dnd_zone_run (run_id, user_id, zone_id, total_rooms, boss_defeated, completed_at) + VALUES (?, ?, ?, ?, 1, ?)`, "r1", "@zapp:x", "goblin_warren", 5, "2026-05-20 12:00:00") + + p := &AdventurePlugin{} + p.bootstrapPeteNewsBackfill() + + if got := queuedCount(t, "death:%"); got != 1 { + t.Errorf("death dispatches queued = %d, want 1", got) + } + if got := queuedCount(t, "achv:%"); got != 1 { + t.Errorf("achievement dispatches queued = %d, want 1", got) + } + if got := queuedCount(t, "zone:%"); got != 1 { + t.Errorf("zone-first dispatches queued = %d, want 1", got) + } + // Realm-first ledger seeded so a later live clear tiers as a repeat. + if claimRealmFirst("zone", "goblin_warren") { + t.Error("realm-first ledger not seeded — a live clear would mis-announce as first-ever") + } + + // Second boot: job gate holds, nothing re-queued. + p.bootstrapPeteNewsBackfill() + if got := queuedCount(t, "%"); got != 3 { + t.Errorf("total queued after re-run = %d, want 3 (idempotent)", got) + } +} + +// TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip. +func TestNewsEmissionKillSwitch(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + if !newsEmissionOn() { + t.Error("emission should default ON") + } + setNewsEmission(false) + if newsEmissionOn() { + t.Error("emission still ON after disable") + } + setNewsEmission(true) + if !newsEmissionOn() { + t.Error("emission still OFF after enable") + } +} diff --git a/main.go b/main.go index 54d89a0..4ba7f02 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "gogobee/internal/bot" "gogobee/internal/db" "gogobee/internal/dreamclient" + "gogobee/internal/peteclient" "gogobee/internal/plugin" "gogobee/internal/util" "gogobee/internal/version" @@ -55,6 +56,11 @@ func main() { } db.RecordStartup(version.Version, version.Commit) + // Pete adventure-news seam: wire the outbound fact client from the + // environment (no-op unless FEATURE_PETE_NEWS=true). The background sender + // is started once the run context exists (below). + peteclient.Init() + // Create Matrix session. AUTH_MODE selects the transport: // masdevice (default) — MAS OAuth device grant over /sync. // appservice — as_token auth over Synapse transaction push. @@ -97,6 +103,9 @@ func main() { cancel() }() + // Drain the Pete adventure-news emit queue in the background until shutdown. + peteclient.StartSender(ctx) + // Show the bot online from boot (appservice mode; no-op under masdevice, where // /sync refreshes presence implicitly). Safe before the listener — outbound only. sess.StartPresence(ctx) From ae7ff38996e5906f9113a889d03fd6c6c2e2a6b7 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:43:57 -0700 Subject: [PATCH 2/3] Pete news: salted per-event GUID token + zone_clear taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the two deferred code-review follow-ups on the adventure-news seam, plus folds in two pre-committed WIP fixes. A. Privacy — the public GUID no longer leaks a stable per-player id. Replaced userHash(userID)=sha256(userID)[:6] with eventToken(userID, discriminator)=HMAC-SHA256(salt, userID‖disc). The salt is 32 random bytes, auto-generated once and persisted in the durable news_config table (cached via sync.Once). Because each event uses a distinct HMAC message, tokens are a PRF output and are BOTH uncomputable from a Matrix handle (no enumeration of a player's events, incl. ones anonymized after !news optout) AND mutually unlinkable (a named event can't be walked back to a player's other, anonymized events). Updated all emit sites: pete.go zone, dnd_combat death, adventure_duel rival, dnd_setup arrival, achievements milestone, bootstrap x3. B. Taxonomy — repeat zone clears were mislabeled zone_first. Now emit zone_clear (bulletin) vs zone_first (realm-first, priority). Adopted the invariant GUID-prefix == event_type, which also fixes latent permalink mislabels (achv->milestone, rival->rival_result rendered a neutral "Dispatch" on their permalink pages). Folded-in WIP fixes: create the news_config table b42beec's newsEmissionOn reads but never created; reap sent pete_emit_queue rows in RunMaintenance; don't burn a retry attempt when shutdown cancels an in-flight send. Tests: TestEventToken (salted/stable/per-event/persisted), TestEmitZoneClearTaxonomy (first->zone_first, repeat->zone_clear), updated pete_test.go prefixes. Full internal suite + vet green. Unshipped. Deploy Pete first (it must know zone_clear), then gogobee. --- internal/db/db.go | 12 +++ internal/peteclient/client.go | 10 ++- internal/plugin/achievements.go | 2 +- internal/plugin/adventure_duel.go | 3 +- internal/plugin/bootstrap_pete_news.go | 16 ++-- internal/plugin/dnd_combat.go | 7 +- internal/plugin/dnd_setup.go | 2 +- internal/plugin/pete.go | 118 ++++++++++++++++++++----- internal/plugin/pete_test.go | 81 ++++++++++++++++- 9 files changed, 206 insertions(+), 45 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index ccd2342..8a4ac99 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -616,6 +616,10 @@ func RunMaintenance() { {"urban_cache", `DELETE FROM urban_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, {"url_cache", `DELETE FROM url_cache WHERE cached_at < ?`, []interface{}{cutoff30d}}, + // Pete adventure-news queue — reap delivered rows (kept only for + // idempotency); undelivered rows stay so the sender can retry/park them. + {"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}}, + // Rate limits — purge entries older than today {"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}}, @@ -979,6 +983,14 @@ CREATE TABLE IF NOT EXISTS news_realm_firsts ( PRIMARY KEY (kind, target) ); +-- Durable runtime config for adventure news (e.g. the emission kill-switch). +-- Deliberately NOT stored in api_cache: RunMaintenance prunes that table after +-- 7 days, which would silently revert an operator's !news off back to on. +CREATE TABLE IF NOT EXISTS news_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + -- Birthdays CREATE TABLE IF NOT EXISTS birthdays ( user_id TEXT PRIMARY KEY, diff --git a/internal/peteclient/client.go b/internal/peteclient/client.go index d05dda6..87951a3 100644 --- a/internal/peteclient/client.go +++ b/internal/peteclient/client.go @@ -30,7 +30,7 @@ import ( // only names permitted to appear in Pete's rendered output. See // pete_adventure_news_voice.md for the field contract. type Fact struct { - GUID string `json:"guid"` // stable idempotency key, e.g. "death::" + GUID string `json:"guid"` // stable idempotency key, e.g. "death::"; prefix == event_type EventType string `json:"event_type"` Tier string `json:"tier"` // "priority" | "bulletin" Actors []string `json:"actors"` @@ -72,7 +72,7 @@ var std *Client const ( senderTick = 15 * time.Second senderBatch = 20 - maxAttempts = 8 // ~ up to a few hours of backoff, then park + maxAttempts = 8 // ~ up to a few hours of backoff, then park backoffBase = 30 * time.Second backoffCapSec = 3600 sendTimeout = 15 * time.Second @@ -175,6 +175,12 @@ func (c *Client) drain(ctx context.Context) { return } if err := c.send(ctx, []byte(it.payload)); err != nil { + if ctx.Err() != nil { + // Shutdown canceled the in-flight send — Pete didn't reject + // anything. Don't burn a durable retry attempt; the row is picked + // up on the next boot's drain. + return + } db.Exec("pete emit retry", `UPDATE pete_emit_queue SET attempts = attempts + 1, next_attempt_at = unixepoch() + ? diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index c8cff9e..6dd6099 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -140,7 +140,7 @@ func (p *AchievementsPlugin) emitMilestoneNews(d *sql.DB, userID id.UserID, achi } ts := nowUnix() emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("achv:%s:%s", userHash(userID), achievementID), + GUID: fmt.Sprintf("milestone:%s:%s", eventToken(userID, achievementID), achievementID), EventType: "milestone", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/adventure_duel.go b/internal/plugin/adventure_duel.go index e5aff19..86ae090 100644 --- a/internal/plugin/adventure_duel.go +++ b/internal/plugin/adventure_duel.go @@ -618,8 +618,9 @@ func (p *AdventurePlugin) settleDuel(ctx MessageContext, ch *advDuelChallenge, c // fires once). Character names only; no-op unless the seam is enabled. if wn, ln := charName(winnerID), charName(loserID); wn != "" && ln != "" { ts := nowUnix() + disc := fmt.Sprintf("rival:%d", ts) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("rival:%s:%s:%d", userHash(winnerID), userHash(loserID), ts), + GUID: fmt.Sprintf("rival_result:%s:%s:%d", eventToken(winnerID, disc), eventToken(loserID, disc), ts), EventType: "rival_result", Tier: "bulletin", Subject: wn, diff --git a/internal/plugin/bootstrap_pete_news.go b/internal/plugin/bootstrap_pete_news.go index 05370a9..d1fac95 100644 --- a/internal/plugin/bootstrap_pete_news.go +++ b/internal/plugin/bootstrap_pete_news.go @@ -93,12 +93,9 @@ func (p *AdventurePlugin) backfillZoneFirsts() int { continue } zone := zoneOrFallback(ZoneID(f.zoneID)) - lvl := 0 - if dc, _ := LoadDnDCharacter(uid); dc != nil { - lvl = dc.Level - } + lvl := charLevel(uid) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(uid), f.zoneID, ts.Unix()), + GUID: fmt.Sprintf("zone_first:%s:%s:%d", eventToken(uid, fmt.Sprintf("%s:%d", f.zoneID, ts.Unix())), f.zoneID, ts.Unix()), EventType: "zone_first", Tier: "priority", Subject: name, @@ -150,12 +147,9 @@ func backfillDeaths() int { if err != nil { continue } - lvl := 0 - if dc, _ := LoadDnDCharacter(uid); dc != nil { - lvl = dc.Level - } + lvl := charLevel(uid) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("death:%s:%s", userHash(uid), d.date), + GUID: fmt.Sprintf("death:%s:%s", eventToken(uid, d.date), d.date), EventType: "death", Tier: "priority", Subject: name, @@ -219,7 +213,7 @@ func (p *AdventurePlugin) backfillSoloAchievements() int { ts = nowUnix() } emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("achv:%s:%s", userHash(uid), s.achID), + GUID: fmt.Sprintf("milestone:%s:%s", eventToken(uid, s.achID), s.achID), EventType: "milestone", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 1c621b2..dce4f11 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -587,13 +587,10 @@ func emitDeathNews(userID id.UserID, location string) { if name == "" { return } - lvl := 0 - if dc, _ := LoadDnDCharacter(userID); dc != nil { - lvl = dc.Level - } + lvl := charLevel(userID) ts := nowUnix() emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("death:%s:%d", userHash(userID), ts), + GUID: fmt.Sprintf("death:%s:%d", eventToken(userID, fmt.Sprintf("%d", ts)), ts), EventType: "death", Tier: "priority", Subject: name, diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index f0914db..2de2d05 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -297,7 +297,7 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { if name := charName(ctx.Sender); name != "" { ts := nowUnix() emitFact(peteclient.Fact{ - GUID: "arrival:" + userHash(ctx.Sender), + GUID: "arrival:" + eventToken(ctx.Sender, "arrival"), EventType: "arrival", Tier: "bulletin", Subject: name, diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index 70faf7d..96fede6 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -1,10 +1,13 @@ package plugin import ( + "crypto/hmac" + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" "strings" + "sync" "time" "gogobee/internal/db" @@ -19,17 +22,22 @@ import ( // — Pete owns the voice. See pete_adventure_news_plan.md / _voice.md. const ( - newsEnabledKey = "adventure_news_enabled" - // foreverTTL makes CacheGet a persistent flag: the row never "expires". - foreverTTL = 100 * 365 * 24 * 3600 - anonName = "an adventurer" + newsEnabledKey = "adventure_news_enabled" + newsGUIDSaltKey = "adventure_news_guid_salt" + anonName = "an adventurer" ) // newsEmissionOn is the runtime kill-switch (default on). An operator flips it // with `!news off` without a redeploy; FEATURE_PETE_NEWS is the source-level -// master switch checked separately by peteclient.Enabled. +// master switch checked separately by peteclient.Enabled. Persisted in the +// durable news_config table (NOT api_cache, which RunMaintenance prunes). func newsEmissionOn() bool { - return db.CacheGet(newsEnabledKey, foreverTTL) != "0" + var v string + err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsEnabledKey).Scan(&v) + if err != nil { + return true // no row → default on + } + return v != "0" } // setNewsEmission persists the runtime kill-switch. @@ -38,7 +46,10 @@ func setNewsEmission(on bool) { if !on { v = "0" } - db.CacheSet(newsEnabledKey, v) + db.Exec("news emission set", + `INSERT INTO news_config (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + newsEnabledKey, v) } // isNewsOptedOut reports whether a player asked not to be named in the news. @@ -61,12 +72,66 @@ func setNewsOptout(userID id.UserID, out bool) { db.Exec("news optout clear", `DELETE FROM news_optout WHERE user_id = ?`, string(userID)) } -// userHash is a stable, one-way digest of a Matrix user id. It keys per-player -// event GUIDs (which become PUBLIC permalink paths on Pete) WITHOUT leaking the -// Matrix handle. Not reversible, stable across restarts. -func userHash(userID id.UserID) string { - sum := sha256.Sum256([]byte(userID)) - return hex.EncodeToString(sum[:6]) // 12 hex chars — ample for 4–thousands of players +var ( + guidSaltOnce sync.Once + guidSalt []byte +) + +// newsGUIDSalt returns the secret, server-side salt that keys every public event +// token. It is 32 random bytes, generated once and persisted in the durable +// news_config table, cached in-process. Secret from anyone outside the server +// (external users never see it), stable across restarts (so a re-emit or the +// cold-start backfill reproduces the same GUID). No operator action needed. +func newsGUIDSalt() []byte { + guidSaltOnce.Do(func() { + var stored string + if err := db.Get().QueryRow( + `SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&stored); err == nil { + if b, e := hex.DecodeString(stored); e == nil && len(b) > 0 { + guidSalt = b + return + } + } + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + // crypto/rand should never fail; if it does, refuse to fall back to a + // predictable salt (that would reopen the enumeration attack). Leave the + // salt empty and let eventToken sort it out. + return + } + salt := hex.EncodeToString(b) + // Persist. OR IGNORE so a concurrent first-writer wins cleanly; we then + // re-read the row so every caller agrees on the same salt. + db.Exec("news guid salt seed", + `INSERT OR IGNORE INTO news_config (key, value) VALUES (?, ?)`, newsGUIDSaltKey, salt) + _ = db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt) + guidSalt, _ = hex.DecodeString(salt) + }) + return guidSalt +} + +// eventToken is the per-event, one-way identifier embedded in a public GUID +// (which becomes a PUBLIC permalink path on Pete). It is HMAC-SHA256 over the +// Matrix user id AND an event-specific discriminator, keyed by the secret +// newsGUIDSalt. +// +// Two properties matter and both come from HMAC being a PRF under a secret key: +// - Not computable from a Matrix handle. An attacker who knows @alice:server +// cannot derive any of her tokens without the salt — so her events (including +// ones anonymized after `!news optout`) can't be enumerated from her handle. +// - Per-event, so tokens are mutually unlinkable. Even for the same user, two +// events yield unrelated tokens; a story that names her (opted-in, or a duel +// opponent) exposes only that one event's token and can't be walked back to +// her other, anonymized events. +// +// The discriminator must uniquely identify the logical event so a re-emit or the +// backfill reproduces the same token (idempotency rides on the GUID). +func eventToken(userID id.UserID, discriminator string) string { + mac := hmac.New(sha256.New, newsGUIDSalt()) + mac.Write([]byte(userID)) + mac.Write([]byte{0}) // domain separator: keep id and discriminator from bleeding + mac.Write([]byte(discriminator)) + return hex.EncodeToString(mac.Sum(nil)[:9]) // 18 hex chars — ample, non-reversible } // charName returns a player's in-game character name, never their Matrix handle. @@ -76,6 +141,15 @@ func charName(userID id.UserID) string { return strings.TrimSpace(name) } +// charLevel returns a player's current character level for a news Fact, or 0 if +// no character loads (Level is omitempty, so 0 is dropped from the payload). +func charLevel(userID id.UserID) int { + if dc, _ := LoadDnDCharacter(userID); dc != nil { + return dc.Level + } + return 0 +} + // classRaceLabel renders a character's race + class for the arrival fact, e.g. // "Elf Ranger". func classRaceLabel(c *DnDCharacter) string { @@ -184,7 +258,9 @@ func claimRealmFirst(kind, target string) bool { } // emitZoneClearNews files a zone-clear dispatch (boss down = zone cleared). The -// realm's first clear of a zone is PRIORITY; later clears are BULLETIN. Character +// realm's first clear of a zone is a PRIORITY "zone_first"; later clears are a +// BULLETIN "zone_clear". The event_type and the GUID prefix track the tier so +// Pete templates and labels a repeat as a repeat, not a first-ever. Character // name only; no-op unless the seam is enabled. func emitZoneClearNews(userID id.UserID, exp *Expedition) { if !peteclient.Enabled() || !newsEmissionOn() { @@ -201,18 +277,16 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { region = r.Name } } - lvl := 0 - if dc, _ := LoadDnDCharacter(userID); dc != nil { - lvl = dc.Level - } - tier := "bulletin" + lvl := charLevel(userID) + eventType, tier := "zone_clear", "bulletin" if claimRealmFirst("zone", string(exp.ZoneID)) { - tier = "priority" + eventType, tier = "zone_first", "priority" } ts := nowUnix() + disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts) emitFact(peteclient.Fact{ - GUID: fmt.Sprintf("zone:%s:%s:%d", userHash(userID), exp.ZoneID, ts), - EventType: "zone_first", + GUID: fmt.Sprintf("%s:%s:%s:%d", eventType, eventToken(userID, disc), exp.ZoneID, ts), + EventType: eventType, Tier: tier, Subject: name, Zone: zone.Display, diff --git a/internal/plugin/pete_test.go b/internal/plugin/pete_test.go index ea6a11c..bc45a3f 100644 --- a/internal/plugin/pete_test.go +++ b/internal/plugin/pete_test.go @@ -1,7 +1,10 @@ package plugin import ( + "crypto/sha256" + "encoding/hex" "os" + "sync" "testing" "gogobee/internal/db" @@ -98,10 +101,10 @@ func TestPeteNewsBackfill(t *testing.T) { if got := queuedCount(t, "death:%"); got != 1 { t.Errorf("death dispatches queued = %d, want 1", got) } - if got := queuedCount(t, "achv:%"); got != 1 { + if got := queuedCount(t, "milestone:%"); got != 1 { t.Errorf("achievement dispatches queued = %d, want 1", got) } - if got := queuedCount(t, "zone:%"); got != 1 { + if got := queuedCount(t, "zone_first:%"); got != 1 { t.Errorf("zone-first dispatches queued = %d, want 1", got) } // Realm-first ledger seeded so a later live clear tiers as a repeat. @@ -116,6 +119,80 @@ func TestPeteNewsBackfill(t *testing.T) { } } +// TestEventToken: the public GUID token is salted (not a bare sha256 an attacker +// can recompute from the handle), stable per logical event (idempotency), and +// per-event so a user's tokens don't link to each other. +func TestEventToken(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + + // The salt is cached process-wide via sync.Once; reset it so it derives (and + // persists) against this test's fresh DB rather than an earlier test's. + guidSaltOnce = sync.Once{} + guidSalt = nil + + u := id.UserID("@alice:example.org") + + // Not the old unsalted digest: an attacker with the handle can't recompute it. + bare := sha256.Sum256([]byte(u)) + if got := eventToken(u, "arrival"); got == hex.EncodeToString(bare[:9]) { + t.Error("token matches unsalted sha256(handle) — enumeration attack reopened") + } + + // Stable for the same (user, discriminator): a re-emit dedups. + if eventToken(u, "arrival") != eventToken(u, "arrival") { + t.Error("token not stable for the same event — idempotency broken") + } + + // Per-event: two different events for the same user yield unrelated tokens, so + // a named event can't be walked back to the user's anonymized events. + if eventToken(u, "arrival") == eventToken(u, "death:123") { + t.Error("tokens collide across events — linkage attack reopened") + } + + // Different users, same discriminator, still differ. + if eventToken(u, "arrival") == eventToken(id.UserID("@bob:example.org"), "arrival") { + t.Error("token collides across users") + } + + // The salt persisted, so it survives a restart (a re-emit after reboot dedups). + var salt string + if err := db.Get().QueryRow(`SELECT value FROM news_config WHERE key = ?`, newsGUIDSaltKey).Scan(&salt); err != nil || salt == "" { + t.Fatalf("guid salt not persisted: %v", err) + } +} + +// TestEmitZoneClearTaxonomy: the realm's first clear of a zone emits a PRIORITY +// zone_first; a later clear emits a BULLETIN zone_clear — distinct event_type and +// GUID prefix, so Pete never files a repeat as a first-ever. +func TestEmitZoneClearTaxonomy(t *testing.T) { + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatalf("db.Init: %v", err) + } + t.Cleanup(db.Close) + enablePeteSeam(t) + + db.Exec("seed player", `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?)`, + "@zapp:x", "Zapp") + exp := &Expedition{ZoneID: "goblin_warren"} + + emitZoneClearNews(id.UserID("@zapp:x"), exp) // realm-first + emitZoneClearNews(id.UserID("@zapp:x"), exp) // repeat + + if got := queuedCount(t, "zone_first:%"); got != 1 { + t.Errorf("zone_first queued = %d, want 1 (the realm-first)", got) + } + if got := queuedCount(t, "zone_clear:%"); got != 1 { + t.Errorf("zone_clear queued = %d, want 1 (the repeat)", got) + } +} + // TestNewsEmissionKillSwitch: the runtime flag defaults on and persists a flip. func TestNewsEmissionKillSwitch(t *testing.T) { dir := t.TempDir() From 1634bb1970ebd07e857228c9dfaf7cf81bd12b59 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 11 Jul 2026 07:56:41 -0700 Subject: [PATCH 3/3] Pete news code-review fixes: realm-first ordering, parked-row reap, death-emit gate - emitZoneClearNews: claim realm-first before the name guard so an unnamed straggler's first clear seeds news_realm_firsts (else the next named clearer is mis-announced as first-ever). Mirrors backfillZoneFirsts. - RunMaintenance: reap permanently-parked pete_emit_queue rows (unsent, >30d) so a durable Pete outage can't accrete rows forever. - emitDeathNews: gate on Enabled()/newsEmissionOn() before the char DB reads; markAdventureDead fires per-member on party wipes + in the sim. --- internal/db/db.go | 5 +++++ internal/plugin/dnd_combat.go | 5 +++++ internal/plugin/pete.go | 12 ++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/internal/db/db.go b/internal/db/db.go index 8a4ac99..7e0a893 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -619,6 +619,11 @@ func RunMaintenance() { // Pete adventure-news queue — reap delivered rows (kept only for // idempotency); undelivered rows stay so the sender can retry/park them. {"pete_emit_queue", `DELETE FROM pete_emit_queue WHERE sent_at IS NOT NULL AND sent_at < ?`, []interface{}{cutoff7d}}, + // ...and reap permanently-parked rows: the sender exhausts its retries + // within a few hours, so anything still unsent after 30 days is dead + // weight the drain query already skips — drop it so a durable outage + // can't accrete rows forever. + {"pete_emit_queue_parked", `DELETE FROM pete_emit_queue WHERE sent_at IS NULL AND created_at < ?`, []interface{}{cutoff30d}}, // Rate limits — purge entries older than today {"rate_limits", `DELETE FROM rate_limits WHERE date < ?`, []interface{}{today}}, diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index dce4f11..2429236 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -583,6 +583,11 @@ func markAdventureDead(userID id.UserID, source, location string) { // unless the seam is enabled. Uses the character name (never the Matrix handle); // skips silently if the name is unknown. func emitDeathNews(userID id.UserID, location string) { + // Gate before the char lookups: markAdventureDead fires per-member on a + // party wipe and in the sim harness, so skip the DB reads when disabled. + if !peteclient.Enabled() || !newsEmissionOn() { + return + } name := charName(userID) if name == "" { return diff --git a/internal/plugin/pete.go b/internal/plugin/pete.go index 96fede6..bb22f1c 100644 --- a/internal/plugin/pete.go +++ b/internal/plugin/pete.go @@ -266,6 +266,14 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { if !peteclient.Enabled() || !newsEmissionOn() { return } + // Claim the realm-first BEFORE the name guard, so an unnamed straggler's + // genuine first clear still seeds news_realm_firsts. Otherwise the next + // named clearer would claim it and be mis-announced as the first-ever. + // Mirrors backfillZoneFirsts, which claims before its own name check. + eventType, tier := "zone_clear", "bulletin" + if claimRealmFirst("zone", string(exp.ZoneID)) { + eventType, tier = "zone_first", "priority" + } name := charName(userID) if name == "" { return @@ -278,10 +286,6 @@ func emitZoneClearNews(userID id.UserID, exp *Expedition) { } } lvl := charLevel(userID) - eventType, tier := "zone_clear", "bulletin" - if claimRealmFirst("zone", string(exp.ZoneID)) { - eventType, tier = "zone_first", "priority" - } ts := nowUnix() disc := fmt.Sprintf("%s:%d", exp.ZoneID, ts) emitFact(peteclient.Fact{