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 = ``
// 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
}