package web import ( "context" "encoding/json" "fmt" "log/slog" "net/url" "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 runReportPath(ev.RunID) } return advStoryURL(ev.GUID) } // advStoryURL is advPermalink's relative half, and it escapes for the same // reason: the guid arrives over a wire, and one that grew a slash would send the // notification somewhere else entirely. func advStoryURL(guid string) string { return "/adventure/" + url.PathEscape(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 }