The site could only reach somebody who was already looking at it. Push existed and adventure used none of it, so the one communal event in the game -- the Siege -- was invisible to anyone not sitting in Matrix, and a player whose adventurer died found out whenever they next opened a tab. Four opt-in categories, every one of them off until asked for: the Siege (realm-wide, begins and ends), your expedition ending, your adventurer wandering off, and a contract landing on you. Turning on news notifications is not consent to be told about the game, so nothing here enrolls anybody automatically. No new wire. Every trigger is a dispatch already landing in adventure_events, so this is Pete-side only and gogobee is untouched. Two things it needed from storage. push_subscriptions now keeps the Matrix localpart alongside the OIDC subject, because every ownership join in the schema is keyed on the localpart and the sender runs on a ticker with no session to read one from -- without it there is no way to answer "whose adventurer is this". And the alerts carry their own watermark, kept apart from the digest's: the two run on different clocks and one column would let each consume the other's backlog. The ownership join is re-read on every pass rather than trusted from the subscription row, so an opt-out or a removal closes the channel at once. It fails closed in both directions, and an unresolved owner can never fall through to a broadcast -- a game alert naming somebody's adventurer, delivered to the wrong phone, is a privacy leak dressed as a feature. An existing subscription carries watermark 0, which read literally means "has never been told anything" and would page every subscriber for the whole history of the realm on the first tick after deploy. Those rows are stamped to now and start from the next dispatch. Verified against a running Pete with a real push service, real P-256 client keys and real encryption: the right person is notified, the wrong one is not, a second pass is silent, and dropping the player from the board takes the channel with it. Claude-Session: https://claude.ai/code/session_012bxpQQJDjC1mTtLN3VVtBQ
421 lines
13 KiB
Go
421 lines
13 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// Adventure alerts: the only channel that reaches a player who isn't looking at
|
|
// the site or at Matrix.
|
|
//
|
|
// This rides entirely on facts gogobee is already sending. Every trigger below
|
|
// is a dispatch that already lands in adventure_events, so the whole phase is
|
|
// Pete-side and no new wire, event type or deploy ordering is involved.
|
|
//
|
|
// Two things separate it from the news digest it sits beside:
|
|
//
|
|
// - Its own clock. A digest is a summary and six hours late is fine; "the
|
|
// Siege has begun" six hours late is worse than silence, because the bout
|
|
// the alert is asking for may already be over.
|
|
// - Its own watermark (last_adv_notified_at). Sharing the digest's column
|
|
// would let each sender consume the other's backlog.
|
|
|
|
// advAlertInterval matches the roster tick that delivers the facts. Checking
|
|
// faster than they can arrive only burns queries.
|
|
const advAlertInterval = 2 * time.Minute
|
|
|
|
// advAlertScan caps how many dispatches one pass inspects. Generously above the
|
|
// realm's real rate (a busy day is tens of dispatches, not hundreds), so hitting
|
|
// it means something is wrong rather than something is busy — see the warning in
|
|
// sendAdventureAlerts.
|
|
const advAlertScan = 200
|
|
|
|
// advPrefsKey is where the client stores the per-category opt-ins, alongside the
|
|
// other synced preferences. Its value is a JSON object of {category: true}.
|
|
const advPrefsKey = "pete.advPush.v1"
|
|
|
|
// The alert categories. Every one is opt-in and defaults OFF: a user who turned
|
|
// on notifications did so for news, and quietly enrolling them in game alerts
|
|
// they never asked for is how a notification permission gets revoked for good.
|
|
//
|
|
// advCatSiege is realm-wide — it needs no ownership and reaches everyone who
|
|
// asked for it. The other three are owner-scoped and reach exactly one person.
|
|
const (
|
|
advCatSiege = "siege"
|
|
advCatRun = "run"
|
|
advCatDeparture = "departure"
|
|
advCatContract = "contract"
|
|
)
|
|
|
|
// advAlert is one notification a pass has decided to send.
|
|
type advAlert struct {
|
|
Category string
|
|
Title string
|
|
Body string
|
|
URL string
|
|
}
|
|
|
|
// StartAdventureAlerts launches the alert loop when push and the adventure
|
|
// section are both configured. Safe to call unconditionally.
|
|
func (s *Server) StartAdventureAlerts(ctx context.Context) {
|
|
if !s.cfg.Push.Enabled || s.auth == nil || !s.adv.Enabled {
|
|
return
|
|
}
|
|
go s.runAdventureAlerts(ctx)
|
|
}
|
|
|
|
func (s *Server) runAdventureAlerts(ctx context.Context) {
|
|
slog.Info("web: adventure alert sender started", "interval", advAlertInterval)
|
|
ticker := time.NewTicker(advAlertInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.sendAdventureAlerts()
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendAdventureAlerts runs one pass: read the dispatches nobody has been told
|
|
// about yet, and for each subscription decide whether any of them is that
|
|
// person's business.
|
|
func (s *Server) sendAdventureAlerts() {
|
|
subs, err := storage.ListPushSubscriptions()
|
|
if err != nil {
|
|
slog.Error("adv-push: list subscriptions failed", "err", err)
|
|
return
|
|
}
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
|
|
// A row written before adventure alerts existed carries watermark 0, which
|
|
// read literally means "has never been told about anything" and would page the
|
|
// subscriber for the entire history of the realm on the first tick after
|
|
// deploy. Stamp those to now and let them start from the next dispatch. This
|
|
// is the deploy-safety valve for the whole phase.
|
|
live := subs[:0]
|
|
for _, sub := range subs {
|
|
if sub.LastAdvNotifiedAt == 0 {
|
|
if err := storage.TouchAdvPushSubscription(sub.Endpoint, now); err != nil {
|
|
slog.Error("adv-push: seed watermark failed", "sub", sub.UserSub, "err", err)
|
|
}
|
|
continue
|
|
}
|
|
live = append(live, sub)
|
|
}
|
|
if len(live) == 0 {
|
|
return
|
|
}
|
|
|
|
// One scan serves every subscriber: read from the oldest watermark in the set
|
|
// and let each row be filtered per subscription below.
|
|
oldest := live[0].LastAdvNotifiedAt
|
|
for _, sub := range live {
|
|
if sub.LastAdvNotifiedAt < oldest {
|
|
oldest = sub.LastAdvNotifiedAt
|
|
}
|
|
}
|
|
events, err := storage.AdvEventsSince(oldest, advAlertScan)
|
|
if err != nil {
|
|
slog.Error("adv-push: scan dispatches failed", "err", err)
|
|
return
|
|
}
|
|
if len(events) == 0 {
|
|
return
|
|
}
|
|
if len(events) == advAlertScan {
|
|
// The scan was capped, so dispatches older than the window exist and are
|
|
// about to be skipped by the watermark advance below. Alerts are
|
|
// time-sensitive enough that dropping the tail is right; being quiet about
|
|
// it is not.
|
|
slog.Warn("adv-push: scan hit its cap; older dispatches skipped",
|
|
"cap", advAlertScan, "since", oldest)
|
|
}
|
|
// events[0] is the newest in the window (occurred_at DESC) and is where every
|
|
// watermark lands this pass, sent or not.
|
|
newest := events[0].OccurredAt
|
|
|
|
// Both lookups are per-user, and a user can hold several endpoints (phone,
|
|
// desktop). Cache within the pass so a two-device user costs one prefs parse
|
|
// and one ownership join rather than two.
|
|
catsByUser := make(map[string]map[string]bool)
|
|
nameByLocalpart := make(map[string]string)
|
|
|
|
sent, pruned := 0, 0
|
|
for _, sub := range live {
|
|
cats, ok := catsByUser[sub.UserSub]
|
|
if !ok {
|
|
cats = advCategoriesFor(sub.UserSub)
|
|
catsByUser[sub.UserSub] = cats
|
|
}
|
|
if len(cats) == 0 {
|
|
// Nothing enabled. Still advance, so turning a category on later starts
|
|
// from that moment rather than replaying the backlog it was off for.
|
|
s.touchAdv(sub.Endpoint, newest)
|
|
continue
|
|
}
|
|
|
|
// The ownership join is re-read every pass rather than trusted from the
|
|
// subscription row, and that is deliberate: an opt-out, a removal or a
|
|
// character change has to close the channel immediately, exactly as
|
|
// runReportLinkFor re-resolves rather than trusting a stored id.
|
|
mine := ""
|
|
if sub.Localpart != "" {
|
|
if cached, ok := nameByLocalpart[sub.Localpart]; ok {
|
|
mine = cached
|
|
} else {
|
|
if name, ok := storage.AdvCharacterForOwner(sub.Localpart); ok {
|
|
mine = name
|
|
}
|
|
nameByLocalpart[sub.Localpart] = mine
|
|
}
|
|
}
|
|
|
|
var top *advAlert
|
|
extra := 0
|
|
for _, ev := range events {
|
|
if ev.OccurredAt <= sub.LastAdvNotifiedAt {
|
|
continue // this endpoint has already been told
|
|
}
|
|
alert, ok := advAlertFor(ev, mine)
|
|
if !ok || !cats[alert.Category] {
|
|
continue
|
|
}
|
|
if top == nil {
|
|
// events are newest-first, so the first match is the newest match.
|
|
a := alert
|
|
top = &a
|
|
continue
|
|
}
|
|
extra++
|
|
}
|
|
if top == nil {
|
|
s.touchAdv(sub.Endpoint, newest)
|
|
continue
|
|
}
|
|
|
|
gone, err := s.sendPush(sub, buildAdvPayload(*top, extra))
|
|
if gone {
|
|
if derr := storage.RemovePushSubscription(sub.Endpoint); derr != nil {
|
|
slog.Error("adv-push: prune gone subscription failed", "err", derr)
|
|
} else {
|
|
pruned++
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
// Leave the watermark alone: a transient push-service failure should be
|
|
// retried on the next tick, not swallowed. The alert is at most
|
|
// advAlertInterval late, and the events are still in the window.
|
|
slog.Warn("adv-push: send failed", "sub", sub.UserSub, "err", err)
|
|
continue
|
|
}
|
|
s.touchAdv(sub.Endpoint, newest)
|
|
sent++
|
|
}
|
|
if sent > 0 || pruned > 0 {
|
|
slog.Info("adv-push: pass complete", "sent", sent, "pruned", pruned, "subscriptions", len(live))
|
|
}
|
|
}
|
|
|
|
func (s *Server) touchAdv(endpoint string, ts int64) {
|
|
if err := storage.TouchAdvPushSubscription(endpoint, ts); err != nil {
|
|
slog.Error("adv-push: advance watermark failed", "endpoint", endpoint, "err", err)
|
|
}
|
|
}
|
|
|
|
// advAlertFor decides whether one dispatch is worth waking somebody for, and in
|
|
// what words. mine is the character name belonging to the subscriber, or "" when
|
|
// Pete cannot establish one — in which case only realm-wide alerts can match.
|
|
//
|
|
// Every owner-scoped branch compares against mine and nothing else. There is no
|
|
// path here where an unresolved owner falls through to a broadcast: a game alert
|
|
// naming somebody's adventurer, delivered to the wrong phone, is a privacy leak
|
|
// dressed as a feature.
|
|
func advAlertFor(ev storage.AdvEvent, mine string) (advAlert, bool) {
|
|
switch ev.EventType {
|
|
case "siege_start":
|
|
boss := ev.Boss
|
|
if boss == "" {
|
|
boss = "Something"
|
|
}
|
|
return advAlert{
|
|
Category: advCatSiege,
|
|
Title: "The Siege has begun",
|
|
Body: fmt.Sprintf("%s is camped outside the town. Everyone gets one bout a day.", boss),
|
|
URL: "/adventure/siege",
|
|
}, true
|
|
case "siege_win":
|
|
return advAlert{
|
|
Category: advCatSiege,
|
|
Title: "The town holds",
|
|
Body: siegeOutcomeBody(ev, "went down"),
|
|
URL: "/adventure/siege",
|
|
}, true
|
|
case "siege_loss":
|
|
return advAlert{
|
|
Category: advCatSiege,
|
|
Title: "The Siege is over",
|
|
Body: siegeOutcomeBody(ev, "walked away still standing"),
|
|
URL: "/adventure/siege",
|
|
}, true
|
|
}
|
|
|
|
// Everything below is about one person, so an unmatched subject ends it here.
|
|
if mine == "" || ev.Subject != mine {
|
|
return advAlert{}, false
|
|
}
|
|
|
|
switch ev.EventType {
|
|
case "death":
|
|
return advAlert{
|
|
Category: advCatRun,
|
|
Title: fmt.Sprintf("%s fell in %s", mine, orPlace(ev.Zone)),
|
|
Body: "The expedition is over. They'll need picking up.",
|
|
URL: advRunOrStoryURL(ev),
|
|
}, true
|
|
case "zone_clear":
|
|
return advAlert{
|
|
Category: advCatRun,
|
|
Title: fmt.Sprintf("%s cleared %s", mine, orPlace(ev.Zone)),
|
|
Body: "They're through it and on the way home. Read how it went.",
|
|
URL: advRunOrStoryURL(ev),
|
|
}, true
|
|
case "retreat":
|
|
return advAlert{
|
|
Category: advCatRun,
|
|
Title: fmt.Sprintf("%s backed out of %s", mine, orPlace(ev.Zone)),
|
|
Body: "Everyone came home breathing. The run's finished either way.",
|
|
URL: advRunOrStoryURL(ev),
|
|
}, true
|
|
case "departure":
|
|
return advAlert{
|
|
Category: advCatDeparture,
|
|
Title: fmt.Sprintf("%s got bored and left", mine),
|
|
Body: fmt.Sprintf("No orders, no escort. They packed the cheap kit and set off into %s on their own.",
|
|
orPlace(ev.Zone)),
|
|
URL: advStoryURL(ev.GUID),
|
|
}, true
|
|
case "mischief_contract":
|
|
body := "Somebody paid to have something sent after them, and isn't saying who. Survive it and the money's theirs."
|
|
if ev.Opponent != "" {
|
|
body = fmt.Sprintf("%s paid for it and signed the thing. It's out there looking right now.", ev.Opponent)
|
|
}
|
|
return advAlert{
|
|
Category: advCatContract,
|
|
Title: fmt.Sprintf("There's a contract out on %s", mine),
|
|
Body: body,
|
|
URL: advStoryURL(ev.GUID),
|
|
}, true
|
|
}
|
|
return advAlert{}, false
|
|
}
|
|
|
|
// siegeOutcomeBody names the boss when the fact carried one. The verb differs by
|
|
// outcome, so it is the caller's.
|
|
func siegeOutcomeBody(ev storage.AdvEvent, verb string) string {
|
|
if ev.Boss == "" {
|
|
return fmt.Sprintf("It %s. See how the town did.", verb)
|
|
}
|
|
return fmt.Sprintf("%s %s. See how the town did.", ev.Boss, verb)
|
|
}
|
|
|
|
func orPlace(zone string) string {
|
|
if zone == "" {
|
|
return "the dark"
|
|
}
|
|
return zone
|
|
}
|
|
|
|
// advRunOrStoryURL prefers the run report, which is the richer landing place for
|
|
// an expedition that has just ended, and falls back to the dispatch permalink.
|
|
// Runs that predate the liveblog carry no run id and land on the story, which is
|
|
// what they have always done.
|
|
func advRunOrStoryURL(ev storage.AdvEvent) string {
|
|
if ev.RunID != "" {
|
|
return "/adventure/run/" + ev.RunID
|
|
}
|
|
return advStoryURL(ev.GUID)
|
|
}
|
|
|
|
func advStoryURL(guid string) string {
|
|
return "/adventure/" + guid
|
|
}
|
|
|
|
// buildAdvPayload renders the notification JSON the service worker expects. The
|
|
// tag is per-category so a Siege alert can't silently replace an unread alert
|
|
// about somebody's own adventurer on the lock screen.
|
|
func buildAdvPayload(a advAlert, extra int) []byte {
|
|
body := a.Body
|
|
if extra == 1 {
|
|
body += " Plus 1 other update."
|
|
} else if extra > 1 {
|
|
body += fmt.Sprintf(" Plus %d other updates.", extra)
|
|
}
|
|
b, _ := json.Marshal(map[string]string{
|
|
"title": a.Title,
|
|
"body": body,
|
|
"url": a.URL,
|
|
"tag": "pete-adv-" + a.Category,
|
|
})
|
|
return b
|
|
}
|
|
|
|
// advCategoriesFor returns the alert categories a user has switched on. An
|
|
// absent key, an unparseable blob or a user who has never opened the settings
|
|
// drawer all yield an empty set, which sends nothing — the safe direction for a
|
|
// channel that interrupts people.
|
|
func advCategoriesFor(sub string) map[string]bool {
|
|
return userPrefBoolSet(sub, advPrefsKey)
|
|
}
|
|
|
|
// userPrefBoolSet reads one {name: bool} map out of a user's stored preferences
|
|
// blob, keeping only the true entries.
|
|
//
|
|
// The blob mirrors localStorage: a JSON object whose values are themselves JSON
|
|
// *strings*. That double encoding is the client's doing, not ours, so unwrap it
|
|
// — while tolerating a bare object, since a hand-written or migrated blob may
|
|
// carry one. Any parse failure yields an empty set, and every caller treats an
|
|
// empty set as "no", so a corrupt blob costs a feature rather than misfiring it.
|
|
func userPrefBoolSet(sub, key string) map[string]bool {
|
|
out := map[string]bool{}
|
|
blob, err := storage.GetUserPrefs(sub)
|
|
if err != nil || blob == "" {
|
|
return out
|
|
}
|
|
var prefs map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(blob), &prefs); err != nil {
|
|
return out
|
|
}
|
|
raw, ok := prefs[key]
|
|
if !ok {
|
|
return out
|
|
}
|
|
inner := []byte(raw)
|
|
var asStr string
|
|
if err := json.Unmarshal(raw, &asStr); err == nil {
|
|
inner = []byte(asStr)
|
|
}
|
|
var set map[string]bool
|
|
if err := json.Unmarshal(inner, &set); err != nil {
|
|
return out
|
|
}
|
|
for name, on := range set {
|
|
if on {
|
|
out[strings.TrimSpace(name)] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|