Pete's side of the Adventure news feed. Receives structured game-event
facts from gogobee, templates them in Pete's warm-reporter voice, and
publishes to a new /adventure section + live Matrix posts.
- adventure.go: bearer ingest + fact-guard + 13 event templates;
/adventure/{guid} permalink (story.html); per-event SVG emblems at
/adventure/art/{type}.svg (card image + og:image); NoPush suppresses
the live Matrix post (cold-start backfill).
- adventure_digest.go: daily BULLETIN roundup at DigestHour (UTC);
unposted-in-48h = bulletins; marks them digested; per-day ?digest= URL
avoids canonical dedup.
- config AdventureConfig (enabled/ingest_token/channel/digest_hour);
web.New takes the seam + a priority poster; started in main.
- adventure theme colors; thumbURL passes through local emblem paths;
adventure pages are noindex (player-named; gap #5).
Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
371 lines
14 KiB
Go
371 lines
14 KiB
Go
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 "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 = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||
<defs>
|
||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0" stop-color="#7c5ce8"/>
|
||
<stop offset="1" stop-color="#5836b8"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<rect width="1200" height="630" fill="url(#g)"/>
|
||
<text x="600" y="300" font-size="260" text-anchor="middle" dominant-baseline="central">%s</text>
|
||
<text x="600" y="500" font-size="64" font-family="Fredoka, Nunito, system-ui, sans-serif" font-weight="700" fill="#ffffff" text-anchor="middle" letter-spacing="6" opacity="0.92">%s</text>
|
||
</svg>`
|
||
|
||
// 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:<hash>:<ts>"); 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":
|
||
// Realm-first (priority) vs personal (bulletin) share a lede.
|
||
if 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
|
||
}
|