Two things a code review turned up in the W9 seams. The verdict handler answered 400 for everything ResolveAdvOrder could fail with, not just a bad verdict. gogobee's contract says a 400 means "park this row for a human", so a SQLite busy or a disk hiccup permanently stranded an extract or a bout that was perfectly resolvable. Split the two apart with ErrBadAdvVerdict: a verdict outside the terminal set is still 400, because gogobee will never send it successfully, and a genuine storage failure is now 500 and comes back on the next poll. The push URL builders concatenated the guid and the run id raw, while every other builder beside them path-escapes because these values arrive over a wire. A guid carrying a slash sent the notification tap to a different page.
425 lines
14 KiB
Go
425 lines
14 KiB
Go
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
|
|
}
|