package web import ( "crypto/subtle" "encoding/json" "fmt" "html/template" "io" "log/slog" "net/http" "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" // 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 } 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: f.OccurredAt, PublishedAt: f.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) // PRIORITY beats post live to Matrix; BULLETIN beats wait for the daily // digest. Website section always gets the row above regardless of tier. // NoPush (cold-start backfill) suppresses the live post so a launch doesn't // dump the whole back-catalogue into the channel β€” the stories still land on // the website with their backdated timestamps. if f.Tier == "priority" && !f.NoPush && 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. s.advPost(AdvPost{ GUID: f.GUID, Headline: headline, Lede: lede, ArticleURL: articleURL, Source: advSource, 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", "πŸ…" } 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 } // advPermalink builds the per-story Pete permalink used as article_url (the card // link + Matrix link). Absolute when BaseURL is configured (required for the // Matrix link to survive safeHref); relative otherwise (fine on-site). func (s *Server) advPermalink(guid string) string { if base := strings.TrimRight(s.cfg.BaseURL, "/"); base != "" { return base + "/adventure/" + guid } return "/adventure/" + 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 "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 }