package web import ( "crypto/subtle" "encoding/json" "fmt" "html/template" "io" "log/slog" "net/http" "net/url" "strings" "time" "pete/internal/storage" ) // AdvFact is the game-event fact gogobee POSTs to Pete. It mirrors the contract // in pete_adventure_news_voice.md. Names are character names only (never Matrix // handles) and Actors is the allow-list of the only names permitted to appear in // rendered output. type AdvFact struct { GUID string `json:"guid"` EventType string `json:"event_type"` Tier string `json:"tier"` // "priority" | "bulletin" Actors []string `json:"actors"` Subject string `json:"subject"` Opponent string `json:"opponent"` Boss string `json:"boss"` Zone string `json:"zone"` Region string `json:"region"` Level int `json:"level"` Count int `json:"count"` Outcome string `json:"outcome"` Stakes string `json:"stakes"` ClassRace string `json:"class_race"` Milestone string `json:"milestone"` OccurredAt int64 `json:"occurred_at"` NoPush bool `json:"no_push"` } // AdvPost is a priority adventure item to post live to Matrix. Kept minimal and // web-local so the web package needs no dependency on internal/poster. type AdvPost struct { GUID string Headline string Lede string ImageURL string ArticleURL string Source string Channel string } // PriorityPoster posts a priority adventure item to Matrix immediately. main // adapts *poster.Queue.PostNow to this; nil in web-only/local modes. type PriorityPoster func(AdvPost) const advSource = "Pete" // advBackfillEvent is the synthetic post_log event id used to retire a no_push // (cold-start backfill) dispatch against the daily digest. It never went to // Matrix; the row exists only so the digest skips it. const advBackfillEvent = "adv-backfill" // handleAdventureIngest receives a game-event fact from gogobee, templates it // into a deterministic story, publishes it to the /adventure section, and posts // PRIORITY beats live to Matrix. Bearer-authed; idempotent on the fact GUID. func (s *Server) handleAdventureIngest(w http.ResponseWriter, r *http.Request) { if !s.adv.Enabled { http.NotFound(w, r) return } if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var f AdvFact if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&f); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if f.GUID == "" || f.EventType == "" { http.Error(w, "guid and event_type are required", http.StatusBadRequest) return } // Fact-guard: any player name we render must be in the actors allow-list. // gogobee pre-sanitizes, but Pete never trusts the channel β€” this is the // last line before a name reaches a public page. if !factGuard(f) { slog.Warn("adventure ingest: fact-guard rejected", "guid", f.GUID, "event_type", f.EventType) http.Error(w, "fact-guard: subject/opponent not in actors", http.StatusBadRequest) return } headline, lede, ok := renderAdventure(f) if !ok { http.Error(w, "unknown event_type", http.StatusBadRequest) return } // Idempotent: a re-delivered fact (gogobee retry) is a no-op success. if storage.IsGUIDSeen(f.GUID) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("duplicate")) return } // A fact with no occurred_at would otherwise be stored at the Unix epoch: // dated 1970 on the permalink, pinned to the bottom of the section, and // outside every digest window. Treat "missing" as "now". occurredAt := f.OccurredAt if occurredAt <= 0 { occurredAt = time.Now().Unix() } articleURL := s.advPermalink(f.GUID) imageURL := advArtURL(f.EventType) if err := storage.InsertStory(&storage.Story{ GUID: f.GUID, Headline: headline, Lede: lede, ImageURL: imageURL, ArticleURL: articleURL, Source: advSource, Channel: "adventure", Classified: true, SeenAt: occurredAt, PublishedAt: occurredAt, }); err != nil { slog.Error("adventure ingest: insert failed", "guid", f.GUID, "err", err) http.Error(w, "insert failed", http.StatusInternalServerError) return } slog.Info("adventure ingest: published", "guid", f.GUID, "event_type", f.EventType, "tier", f.Tier) // NoPush (cold-start backfill) means "never goes to Matrix". Suppressing only // the live post isn't enough: the digest collects adventure rows that carry no // post_log entry, so a backfilled bulletin would still be swept into the next // roundup β€” the back-catalogue dump NoPush exists to prevent. Retire the guid // against the digest up front instead. if f.NoPush { storage.MarkAdventureDigested([]string{f.GUID}, advBackfillEvent) w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) return } // PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily // digest. Website section always gets the row above regardless of tier. if f.Tier == "priority" && s.advPost != nil && s.adv.Channel != "" { // No ImageURL: the emblem is an SVG (Matrix clients often block SVG // media), and the link's og:image carries the preview instead. // No Source: the source tag exists to credit an outlet Pete is relaying // (`ars technica`). On his own reporting it renders as him signing his // own name under his own message. s.advPost(AdvPost{ GUID: f.GUID, Headline: headline, Lede: lede, ArticleURL: articleURL, Channel: s.adv.Channel, }) } w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } // advEventMeta maps an event_type to a short display label and emoji used by the // permalink page (and, later, OG art selection). Unknown types fall back to a // neutral dispatch label so a new gogobee event never renders blank. func advEventMeta(eventType string) (label, emoji string) { switch eventType { case "siege_start", "siege_win", "siege_loss": return "The Siege", "🏰" case "boss_first", "boss_kill": return "Boss down", "πŸ‰" case "zone_first": return "First clear", "πŸ—ΊοΈ" case "zone_clear": return "Zone cleared", "πŸ—ΊοΈ" case "death": return "In memoriam", "πŸͺ¦" case "arrival": return "New arrival", "πŸ‘‹" case "standings", "rival_result": return "The rival board", "βš”οΈ" case "pete_duel_loss", "pete_duel_win": return "Pete's duels", "🀝" case "milestone": return "Milestone", "πŸ…" case "retreat": return "Pulled out", "πŸŽ’" case "departure": return "Wandered off", "πŸšͺ" case "mischief_contract": return "Coin on their head", "😈" case "mischief_survived": return "They walked away", "πŸ›‘οΈ" case "mischief_downed": return "The contract landed", "πŸ’€" case "mischief_fizzled": return "Nobody home", "πŸšͺ" } return "Dispatch", "πŸ“£" } // advArtURL is the card/OG image for a dispatch: a themed SVG emblem served by // handleAdventureArt, keyed on event_type. Local (root-relative) so it bypasses // the external-image thumbnailer. func advArtURL(eventType string) string { return "/adventure/art/" + eventType + ".svg" } // handleAdventureArt renders the themed emblem for an event type β€” an adventure // gradient with the event's emoji and label. Deterministic and dependency-free // (no external asset), so every dispatch card has visual identity instead of the // blank placeholder that made the section look broken next to RSS cards. func (s *Server) handleAdventureArt(w http.ResponseWriter, r *http.Request) { if !s.adv.Enabled { http.NotFound(w, r) return } eventType := strings.TrimSuffix(r.PathValue("type"), ".svg") label, emoji := advEventMeta(eventType) w.Header().Set("Content-Type", "image/svg+xml; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=86400") _, _ = fmt.Fprintf(w, advArtSVG, template.HTMLEscapeString(emoji), template.HTMLEscapeString(strings.ToUpper(label))) } // advArtSVG is the emblem template: %s = emoji, %s = label. 1200Γ—630 (the OG // card ratio) so the same image works as a link-preview image. const advArtSVG = ` %s %s ` // advStoryPage is the per-story permalink view. It reuses the shared layout so a // dispatch reads like the rest of the site, with an adventure-themed hero. type advStoryPage struct { pageData EventLabel string Emoji string Headline string Body string Region string When string Permalink string } // handleAdventureStory serves the server-rendered permalink for one dispatch // (the article_url every ingested story points at). Public and cacheable; 404s // when the section is disabled or the guid is unknown. Character names in the // stored headline/body already passed the ingest fact-guard, so nothing // player-controlled reaches here unchecked. func (s *Server) handleAdventureStory(w http.ResponseWriter, r *http.Request) { if !s.adv.Enabled { http.NotFound(w, r) return } guid := r.PathValue("guid") st, err := storage.GetStoryByGUID(guid) if err != nil || st == nil || st.Channel != "adventure" { http.NotFound(w, r) return } s.track(r, "adventure") // event_type is encoded in the guid prefix (e.g. "death::"); fall // back to the neutral dispatch meta when it isn't a known type. eventType, _, _ := strings.Cut(guid, ":") label, emoji := advEventMeta(eventType) body := st.Content if strings.TrimSpace(body) == "" { body = st.Lede // template-only dispatches carry the write-up in the lede } base := s.base(r) base.Active = "adventure" base.NoIndex = true // player-named page; keep out of search indexes (gap #5) if abs := strings.TrimRight(s.cfg.BaseURL, "/"); abs != "" { base.OGImage = abs + advArtURL(eventType) // emblem for link unfurls } s.render(w, "story", advStoryPage{ pageData: base, EventLabel: label, Emoji: emoji, Headline: st.Headline, Body: body, Region: "", // reserved: region isn't stored on the row yet When: time.Unix(st.SeenAt, 0).UTC().Format("Jan 2, 2006"), Permalink: s.advPermalink(guid), }) } // bearerOK checks the Authorization: Bearer header against the configured ingest // token in constant time. func (s *Server) bearerOK(r *http.Request) bool { const prefix = "Bearer " h := r.Header.Get("Authorization") if !strings.HasPrefix(h, prefix) || s.adv.IngestToken == "" { return false } got := strings.TrimPrefix(h, prefix) return subtle.ConstantTimeCompare([]byte(got), []byte(s.adv.IngestToken)) == 1 } // siteURL makes a root-relative path absolute against BaseURL. Links that go out // to Matrix need the absolute form to survive safeHref; when BaseURL isn't // configured the relative form is all we have, and it's still fine on-site. func (s *Server) siteURL(path string) string { return strings.TrimRight(s.cfg.BaseURL, "/") + path } // advPermalink builds the per-story Pete permalink used as article_url (the card // link + Matrix link). The guid is path-escaped: it's ingest-supplied, and a // stray "/" or "?" would otherwise produce a link that routes somewhere else. func (s *Server) advPermalink(guid string) string { return s.siteURL("/adventure/" + url.PathEscape(guid)) } // factGuard verifies every player-name field we might render is present in the // actors allow-list. Boss/zone/region/milestone are game-authored content, not // player-controlled, so they are not guarded. func factGuard(f AdvFact) bool { allow := make(map[string]bool, len(f.Actors)) for _, a := range f.Actors { if a != "" { allow[a] = true } } if f.Subject != "" && !allow[f.Subject] { return false } if f.Opponent != "" && !allow[f.Opponent] { return false } return true } // renderAdventure returns the deterministic headline + lede for a fact. Copied // verbatim from the voice spec (pete_adventure_news_voice.md). Template-only β€” // no LLM β€” so output is safe and reproducible. ok is false for an unknown type. func renderAdventure(f AdvFact) (headline, lede string, ok bool) { atLevel := "" if f.Level > 0 { atLevel = fmt.Sprintf(", at level %d", f.Level) } switch f.EventType { case "siege_start": return fmt.Sprintf("Breaking: %s is marching on the town.", f.Boss), fmt.Sprintf("Folks, this is the big one β€” %s has camped outside the gates and the whole community's needed to turn it back. You've got %s. Let's rally.", f.Boss, f.Stakes), true case "siege_win": return fmt.Sprintf("The town holds! %s turned back.", f.Boss), fmt.Sprintf("What a turnout β€” %d defenders stood shoulder to shoulder and sent %s packing. Spoils are going out now. Proud of you all.", f.Count, f.Boss), true case "siege_loss": return fmt.Sprintf("Heavy news: %s broke through.", f.Boss), fmt.Sprintf("We gave it everything, but %s got past the gates and took its tribute. We'll be ready next time β€” heads up, everyone.", f.Boss), true case "boss_first": return fmt.Sprintf("First ever: %s brings down %s.", f.Subject, f.Boss), fmt.Sprintf("History in %s today β€” %s is the first anyone's seen clear %s. Nobody had done it before. Hats off.", f.Region, f.Subject, f.Boss), true case "boss_kill": return fmt.Sprintf("%s takes down %s again.", f.Subject, f.Boss), fmt.Sprintf("Another clean run in %s today. Routine for %s by now β€” but still worth a nod.", f.Zone, f.Subject), true case "zone_first", "zone_clear": // gogobee splits the realm's first-ever clear (zone_first, priority) from a // later repeat (zone_clear, bulletin); they share a lede but differ in // headline. Fall back to the tier for a legacy zone_first that predates the // split. if f.EventType == "zone_first" || f.Tier == "priority" { headline = fmt.Sprintf("%s cleared for the very first time.", f.Zone) } else { headline = fmt.Sprintf("%s clears %s.", f.Subject, f.Zone) } inRegion := "" if f.Region != "" { inRegion = " in " + f.Region } return headline, fmt.Sprintf("%s made it through %s%s%s. Nicely done.", f.Subject, f.Zone, inRegion, atLevel), true case "death": return fmt.Sprintf("We lost %s in %s.", f.Subject, f.Zone), fmt.Sprintf("Sad news to pass along: %s fell at level %d in %s. The graveyard's a little fuller tonight. Rest easy.", f.Subject, f.Level, f.Zone), true case "retreat": // An expedition that came apart without killing anyone. Until gogobee // started sending these, the feed had no way to say "it went badly and // everyone lived" β€” so it never said it, and the classes that retreat // often simply never appeared. Warm, not a failure notice: everyone came // home, and that is the part Pete leads with. howFar := "barely a day in" if f.Count > 1 { howFar = fmt.Sprintf("%d days in", f.Count) } return fmt.Sprintf("%s backed out of %s.", f.Subject, f.Zone), fmt.Sprintf("%s turned around %s β€” %s got the better of them this time%s, and they made the call to walk out rather than push it. Everybody came home breathing, which is the bit that counts. That dungeon'll still be there next week.", f.Subject, howFar, f.Zone, atLevel), true case "departure": // A bored adventurer let themselves out. Nobody sent them β€” they got // restless waiting on a player who wasn't coming, took the cheap supplies // they could afford, and went. Pete plays it straight and a little fond; // the joke tells itself, and the player it's about may well be reading. return fmt.Sprintf("%s got bored and left without waiting.", f.Subject), fmt.Sprintf("No orders, no escort, no fuss β€” %s packed the cheapest kit on the shelf and set off into %s%s. Nobody told them to. Nobody talked them out of it either. We'll let you know how it goes.", f.Subject, f.Zone, atLevel), true case "mischief_contract": // Somebody paid to have a monster sent after an adventurer who is out in a // dungeon right now. Anonymous unless the buyer paid extra to sign it, and // the anonymity is the story: Pete reports the money, not the name he // doesn't have. Opponent carries the buyer only when it's public. if f.Opponent != "" { return fmt.Sprintf("%s has put %s on %s's head.", f.Opponent, f.Stakes, f.Subject), fmt.Sprintf("No secret about it β€” %s paid for a %s to go find %s out in whatever hole they're currently down, and signed the thing. It's out there looking right now. If %s comes back breathing, they keep a cut of that money.", f.Opponent, strings.ToLower(f.Boss), f.Subject, f.Subject), true } return fmt.Sprintf("Someone's put %s on %s's head.", f.Stakes, f.Subject), fmt.Sprintf("Word came in quiet: %s has been paid for a %s to go looking for %s, and whoever paid it isn't saying so. It's already out there. Survive it and the money's theirs β€” and we all find out who signed the cheque.", f.Stakes, strings.ToLower(f.Boss), f.Subject), true case "mischief_survived": // The unseal. A survival is the only thing that names an anonymous buyer, // and it is the whole brake on casual griefing β€” so Pete leads with it. return fmt.Sprintf("%s walked away from it. It was %s who paid.", f.Subject, f.Opponent), fmt.Sprintf("%s came for %s, and %s is the one still standing β€” %s richer for the trouble. The contract's been opened up, and the name inside it is %s. Make of that what you will, folks.", f.Boss, f.Subject, f.Subject, f.Stakes, f.Opponent), true case "mischief_downed": who := "Nobody's saying who paid for it" if f.Opponent != "" { who = fmt.Sprintf("%s paid for it, and put their name on it", f.Opponent) } return fmt.Sprintf("%s didn't walk away.", f.Subject), fmt.Sprintf("A %s found %s mid-run%s and put them on the floor. They're being carried home β€” alive, which is more than the contract asked for, but that expedition's finished. %s.", f.Boss, f.Subject, atLevel, who), true case "mischief_fizzled": return fmt.Sprintf("The monster sent for %s arrived to an empty dungeon.", f.Subject), fmt.Sprintf("Somebody spent good money to have %s ambushed, and %s had already gone home. It wandered the halls for a bit and left. Most of the fee's been refunded. The rest, the town's keeping.", f.Subject, f.Subject), true case "arrival": return fmt.Sprintf("Welcome to the realm, %s!", f.Subject), fmt.Sprintf("A new %s just walked through the gates. Say hello if you see them out there.", f.ClassRace), true case "standings": return "The rival board's been shaken up.", fmt.Sprintf("%s is on the move β€” here's where the standings sit today.", f.Subject), true case "rival_result": return fmt.Sprintf("%s settles the score with %s.", f.Subject, f.Opponent), fmt.Sprintf("Their duel went %s's way today, and the board reflects it. Good match, you two.", f.Subject), true case "pete_duel_loss": if f.Tier == "priority" { headline = fmt.Sprintf("You got me, %s.", f.Subject) } else { headline = fmt.Sprintf("%s got the better of me again.", f.Subject) } return headline, fmt.Sprintf("Credit where it's due β€” %s beat me fair and square. Good duel. I'll want a rematch when you're ready.", f.Subject), true case "pete_duel_win": return fmt.Sprintf("Held my ground against %s today.", f.Subject), fmt.Sprintf("Closer than the record will show, honestly β€” %s pushed me. Rematch whenever you like.", f.Subject), true case "milestone": return fmt.Sprintf("%s hits %s.", f.Subject, f.Milestone), fmt.Sprintf("One for the books β€” %s just reached %s. The long road continues.", f.Subject, f.Milestone), true } return "", "", false }