8 Commits

Author SHA1 Message Date
prosolis
99574db3e9 news: the adventure page gets something that's actually happening
Every dispatch Pete publishes is an accomplishment — a death, a clear, a
milestone — and an accomplishment is a newspaper clipping the moment it lands.
No refresh interval fixes that. So the page never felt alive, and it never was
going to.

The board is the other kind of thing: state that is currently true. gogobee
pushes the whole roster, we replace ours with it, and it renders above the
clippings. An open tab re-polls so it keeps telling the truth.

Replace, never merge: anyone gogobee omits (opted out, no character) drops off
the public page. That omission IS the opt-out — a standing row showing class,
level and zone names the player anyway, so "an adventurer" would have been a fig
leaf.

The snapshot time lives in its own row, because an empty board is ambiguous:
nobody playing, or gogobee stopped talking to us. The page has to tell those
apart — one is a quiet realm, the other is a board that lies confidently, which
is worse than one that admits it lost the wire.

Also teaches Pete "departure", so a bored adventurer letting itself out is news.
2026-07-13 18:05:38 -07:00
prosolis
8cb5b38599 Adventure: stop signing my own posts
The source tag credits an outlet Pete is relaying (`ars technica`). On
his own reporting it rendered as a trailing `pete` under a message he
already sent - him signing his own name. Drop Source on adventure posts
and omit the tag line entirely when there's nothing to credit; RSS posts
keep theirs.
2026-07-12 22:01:12 -07:00
prosolis
a614077cff Adventure: Pete learns the word "retreat"
gogobee has started filing a `retreat` bulletin — an expedition that ended with
the player walking out alive. Pete had no case for it, and handleAdventureIngest
answers an unrecognized event_type with 400.

That failure is silent and total. gogobee's sender treats any non-2xx as a
failure, retries eight times over roughly two hours, and then parks the row for
good. A gogobee emitting `retreat` at a Pete who doesn't know the word would not
log an error anyone reads — it would just quietly drop every retreat the realm
ever files.

So Pete has to learn it BEFORE gogobee starts saying it. Deploy order matters
here, and it is Pete first.

The copy leads with everyone coming home, because that is the true and the kind
part: a retreat is a bad day, not a funeral. Bulletin tier — it rides the daily
digest and does not interrupt the room. Announcing every failed run live would
be a firehose, and an unkind one.

https://claude.ai/code/session_01B2MwktU4RgfWkar8HM3zZn
2026-07-12 10:33:53 -07:00
prosolis
82d1c6ebeb Adventure: gate live beats on [adventure], not posting.enabled
posting.enabled governs the RSS pipeline's Matrix chatter. Adventure facts
are push-based -- they arrive from gogobee at ingest, are never polled,
classified or paced, and are excluded from the round-robin rotation -- so
folding them under the same flag made 'news on the web only, adventure live
in the games room' inexpressible. Gate advPost on [adventure].enabled + a
configured channel instead; a nil poster still shuts off both the live beats
and the digest loop.
2026-07-11 08:25:29 -07:00
prosolis
10bcc78c51 Merge adventure-news: gogobee adventure news section 2026-07-11 08:07:58 -07:00
prosolis
8e0d6aff3e Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag)
- Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest
- no_push now retires a fact against the digest, not just the live post
- Default a missing occurred_at to now instead of the Unix epoch
- Keep protocol-relative image URLs on the guarded thumbnailer
- Make digest_hour=0 (midnight UTC) settable
- Quote the true window total in the digest, not the truncated slice
- Hide the Adventure section entirely when it's disabled
2026-07-11 08:07:40 -07:00
prosolis
9bf56cbb4e Adventure news: add zone_clear event type
gogobee now splits the realm's first-ever clear (zone_first, priority)
from a later repeat (zone_clear, bulletin) so a repeat is never filed as
a first-ever. Handle both:

- renderAdventure: zone_first keeps the "cleared for the very first
  time" headline; zone_clear gets the personal "{subject} clears {zone}"
  headline, sharing the lede. A tier fallback still handles a legacy
  zone_first that predates the split.
- advEventMeta: zone_clear -> "Zone cleared" (distinct from zone_first's
  "First clear"), so a repeat's permalink page and emblem aren't stamped
  "First clear".

The GUID contract is otherwise unchanged: prefix == event_type, so the
permalink label derives correctly. Test: TestRenderZoneTaxonomy.

Deploy this before the matching gogobee change — a zone_clear fact hits
an un-updated Pete as an unknown event_type (400).
2026-07-11 07:44:15 -07:00
prosolis
4c671fb410 Adventure section: ingest, permalink, digest, emblems, noindex
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
2026-07-11 00:53:59 -07:00
24 changed files with 1806 additions and 35 deletions

View File

@@ -13,13 +13,44 @@ import (
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
type Config struct {
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Sources []SourceConfig `toml:"sources"`
Matrix MatrixConfig `toml:"matrix"`
Posting PostingConfig `toml:"posting"`
Storage StorageConfig `toml:"storage"`
Web WebConfig `toml:"web"`
Adventure AdventureConfig `toml:"adventure"`
Sources []SourceConfig `toml:"sources"`
}
// AdventureConfig wires the gogobee adventure-news seam: gogobee POSTs
// game-event facts to Pete's ingest endpoint, Pete templates them into stories
// on the /adventure section and posts PRIORITY beats live to Matrix. Disabled by
// default — the endpoint 404s and no adventure channel appears until enabled.
type AdventureConfig struct {
Enabled bool `toml:"enabled"`
// IngestToken is the shared bearer secret gogobee presents on
// POST /api/ingest/adventure. Required when enabled. Use ${ENV_VAR}.
IngestToken string `toml:"ingest_token"`
// Channel is the Matrix channel name (a key in [matrix.channels]) that
// PRIORITY adventure beats post to live. If it isn't a configured Matrix
// channel, adventure runs website-only (stories still appear on /adventure).
Channel string `toml:"channel"`
// DigestHour is the UTC hour [0,23] the daily bulletin digest posts. Default
// 17. A pointer so digest_hour = 0 (midnight UTC) is distinguishable from
// "unset" and doesn't get silently rewritten to the default.
DigestHour *int `toml:"digest_hour"`
}
// DigestHourOrDefault is the UTC hour the daily digest posts, resolving the
// unset case. Safe on a zero-value AdventureConfig.
func (a AdventureConfig) DigestHourOrDefault() int {
if a.DigestHour == nil {
return defaultDigestHour
}
return *a.DigestHour
}
const defaultDigestHour = 17
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
type WebConfig struct {
Enabled bool `toml:"enabled"`
@@ -228,6 +259,21 @@ func (c *Config) validate() error {
}
}
if c.Adventure.Enabled {
if c.Adventure.IngestToken == "" {
return fmt.Errorf("adventure.ingest_token is required when adventure is enabled (the bearer secret gogobee presents)")
}
if h := c.Adventure.DigestHourOrDefault(); h < 0 || h > 23 {
return fmt.Errorf("adventure.digest_hour must be 0-23")
}
if c.Adventure.Channel != "" {
if _, ok := c.Matrix.Channels[c.Adventure.Channel]; !ok {
slog.Warn("adventure.channel is not a configured Matrix channel — adventure runs website-only",
"channel", c.Adventure.Channel)
}
}
}
for i, s := range c.Sources {
if s.Name == "" {
return fmt.Errorf("sources[%d].name is required", i)

View File

@@ -37,7 +37,9 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
plainParts = append(plainParts, s.Lede)
}
plainParts = append(plainParts, formatSourceTag(s.Source, s.Platforms, false))
if tag := formatSourceTag(s.Source, s.Platforms, false); tag != "" {
plainParts = append(plainParts, tag)
}
plain = strings.Join(plainParts, "\n")
// HTML body
@@ -55,25 +57,32 @@ func formatPost(s *PostableStory) (plain, htmlBody string) {
if s.Lede != "" {
htmlParts = append(htmlParts, html.EscapeString(s.Lede))
}
htmlParts = append(htmlParts, formatSourceTag(s.Source, s.Platforms, true))
if tag := formatSourceTag(s.Source, s.Platforms, true); tag != "" {
htmlParts = append(htmlParts, tag)
}
htmlBody = strings.Join(htmlParts, "<br/>")
return plain, htmlBody
}
// formatSourceTag builds the source + platform tags line.
// formatSourceTag builds the source + platform tags line. An empty source is
// omitted rather than tagged: Pete's own reporting has no outlet to credit, and
// an empty tag would read as him signing his own name.
func formatSourceTag(source string, platforms []string, isHTML bool) string {
if isHTML {
parts := []string{fmt.Sprintf("<code>%s</code>", html.EscapeString(strings.ToLower(source)))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("<code>%s</code>", html.EscapeString(p)))
}
return strings.Join(parts, " \u00b7 ")
var parts []string
if source != "" {
parts = append(parts, strings.ToLower(source))
}
parts := []string{fmt.Sprintf("`%s`", strings.ToLower(source))}
for _, p := range platforms {
parts = append(parts, fmt.Sprintf("`%s`", p))
parts = append(parts, platforms...)
if len(parts) == 0 {
return ""
}
for i, p := range parts {
if isHTML {
parts[i] = fmt.Sprintf("<code>%s</code>", html.EscapeString(p))
} else {
parts[i] = fmt.Sprintf("`%s`", p)
}
}
return strings.Join(parts, " \u00b7 ")
}

View File

@@ -132,10 +132,10 @@ func MarkClassified(guid, channel, platforms string) {
// GetStoryByGUID returns the full story record for a GUID, or nil if not found.
func GetStoryByGUID(guid string) (*Story, error) {
row := Get().QueryRow(
`SELECT guid, headline, lede, image_url, article_url, source, platforms, channel, seen_at
`SELECT guid, headline, lede, COALESCE(content, ''), image_url, article_url, source, platforms, channel, seen_at
FROM stories WHERE guid = ?`, guid)
var s Story
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
if err := row.Scan(&s.GUID, &s.Headline, &s.Lede, &s.Content, &s.ImageURL, &s.ArticleURL, &s.Source, &s.Platforms, &s.Channel, &s.SeenAt); err != nil {
return nil, err
}
return &s, nil
@@ -252,6 +252,53 @@ func GetNewestPostableStoryByChannel(channel string) (*Story, error) {
return &s, nil
}
// UnpostedAdventureSince returns adventure dispatches seen at or after `since`
// that have never been posted to Matrix (no post_log row). PRIORITY beats post
// live and so carry a post_log row; the ones left are exactly the BULLETINs the
// daily digest collects. Oldest first so the digest reads chronologically.
//
// At most `limit` rows come back, but total is how many match in all — the digest
// quotes it to readers, so it must count the window rather than the returned page.
func UnpostedAdventureSince(since int64, limit int) (stories []Story, total int, err error) {
const where = `WHERE classified = 1
AND channel = 'adventure'
AND seen_at >= ?
AND guid NOT IN (SELECT guid FROM post_log)`
if err := Get().QueryRow(`SELECT COUNT(*) FROM stories `+where, since).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := Get().Query(
`SELECT guid, headline, lede, article_url, seen_at
FROM stories `+where+`
ORDER BY seen_at ASC
LIMIT ?`, since, limit)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []Story
for rows.Next() {
var s Story
if err := rows.Scan(&s.GUID, &s.Headline, &s.Lede, &s.ArticleURL, &s.SeenAt); err != nil {
return nil, 0, err
}
out = append(out, s)
}
return out, total, rows.Err()
}
// MarkAdventureDigested records each bulletin guid as posted (in a shared digest
// event) so the next daily digest doesn't re-collect it. Idempotent per guid via
// the post_log OR IGNORE. eventID is the digest's synthetic key (e.g.
// "adv-digest:2026-07-11") shared by every story that went out in that digest.
func MarkAdventureDigested(guids []string, eventID string) {
for _, g := range guids {
InsertPostLog(g, "adventure", eventID, "", false)
}
}
// ListClassifiedByChannel returns up to `limit` classified stories routed to a
// real channel, newest first, with optional offset for pagination. Sentinel
// channels (_discarded, _duplicate) are excluded.

108
internal/storage/roster.go Normal file
View File

@@ -0,0 +1,108 @@
package storage
import (
"database/sql"
"log/slog"
)
// RosterEntry is one adventurer's currently-true state, as of the last snapshot
// gogobee pushed. Not an event — nothing here is a thing that *happened*.
type RosterEntry struct {
Token string `json:"token"`
Name string `json:"name"`
Level int `json:"level"`
ClassRace string `json:"class_race"`
Status string `json:"status"` // "expedition" | "idle"
Zone string `json:"zone,omitempty"`
Region string `json:"region,omitempty"`
Day int `json:"day,omitempty"`
IdleHours int `json:"idle_hours,omitempty"`
SnapshotAt int64 `json:"snapshot_at"`
}
// ReplaceRoster swaps the whole board for a new snapshot, in one transaction.
//
// Replace — never merge. A player who dropped out of gogobee's snapshot (deleted
// character, or a fresh `!news optout`) must vanish from the board, and an
// upsert would leave them standing there forever. The transaction means a reader
// mid-swap sees the old board or the new one, never a half-empty realm.
func ReplaceRoster(entries []RosterEntry, snapshotAt int64) error {
tx, err := Get().Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.Exec(`DELETE FROM adventure_roster`); err != nil {
return err
}
stmt, err := tx.Prepare(`
INSERT INTO adventure_roster
(token, name, level, class_race, status, zone, region, day, idle_hours, snapshot_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, e := range entries {
if _, err := stmt.Exec(e.Token, e.Name, e.Level, e.ClassRace, e.Status,
e.Zone, e.Region, e.Day, e.IdleHours, snapshotAt); err != nil {
return err
}
}
// Stamp the snapshot even when it carried zero adventurers — that is a
// legitimate state (quiet realm) and must not read as "gogobee went away".
if _, err := tx.Exec(`
INSERT INTO adventure_roster_meta (id, snapshot_at) VALUES (1, ?)
ON CONFLICT(id) DO UPDATE SET snapshot_at = excluded.snapshot_at`, snapshotAt); err != nil {
return err
}
return tx.Commit()
}
// LoadRoster returns the current board: everyone on an expedition first (most
// recently departed at the top of that group), then the idle, longest-idle last.
// Also returns the snapshot time so the caller can decide whether the wire has
// gone quiet — see rosterStale.
func LoadRoster() ([]RosterEntry, int64, error) {
rows, err := Get().Query(`
SELECT token, name, level, COALESCE(class_race, ''), status,
COALESCE(zone, ''), COALESCE(region, ''), day, idle_hours, snapshot_at
FROM adventure_roster
ORDER BY status = 'expedition' DESC, day DESC, idle_hours ASC, level DESC, name ASC`)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var out []RosterEntry
for rows.Next() {
var e RosterEntry
if err := rows.Scan(&e.Token, &e.Name, &e.Level, &e.ClassRace, &e.Status,
&e.Zone, &e.Region, &e.Day, &e.IdleHours, &e.SnapshotAt); err != nil {
return nil, 0, err
}
out = append(out, e)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
return out, RosterSnapshotAt(), nil
}
// RosterSnapshotAt reports when the board was last refreshed, 0 if gogobee has
// never pushed one. Read from the meta row, not the entries, so a snapshot that
// legitimately carried nobody still counts as a snapshot.
func RosterSnapshotAt() int64 {
var at sql.NullInt64
err := Get().QueryRow(`SELECT snapshot_at FROM adventure_roster_meta WHERE id = 1`).Scan(&at)
if err == sql.ErrNoRows {
return 0
}
if err != nil {
slog.Error("RosterSnapshotAt query failed", "err", err)
return 0
}
return at.Int64
}

View File

@@ -21,6 +21,37 @@ CREATE TABLE IF NOT EXISTS stories (
published_at INTEGER
);
-- adventure_roster is a *snapshot*, not a log: gogobee POSTs the whole live
-- board and it replaces this table wholesale. Rows are state that is currently
-- true ("Josie is in holymachina"), which is the one thing the story feed can
-- never be — every dispatch there is an accomplishment, and an accomplishment is
-- a clipping the moment it lands.
--
-- token is gogobee's per-player roster token, not a Matrix handle and not a
-- story GUID. Players who ran "!news optout" are omitted from the snapshot
-- upstream and so never appear here at all.
CREATE TABLE IF NOT EXISTS adventure_roster (
token TEXT PRIMARY KEY,
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
class_race TEXT,
status TEXT NOT NULL, -- "expedition" | "idle"
zone TEXT,
region TEXT,
day INTEGER NOT NULL DEFAULT 0, -- expedition day, 0 if idle
idle_hours INTEGER NOT NULL DEFAULT 0, -- hours since last player action
snapshot_at INTEGER NOT NULL -- when gogobee took the snapshot
);
-- The snapshot time lives outside the rows because an *empty* board is
-- ambiguous: either nobody is playing, or gogobee has stopped talking to us. A
-- MAX(snapshot_at) over zero rows can't tell those apart, and the page must —
-- one is "quiet realm", the other is "the wire is down, trust nothing here".
CREATE TABLE IF NOT EXISTS adventure_roster_meta (
id INTEGER PRIMARY KEY CHECK (id = 1),
snapshot_at INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS post_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT NOT NULL,

428
internal/web/adventure.go Normal file
View File

@@ -0,0 +1,428 @@
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", "🚪"
}
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
}
// 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 "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
}

View File

@@ -0,0 +1,154 @@
package web
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"pete/internal/storage"
)
// The BULLETIN digest is the batched counterpart to the live PRIORITY beats.
// Once a day (Adventure.DigestHour, UTC) Pete collects the adventure dispatches
// that were seen since the last digest but never posted live — exactly the
// bulletins — and posts a single warm roundup to the adventure channel. The
// website already carries each one as its own card; the digest is the Matrix-only
// nudge so quiet-but-real activity surfaces without one ping per event.
const (
// digestWindow bounds how far back a digest looks. Wider than a day so a
// missed run (process down over a digest hour) still sweeps up the gap;
// re-collection is prevented by MarkAdventureDigested, not by the window.
digestWindow = 48 * time.Hour
// digestCap bounds a single digest. Far past the observed volume (a dormant
// community, per the voice spec); a runaway just truncates with a log line.
digestCap = 40
// digestPreview is how many headlines the roundup lede lists by name before
// collapsing the rest to "…and N more".
digestPreview = 4
)
// StartAdventureDigest launches the daily bulletin-digest loop. No-op unless the
// section is enabled AND there's a live Matrix poster AND a channel to post to —
// i.e. website-only and local modes never post a digest.
func (s *Server) StartAdventureDigest(ctx context.Context) {
if !s.adv.Enabled || s.advPost == nil || s.adv.Channel == "" {
return
}
go s.runAdventureDigest(ctx)
}
// runAdventureDigest sleeps until the next DigestHour, posts, then repeats every
// 24h. Sleeping to a wall-clock hour (not a fixed ticker from boot) keeps the
// digest at a predictable time of day across restarts.
func (s *Server) runAdventureDigest(ctx context.Context) {
hour := s.adv.DigestHourOrDefault()
slog.Info("web: adventure digest scheduler started", "digest_hour_utc", hour, "channel", s.adv.Channel)
for {
wait := durUntilNextHour(time.Now().UTC(), hour)
select {
case <-ctx.Done():
return
case <-time.After(wait):
s.postDailyDigest(time.Now().UTC())
}
}
}
// durUntilNextHour returns the duration from now to the next occurrence of the
// given UTC hour. If it's exactly the hour now, it targets tomorrow so a restart
// at the hour doesn't double-fire.
func durUntilNextHour(now time.Time, hour int) time.Duration {
next := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC)
if !next.After(now) {
next = next.Add(24 * time.Hour)
}
return next.Sub(now)
}
// postDailyDigest collects the window's un-posted bulletins, posts one roundup,
// and marks them digested so they don't recur. Silent when there's nothing new —
// a dormant realm should stay quiet, not ship an empty digest.
func (s *Server) postDailyDigest(now time.Time) {
since := now.Add(-digestWindow).Unix()
bulletins, total, err := storage.UnpostedAdventureSince(since, digestCap)
if err != nil {
slog.Error("adventure digest: query failed", "err", err)
return
}
if len(bulletins) == 0 {
return
}
if total > len(bulletins) {
slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap, "total", total)
}
date := now.Format("2006-01-02")
eventID := "adv-digest:" + date
headline, lede := buildDigest(bulletins, total)
s.advPost(AdvPost{
GUID: eventID,
Headline: headline,
Lede: lede,
ArticleURL: s.digestURL(date),
Channel: s.adv.Channel,
})
guids := make([]string, len(bulletins))
for i, b := range bulletins {
guids[i] = b.GUID
}
storage.MarkAdventureDigested(guids, eventID)
slog.Info("adventure digest: posted", "date", date, "items", len(bulletins))
}
// buildDigest renders the roundup headline + lede in Pete's warm reporter voice.
// total is how many bulletins the window actually holds, which is larger than
// len(bulletins) when the cap truncated the fetch — the counts quoted to readers
// have to describe the realm, not the slice. Headlines come from stored,
// fact-guarded rows, so no player-controlled text is introduced here.
func buildDigest(bulletins []storage.Story, total int) (headline, lede string) {
if total == 1 {
return "Today in the realm: one dispatch.",
fmt.Sprintf("Quiet day out there, but one worth noting — %s Full story on the board.", trimHeadline(bulletins[0].Headline))
}
headline = fmt.Sprintf("Today in the realm: %d dispatches.", total)
shown := bulletins
if len(shown) > digestPreview {
shown = shown[:digestPreview]
}
parts := make([]string, len(shown))
for i, b := range shown {
parts[i] = trimHeadline(b.Headline)
}
list := strings.Join(parts, " ")
more := ""
if total > len(shown) {
more = fmt.Sprintf(" …and %d more.", total-len(shown))
}
return headline, fmt.Sprintf("Here's what came across the wire today. %s%s The full board's on the site.", list, more)
}
// trimHeadline ensures a headline ends with sentence punctuation so the joined
// lede reads as prose rather than a run-on.
func trimHeadline(h string) string {
h = strings.TrimSpace(h)
if h == "" {
return ""
}
if last := h[len(h)-1]; last != '.' && last != '!' && last != '?' {
h += "."
}
return h
}
// digestURL points the roundup at the section, with a per-day query param so the
// canonical-URL dedup treats each day's digest as distinct (it strips only
// tracking params, keeping ?digest=).
func (s *Server) digestURL(date string) string {
return s.siteURL("/adventure?digest=" + date)
}

View File

@@ -0,0 +1,55 @@
package web
import (
"strings"
"testing"
"pete/internal/storage"
)
// The retreat bulletin — gogobee's newest event type.
//
// The failure this guards against is silent and total: handleAdventureIngest
// answers an unrecognized event_type with 400, and gogobee's sender treats any
// non-2xx as a failure, retries eight times over ~two hours, and then PARKS the
// row forever. So a gogobee that emits `retreat` against a Pete that doesn't
// know the word doesn't log an error anyone reads — it just quietly drops every
// retreat the realm ever files. Pete has to learn the word first, and this test
// is what says he has.
func TestAdventureIngest_AcceptsRetreat(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "retreat:abc:1000", EventType: "retreat", Tier: "bulletin",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 12, Count: 3, Outcome: "retreated",
OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s — Pete rejected a retreat, so gogobee will "+
"retry it eight times and park it forever", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("retreat:abc:1000")
if err != nil || got == nil {
t.Fatalf("retreat was accepted but not stored: %v", err)
}
// It has to actually say what happened, in Pete's voice, using only the
// supplied facts.
body := got.Headline + " " + got.Lede
for _, want := range []string{"Brannigan", "the Underforge"} {
if !strings.Contains(body, want) {
t.Errorf("rendered retreat is missing %q: %q", want, body)
}
}
if !strings.Contains(got.Lede, "3 days in") {
t.Errorf("the day count never made it into the copy: %q", got.Lede)
}
// Bulletin, not priority: a retreat goes in the daily digest, it does not
// interrupt the room. Pete announcing every failed run live would be a
// firehose — and an unkind one.
if len(*posted) != 0 {
t.Errorf("a bulletin was posted live: %+v", *posted)
}
}

View File

@@ -0,0 +1,313 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"pete/internal/config"
"pete/internal/storage"
)
// newAdvServer builds a web server with the adventure seam enabled and a
// capturing priority poster, backed by a fresh temp DB.
func newAdvServer(t *testing.T, token string) (*Server, *[]AdvPost) {
t.Helper()
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "adv.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
var posted []AdvPost
adv := config.AdventureConfig{Enabled: true, IngestToken: token, Channel: "adventure"}
// Mirror the production poster's side effect: queue.PostNow records a
// post_log row, which is exactly what marks a beat "posted" and thus
// excludes it from the bulletin digest.
poster := func(p AdvPost) {
posted = append(posted, p)
storage.InsertPostLog(p.GUID, "adventure", p.GUID, "", false)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example"},
nil, true, adv, poster)
if err != nil {
t.Fatal(err)
}
return s, &posted
}
func postFact(t *testing.T, s *Server, token string, f AdvFact) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(f)
req := httptest.NewRequest("POST", "/api/ingest/adventure", bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rw := httptest.NewRecorder()
s.handleAdventureIngest(rw, req)
return rw
}
// TestAdventureIngestEndToEnd covers the seam: a priority death fact is
// bearer-accepted, templated, stored as an adventure story, and posted live.
func TestAdventureIngestEndToEnd(t *testing.T) {
const token = "s3cret-token"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d body=%s", rw.Code, rw.Body.String())
}
got, err := storage.GetStoryByGUID("death:abc:1000")
if err != nil || got == nil {
t.Fatalf("story not stored: %v", err)
}
if got.Channel != "adventure" || got.Source != advSource {
t.Errorf("channel/source = %q/%q", got.Channel, got.Source)
}
if got.Headline != "We lost Brannigan in the Underforge." {
t.Errorf("headline = %q", got.Headline)
}
if got.ArticleURL != "https://news.example/adventure/death:abc:1000" {
t.Errorf("article_url = %q", got.ArticleURL)
}
if len(*posted) != 1 || (*posted)[0].GUID != f.GUID {
t.Fatalf("priority post not delivered: %+v", *posted)
}
// Idempotent re-delivery: no error, no second post.
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("dup ingest status = %d", rw.Code)
}
if len(*posted) != 1 {
t.Errorf("duplicate fact re-posted: %d posts", len(*posted))
}
}
// TestAdventurePermalink renders the per-story page the article_url points at:
// an ingested dispatch must be fetchable at /adventure/{guid} with its headline
// and body, and an unknown guid must 404.
func TestAdventurePermalink(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
f := AdvFact{
GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan",
Zone: "the Underforge", Level: 14, OccurredAt: 1000,
}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
req := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
req.SetPathValue("guid", "death:abc:1000")
rw := httptest.NewRecorder()
s.handleAdventureStory(rw, req)
if rw.Code != 200 {
t.Fatalf("permalink status = %d body=%s", rw.Code, rw.Body.String())
}
body := rw.Body.String()
if !bytes.Contains([]byte(body), []byte("We lost Brannigan in the Underforge.")) {
t.Errorf("permalink missing headline; body=%s", body)
}
if !bytes.Contains([]byte(body), []byte("In memoriam")) {
t.Errorf("permalink missing event label; body=%s", body)
}
// Unknown guid 404s.
req2 := httptest.NewRequest("GET", "/adventure/nope:1", nil)
req2.SetPathValue("guid", "nope:1")
rw2 := httptest.NewRecorder()
s.handleAdventureStory(rw2, req2)
if rw2.Code != 404 {
t.Errorf("unknown guid status = %d, want 404", rw2.Code)
}
}
// TestDurUntilNextHour targets the next UTC occurrence of the digest hour and
// rolls to tomorrow when it's already that hour (so a restart can't double-fire).
func TestDurUntilNextHour(t *testing.T) {
// 14:30 UTC, targeting 17:00 → 2h30m today.
now := time.Date(2026, 7, 11, 14, 30, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 2*time.Hour+30*time.Minute {
t.Errorf("before hour: got %v", got)
}
// 17:00 exactly → tomorrow's 17:00 (24h), not zero.
now = time.Date(2026, 7, 11, 17, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 24*time.Hour {
t.Errorf("at hour: got %v", got)
}
// 20:00, targeting 17:00 → tomorrow, 21h.
now = time.Date(2026, 7, 11, 20, 0, 0, 0, time.UTC)
if got := durUntilNextHour(now, 17); got != 21*time.Hour {
t.Errorf("after hour: got %v", got)
}
}
// TestAdventureDigest covers the batched path: bulletins (no live post) are
// collected into one roundup, priority beats are excluded (they already posted),
// digested bulletins don't recur, and an empty window stays silent.
func TestAdventureDigest(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
now := time.Now()
// Two bulletins + one priority (which posts live and must be excluded).
postFact(t, s, token, AdvFact{GUID: "arrival:a:1", EventType: "arrival", Tier: "bulletin",
Actors: []string{"Zapp"}, Subject: "Zapp", ClassRace: "Elf Ranger", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "rival:b:2", EventType: "rival_result", Tier: "bulletin",
Actors: []string{"Kif", "Zapp"}, Subject: "Kif", Opponent: "Zapp", Outcome: "won", OccurredAt: now.Unix()})
postFact(t, s, token, AdvFact{GUID: "death:c:3", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 9, OccurredAt: now.Unix()})
if len(*posted) != 1 {
t.Fatalf("setup: priority posts = %d, want 1", len(*posted))
}
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Fatalf("digest not posted: total posts = %d, want 2", len(*posted))
}
dg := (*posted)[1]
if !strings.Contains(dg.Headline, "2 dispatches") {
t.Errorf("digest headline = %q", dg.Headline)
}
if !strings.HasPrefix(dg.GUID, "adv-digest:") {
t.Errorf("digest guid = %q", dg.GUID)
}
// Re-running finds nothing new (bulletins marked digested).
s.postDailyDigest(now.UTC())
if len(*posted) != 2 {
t.Errorf("digest re-posted: total = %d, want 2", len(*posted))
}
}
// TestAdventureArtAndMeta covers the visual-identity slice: the emblem endpoint
// returns a themed SVG, ingested cards carry its local path, and the permalink
// page is noindex with an og:image.
// TestRenderZoneTaxonomy: a realm-first (zone_first) reads as first-ever; a
// repeat (zone_clear) reads as a personal clear, not a mis-labeled "first".
func TestRenderZoneTaxonomy(t *testing.T) {
first := AdvFact{EventType: "zone_first", Tier: "priority", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl, _, ok := renderAdventure(first)
if !ok || !strings.Contains(hl, "very first time") {
t.Errorf("zone_first headline = %q (ok=%v)", hl, ok)
}
repeat := AdvFact{EventType: "zone_clear", Tier: "bulletin", Subject: "Brannigan",
Zone: "Dragon's Lair", Region: "the Underforge", Level: 14}
hl2, _, ok := renderAdventure(repeat)
if !ok || !strings.Contains(hl2, "Brannigan clears") || strings.Contains(hl2, "first") {
t.Errorf("zone_clear headline = %q (ok=%v)", hl2, ok)
}
// The permalink label distinguishes the two, so a repeat's page isn't stamped
// "First clear".
if lbl, _ := advEventMeta("zone_clear"); lbl == "First clear" {
t.Errorf("zone_clear meta label = %q, want distinct from first clear", lbl)
}
}
func TestAdventureArtAndMeta(t *testing.T) {
const token = "t"
s, _ := newAdvServer(t, token)
// Emblem endpoint returns SVG with the event's emoji.
areq := httptest.NewRequest("GET", "/adventure/art/death.svg", nil)
areq.SetPathValue("type", "death.svg")
arw := httptest.NewRecorder()
s.handleAdventureArt(arw, areq)
if arw.Code != 200 {
t.Fatalf("art status = %d", arw.Code)
}
if ct := arw.Header().Get("Content-Type"); !strings.HasPrefix(ct, "image/svg+xml") {
t.Errorf("art content-type = %q", ct)
}
if b := arw.Body.String(); !strings.Contains(b, "🪦") || !strings.Contains(b, "<svg") {
t.Errorf("art body missing emblem: %s", b)
}
// Ingest sets the card image to the local emblem path.
f := AdvFact{GUID: "death:abc:1000", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Brannigan", Zone: "the Underforge", Level: 4, OccurredAt: 1000}
if rw := postFact(t, s, token, f); rw.Code != 200 {
t.Fatalf("ingest status = %d", rw.Code)
}
got, _ := storage.GetStoryByGUID("death:abc:1000")
if got == nil || got.ImageURL != "/adventure/art/death.svg" {
t.Errorf("story image = %q", got.ImageURL)
}
// Permalink page is noindex with an og:image.
preq := httptest.NewRequest("GET", "/adventure/death:abc:1000", nil)
preq.SetPathValue("guid", "death:abc:1000")
prw := httptest.NewRecorder()
s.handleAdventureStory(prw, preq)
body := prw.Body.String()
if !strings.Contains(body, `name="robots" content="noindex"`) {
t.Error("permalink not noindex")
}
if !strings.Contains(body, `property="og:image" content="https://news.example/adventure/art/death.svg"`) {
t.Errorf("permalink missing og:image; body=%s", body)
}
}
// TestAdventureIngestBearer rejects missing/wrong tokens.
func TestAdventureIngestBearer(t *testing.T) {
s, _ := newAdvServer(t, "right")
f := AdvFact{GUID: "arrival:x:1", EventType: "arrival", Tier: "bulletin", OccurredAt: 1}
if rw := postFact(t, s, "", f); rw.Code != 401 {
t.Errorf("no token: status = %d, want 401", rw.Code)
}
if rw := postFact(t, s, "wrong", f); rw.Code != 401 {
t.Errorf("wrong token: status = %d, want 401", rw.Code)
}
}
// TestAdventureFactGuard rejects a subject not present in the actors allow-list,
// so a name that slipped the source can't reach a public page.
func TestAdventureFactGuard(t *testing.T) {
const token = "t"
s, posted := newAdvServer(t, token)
f := AdvFact{
GUID: "death:evil:1", EventType: "death", Tier: "priority",
Actors: []string{"Brannigan"}, Subject: "Kif", // Kif not in actors
Zone: "the Underforge", Level: 3, OccurredAt: 1,
}
if rw := postFact(t, s, token, f); rw.Code != 400 {
t.Errorf("fact-guard: status = %d, want 400", rw.Code)
}
if storage.IsGUIDSeen("death:evil:1") {
t.Error("guarded fact was stored")
}
if len(*posted) != 0 {
t.Error("guarded fact was posted")
}
}
// TestAdventureDisabled 404s when the seam is off.
func TestAdventureDisabled(t *testing.T) {
storage.Close()
if err := storage.Init(filepath.Join(t.TempDir(), "off.db")); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { storage.Close() })
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}
f := AdvFact{GUID: "x", EventType: "arrival", OccurredAt: 1}
if rw := postFact(t, s, "anything", f); rw.Code != 404 {
t.Errorf("disabled: status = %d, want 404", rw.Code)
}
}

View File

@@ -53,7 +53,7 @@ func TestFeeds(t *testing.T) {
}
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0", BaseURL: "https://news.example/"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -105,6 +105,8 @@ type pageData struct {
PushEnabled bool // Web Push is configured (shows the notifications toggle to signed-in users)
PushPublicKey string // VAPID public key handed to the client to subscribe
TTS template.JS // JSON {enabled, default, voices:[{id,label}]} for read-aloud, or "null"
NoIndex bool // emit <meta name="robots" content="noindex"> — used by the adventure section
OGImage string // absolute og:image URL for link unfurls (adventure emblem); "" = none
}
type channelPage struct {
@@ -117,6 +119,13 @@ type channelPage struct {
PrevURL string
NextURL string
Total int
// Roster is the live adventurer board, populated on the adventure section
// only and only on page 1 — it is present tense, and page 2 of an archive is
// not where anyone looks for what's happening right now.
Roster []RosterView
RosterStale bool
ShowRoster bool
}
type indexPage struct {
@@ -158,7 +167,7 @@ func (s *Server) base(r *http.Request) pageData {
}
d := pageData{
SiteTitle: s.cfg.SiteTitle,
Channels: channels,
Channels: s.channels,
Weather: currentWeather(time.Now()),
AllSources: jsForScript(srcJSON),
AuthEnabled: s.auth != nil,
@@ -215,8 +224,8 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
dayStart := time.Now().Add(-24 * time.Hour).Unix()
stats := make([]channelStat, 0, len(channels))
for _, ch := range channels {
stats := make([]channelStat, 0, len(s.channels))
for _, ch := range s.channels {
total, _ := storage.CountClassifiedByChannel(ch.Slug)
lastTs := storage.GetLastPostTime(ch.Slug)
stat := channelStat{
@@ -327,6 +336,9 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
base := s.base(r)
base.Active = ch.Slug
// The adventure section names player characters; keep it out of search
// indexes to bound the cached-forever exposure (see plan gap #5).
base.NoIndex = ch.Slug == "adventure"
data := channelPage{
pageData: base,
Channel: ch,
@@ -338,6 +350,10 @@ func (s *Server) handleChannel(w http.ResponseWriter, r *http.Request, ch Channe
NextURL: fmt.Sprintf("/%s?page=%d", ch.Slug, page+1),
Total: total,
}
if ch.Slug == "adventure" && page == 1 {
data.Roster, data.RosterStale, _ = s.roster()
data.ShowRoster = true
}
s.render(w, "channel", data)
}

View File

@@ -42,7 +42,7 @@ func TestReaderCardDataAndArticleAPI(t *testing.T) {
t.Fatal(err)
}
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

183
internal/web/roster.go Normal file
View File

@@ -0,0 +1,183 @@
package web
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The live adventurer board.
//
// Everything else Pete publishes about the realm is an *accomplishment* — a
// death, a clear, a milestone. Those are clippings: they read as archive the
// instant they land, however fast we deliver them, and no refresh interval fixes
// that. The board is the opposite kind of thing. It is state that is currently
// true, so it is worth looking at *now*, and it goes stale on its own if we stop
// hearing from gogobee.
//
// Direction of travel is gogobee → Pete, like every other adventure payload:
// Pete has no route back into the game box's network and we are not opening one.
// gogobee pushes the whole board; we replace ours with it.
const (
// rosterStaleAfter — how old a snapshot can get before the board stops
// claiming to be live. gogobee pushes every couple of minutes, so this is
// several missed pushes, not one unlucky one.
//
// This matters more than it looks: if gogobee dies mid-expedition, the last
// snapshot says "Josie is in holymachina" and would say so forever. A board
// that lies confidently is worse than one that admits it lost the wire —
// especially once players can act on it (see the target-list note below).
rosterStaleAfter = 12 * time.Minute
// rosterMaxEntries — hard cap on a snapshot. A bounded realm; this only
// exists so a malformed or hostile push can't spool unbounded rows.
rosterMaxEntries = 500
)
// rosterPush is the payload gogobee POSTs to /api/ingest/roster.
type rosterPush struct {
SnapshotAt int64 `json:"snapshot_at"`
Adventurers []storage.RosterEntry `json:"adventurers"`
}
// RosterView is one row as the page renders it.
type RosterView struct {
Name string
Level int
ClassRace string
OnRun bool
Zone string
Region string
Where string // "holymachina, day 3" | "in town"
Idle string // "quiet for 2 days" — only when idle
}
// handleRosterIngest replaces the board with gogobee's latest snapshot.
func (s *Server) handleRosterIngest(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 push rosterPush
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&push); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if len(push.Adventurers) > rosterMaxEntries {
http.Error(w, "roster too large", http.StatusBadRequest)
return
}
// A snapshot with no timestamp can't be aged, so it could never go stale —
// it would sit on the page claiming to be live forever. Treat it as now.
if push.SnapshotAt <= 0 {
push.SnapshotAt = time.Now().Unix()
}
// Never trust the channel with a name. Same rule as factGuard: gogobee
// pre-sanitizes to character names, and Pete checks anyway, because this is
// the last thing standing between the payload and a public page.
for i, e := range push.Adventurers {
if e.Token == "" || e.Name == "" {
http.Error(w, "each adventurer needs token and name", http.StatusBadRequest)
return
}
if e.Status != "expedition" && e.Status != "idle" {
http.Error(w, fmt.Sprintf("adventurer %d: unknown status %q", i, e.Status), http.StatusBadRequest)
return
}
}
if err := storage.ReplaceRoster(push.Adventurers, push.SnapshotAt); err != nil {
slog.Error("roster ingest: replace failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("roster ingest: board replaced", "adventurers", len(push.Adventurers))
w.WriteHeader(http.StatusOK)
}
// handleRosterAPI serves the board as JSON for the page's own re-poll, so an
// open tab goes live without a reload. Public — same exposure as the rendered
// page, no more.
func (s *Server) handleRosterAPI(w http.ResponseWriter, r *http.Request) {
if !s.adv.Enabled {
http.NotFound(w, r)
return
}
views, stale, _ := s.roster()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"stale": stale,
"adventurers": views,
})
}
// roster loads the board and decides whether it is still live. A stale board is
// still returned — the page shows it dimmed and says so, rather than blanking,
// because "here is who was out when we lost contact" beats an empty page.
func (s *Server) roster() (views []RosterView, stale bool, snapshotAt int64) {
entries, snapshotAt, err := storage.LoadRoster()
if err != nil {
slog.Error("roster: load failed", "err", err)
return nil, true, 0
}
stale = snapshotAt == 0 || time.Since(time.Unix(snapshotAt, 0)) > rosterStaleAfter
for _, e := range entries {
views = append(views, toRosterView(e))
}
return views, stale, snapshotAt
}
func toRosterView(e storage.RosterEntry) RosterView {
v := RosterView{
Name: e.Name,
Level: e.Level,
ClassRace: e.ClassRace,
OnRun: e.Status == "expedition",
Zone: e.Zone,
Region: e.Region,
}
if v.OnRun {
where := e.Zone
if e.Region != "" {
where = fmt.Sprintf("%s, %s", e.Zone, e.Region)
}
if e.Day > 0 {
where = fmt.Sprintf("%s — day %d", where, e.Day)
}
v.Where = where
return v
}
v.Where = "in town"
v.Idle = humanIdle(e.IdleHours)
return v
}
// humanIdle renders the idle clock the way a person would say it. Deliberately
// coarse: this is colour on a board, not the boredom ticker's actual threshold.
func humanIdle(hours int) string {
switch {
case hours <= 0:
return ""
case hours < 2:
return "just got back"
case hours < 24:
return fmt.Sprintf("quiet for %dh", hours)
case hours < 48:
return "quiet for a day"
default:
return fmt.Sprintf("quiet for %d days", hours/24)
}
}

193
internal/web/roster_test.go Normal file
View File

@@ -0,0 +1,193 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"pete/internal/storage"
)
func postRoster(t *testing.T, s *Server, token string, push rosterPush) *httptest.ResponseRecorder {
t.Helper()
body, _ := json.Marshal(push)
req := httptest.NewRequest("POST", "/api/ingest/roster", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleRosterIngest(w, req)
return w
}
func entry(token, name, status, zone string) storage.RosterEntry {
return storage.RosterEntry{Token: token, Name: name, Level: 5, ClassRace: "elf ranger", Status: status, Zone: zone}
}
// TestRosterReplacesNeverMerges is the whole contract of the board. gogobee
// sends the *complete* set of adventurers it is willing to show; anyone missing
// from a later snapshot has left the board — they deleted their character, or
// (the case that actually matters) they just ran `!news optout` and must
// disappear from a public page. An upsert would leave them standing there
// forever, which is precisely the exposure the opt-out exists to prevent.
func TestRosterReplacesNeverMerges(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
entry("t2", "Quack", "idle", ""),
}}); w.Code != 200 {
t.Fatalf("first push = %d, want 200", w.Code)
}
// Quack opts out; gogobee stops including her.
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: now + 60, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}}); w.Code != 200 {
t.Fatalf("second push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if stale {
t.Error("fresh snapshot read as stale")
}
if len(views) != 1 {
t.Fatalf("board has %d rows, want 1 — a dropped adventurer survived the swap", len(views))
}
if views[0].Name != "Josie" {
t.Errorf("board kept %q, want Josie", views[0].Name)
}
}
// TestRosterGoesStale: if gogobee dies mid-expedition, the last snapshot says
// "Josie is in holymachina" and would say so forever. The board has to admit it
// lost the wire rather than keep asserting a stale fact as live.
func TestRosterGoesStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
old := time.Now().Add(-2 * rosterStaleAfter).Unix()
postRoster(t, s, "tok", rosterPush{SnapshotAt: old, Adventurers: []storage.RosterEntry{
entry("t1", "Josie", "expedition", "holymachina"),
}})
views, stale, _ := s.roster()
if !stale {
t.Error("snapshot older than rosterStaleAfter still reported live")
}
// Stale, but still shown: "who was out when we lost contact" beats a blank page.
if len(views) != 1 {
t.Errorf("stale board dropped its rows (%d), want them kept and dimmed", len(views))
}
}
// TestRosterEmptySnapshotIsNotStale: a realm where nobody is playing is a
// legitimate state and must not be confused with gogobee having gone away. This
// is why the snapshot time lives in its own row instead of MAX() over entries.
func TestRosterEmptySnapshotIsNotStale(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if w := postRoster(t, s, "tok", rosterPush{SnapshotAt: time.Now().Unix()}); w.Code != 200 {
t.Fatalf("empty push = %d, want 200", w.Code)
}
views, stale, _ := s.roster()
if len(views) != 0 {
t.Errorf("empty snapshot produced %d rows", len(views))
}
if stale {
t.Error("a quiet realm read as a dead wire — the two must not collapse")
}
}
func TestRosterIngestRejects(t *testing.T) {
s, _ := newAdvServer(t, "tok")
now := time.Now().Unix()
if w := postRoster(t, s, "wrong", rosterPush{SnapshotAt: now}); w.Code != 401 {
t.Errorf("bad bearer = %d, want 401", w.Code)
}
// An unknown status would render as neither "out there" nor "in town".
bad := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Status: "vibing"},
}}
if w := postRoster(t, s, "tok", bad); w.Code != 400 {
t.Errorf("unknown status = %d, want 400", w.Code)
}
// A nameless row would render an empty slot on a public page.
nameless := rosterPush{SnapshotAt: now, Adventurers: []storage.RosterEntry{
{Token: "t1", Status: "idle"},
}}
if w := postRoster(t, s, "tok", nameless); w.Code != 400 {
t.Errorf("nameless adventurer = %d, want 400", w.Code)
}
}
// TestDepartureRenders guards the cross-repo contract: gogobee emits this
// event_type for a bored adventurer, and an event_type Pete doesn't know is a
// 400 that retries and parks the bulletin forever.
func TestDepartureRenders(t *testing.T) {
headline, lede, ok := renderAdventure(AdvFact{
EventType: "departure",
Subject: "Camcast",
Zone: "crypt_valdris",
Level: 1,
Actors: []string{"Camcast"},
})
if !ok {
t.Fatal("departure event_type unknown to Pete — gogobee's bulletin would 400 and park")
}
if headline == "" || lede == "" {
t.Error("departure rendered empty")
}
}
func TestRosterViewWhere(t *testing.T) {
out := toRosterView(storage.RosterEntry{
Name: "Josie", Status: "expedition", Zone: "holymachina", Region: "the deep", Day: 3,
})
if !out.OnRun || out.Where != "holymachina, the deep — day 3" {
t.Errorf("expedition row = %+v", out)
}
in := toRosterView(storage.RosterEntry{Name: "Quack", Status: "idle", IdleHours: 50})
if in.OnRun || in.Where != "in town" || in.Idle != "quiet for 2 days" {
t.Errorf("idle row = %+v", in)
}
}
// TestAdventurePageRendersBoard drives the real page through the real template.
// The handler tests above prove the data is right; this proves it reaches a
// reader. A template slip (bad field, unclosed block) doesn't fail a build — it
// 500s the whole adventure section at request time, board and clippings alike.
func TestAdventurePageRendersBoard(t *testing.T) {
s, _ := newAdvServer(t, "tok")
postRoster(t, s, "tok", rosterPush{
SnapshotAt: time.Now().Unix(),
Adventurers: []storage.RosterEntry{
{Token: "t1", Name: "Josie", Level: 14, ClassRace: "human cleric",
Status: "expedition", Zone: "holymachina", Day: 3},
{Token: "t2", Name: "Quack", Level: 4, ClassRace: "elf ranger",
Status: "idle", IdleHours: 50},
},
})
req := httptest.NewRequest("GET", "/adventure", nil)
w := httptest.NewRecorder()
s.handleChannel(w, req, Channel{Slug: "adventure", Title: "Adventure", Theme: "adventure"})
if w.Code != 200 {
t.Fatalf("GET /adventure = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{
"Out there right now", // the board's headline
"Josie", "holymachina", "day 3", // live zone, shown while she's still in it
"Quack", "in town", "quiet for 2 days",
"/api/roster", // the client re-poll, so an open tab stays true
} {
if !strings.Contains(body, want) {
t.Errorf("adventure page missing %q", want)
}
}
}

View File

@@ -31,6 +31,10 @@ type Channel struct {
}
// channels in display order. Add a new entry here to add a section.
//
// This is the full catalogue, used to resolve a story's channel slug to its
// display metadata. It is NOT the list of live sections: an entry can be gated
// off (adventure), so route registration and the nav read s.channels instead.
var channels = []Channel{
{Slug: "gaming", Title: "Gaming", Theme: "gaming", Emoji: "🎮", Blurb: "Releases, platforms, and the people making the games."},
{Slug: "tech", Title: "Tech", Theme: "tech", Emoji: "💻", Blurb: "Industry, products, and the wires that hold it all together."},
@@ -42,6 +46,7 @@ var channels = []Channel{
{Slug: "kids", Title: "Kids", Theme: "kids", Emoji: "🧒", Blurb: "World news written for younger readers — short, clear, and curious."},
{Slug: "finance", Title: "Finance", Theme: "finance", Emoji: "💰", Blurb: "Markets, money, and the business of the world. Read-only — these stories don't post to Matrix."},
{Slug: "lego", Title: "LEGO", Theme: "lego", Emoji: "🧱", Blurb: "Sets, releases, and the wider brick-building world."},
{Slug: "adventure", Title: "Adventure", Theme: "adventure", Emoji: "⚔️", Blurb: "Dispatches from the realm — deaths, first-clears, arrivals, and rivalries. Reporting from the field, this is Pete."},
}
// SourceInfo is the trimmed view of a configured feed for the settings panel.
@@ -61,6 +66,9 @@ type Server struct {
tts *ttsService // nil when server-side read-aloud is disabled
adminSubs map[string]bool // OIDC subjects allowed to view /status
pushHTTP *http.Client // SSRF-guarded client for Web Push delivery; built lazily by pushClient()
adv config.AdventureConfig // gogobee adventure-news seam
advPost PriorityPoster // posts priority adventure beats to Matrix; nil = web-only
channels []Channel // live sections: the catalogue minus anything gated off (adventure)
// Daily-rotated salt for the privacy-preserving unique-visitor estimate.
// Guarded by metricsMu; never persisted (see metrics.go).
@@ -72,8 +80,8 @@ type Server struct {
// New builds the server. Templates are parsed once at startup. Each page
// gets its own template set sharing layout.html + _card.html, which avoids
// `main` define collisions between pages.
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status"}
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
tpls := make(map[string]*template.Template, len(pages))
for _, p := range pages {
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
@@ -99,7 +107,18 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
adminSubs[sub] = true
}
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs}
// The adventure section only exists when it's enabled: with the seam off,
// there's no nav tab, no /adventure page and no /adventure feed, rather than
// an empty section nobody can fill.
live := make([]Channel, 0, len(channels))
for _, ch := range channels {
if ch.Slug == "adventure" && !adv.Enabled {
continue
}
live = append(live, ch)
}
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than
@@ -147,7 +166,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
mux.HandleFunc("GET /api/related", s.handleRelated)
mux.HandleFunc("GET /feed.xml", func(w http.ResponseWriter, r *http.Request) { s.handleFeedXML(w, r, "") })
mux.HandleFunc("GET /feed.json", func(w http.ResponseWriter, r *http.Request) { s.handleFeedJSON(w, r, "") })
for _, ch := range channels {
for _, ch := range s.channels {
ch := ch
mux.HandleFunc("GET /"+ch.Slug, func(w http.ResponseWriter, r *http.Request) {
s.handleChannel(w, r, ch)
@@ -165,6 +184,24 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
_, _ = w.Write([]byte("ok"))
})
// gogobee adventure-news ingest. Bearer-authed (not OIDC), so it lives
// outside the sign-in block. Self-gates on adv.Enabled (404 when off).
mux.HandleFunc("POST /api/ingest/adventure", s.handleAdventureIngest)
// The live board. Ingest is bearer-authed like the fact seam; the read side
// is public because it renders on a public page anyway.
mux.HandleFunc("POST /api/ingest/roster", s.handleRosterIngest)
mux.HandleFunc("GET /api/roster", s.handleRosterAPI)
// Per-dispatch permalink (the article_url every ingested story points at).
// Public GET; self-gates on adv.Enabled. Distinct from GET /adventure (the
// channel listing, registered in the channels loop above).
mux.HandleFunc("GET /adventure/{guid}", s.handleAdventureStory)
// Themed per-event emblem (card + og:image). A distinct path depth from
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)

View File

@@ -79,6 +79,7 @@ html[data-phase="night"] {
.bg-theme-kids { background-color: #14b8a6; }
.bg-theme-finance { background-color: #10b981; }
.bg-theme-lego { background-color: #d01012; }
.bg-theme-adventure { background-color: #6d4bd8; }
.text-theme-gaming { color: #2d8a5a; }
.text-theme-tech { color: #2f7fb8; }
@@ -90,6 +91,7 @@ html[data-phase="night"] {
.text-theme-kids { color: #0f766e; }
.text-theme-finance { color: #059669; }
.text-theme-lego { color: #b00d0e; }
.text-theme-adventure { color: #5836b8; }
.decoration-theme-gaming { text-decoration-color: #4caf7d; }
.decoration-theme-tech { text-decoration-color: #5aa9e6; }
@@ -101,6 +103,7 @@ html[data-phase="night"] {
.decoration-theme-kids { text-decoration-color: #14b8a6; }
.decoration-theme-finance { text-decoration-color: #10b981; }
.decoration-theme-lego { text-decoration-color: #d01012; }
.decoration-theme-adventure { text-decoration-color: #6d4bd8; }
.border-theme-gaming { border-color: #4caf7d; }
.border-theme-tech { border-color: #5aa9e6; }
@@ -112,6 +115,7 @@ html[data-phase="night"] {
.border-theme-kids { border-color: #14b8a6; }
.border-theme-finance { border-color: #10b981; }
.border-theme-lego { border-color: #d01012; }
.border-theme-adventure { border-color: #6d4bd8; }
/* "Posted to Matrix" glow — soft pulsing aura in the channel's theme color. */
.glow-theme-gaming { box-shadow: 0 0 0 2px rgba(76,175,125,0.35), 0 0 24px 4px rgba(76,175,125,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@@ -124,6 +128,7 @@ html[data-phase="night"] {
.glow-theme-kids { box-shadow: 0 0 0 2px rgba(20,184,166,0.35), 0 0 24px 4px rgba(20,184,166,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-finance { box-shadow: 0 0 0 2px rgba(16,185,129,0.35), 0 0 24px 4px rgba(16,185,129,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-lego { box-shadow: 0 0 0 2px rgba(208,16,18,0.35), 0 0 24px 4px rgba(208,16,18,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
.glow-theme-adventure { box-shadow: 0 0 0 2px rgba(109,75,216,0.35), 0 0 24px 4px rgba(109,75,216,0.45); animation: pete-glow-pulse 2.4s ease-in-out infinite; }
@keyframes pete-glow-pulse {
0%, 100% { filter: brightness(1); }

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,7 @@ import (
)
func TestStatusTemplateExecutes(t *testing.T) {
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

View File

@@ -13,6 +13,81 @@
</div>
</section>
{{if .ShowRoster}}
<section class="mb-10" id="roster" data-stale="{{.RosterStale}}">
<div class="flex items-baseline justify-between mb-3">
<h2 class="font-display text-2xl font-bold">Out there right now</h2>
<p class="text-xs uppercase tracking-wider text-[color:var(--ink)]/50" id="roster-status">
{{if .RosterStale}}last known — the wire's gone quiet{{else}}live{{end}}
</p>
</div>
<div class="rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 shadow-pete overflow-hidden {{if .RosterStale}}opacity-60{{end}}" id="roster-card">
<ul class="divide-y divide-[color:var(--ink)]/10" id="roster-list">
{{range .Roster}}
<li class="flex items-center gap-4 px-5 py-3">
<span class="text-lg" aria-hidden="true">{{if .OnRun}}⚔{{else}}🏠{{end}}</span>
<span class="font-semibold">{{.Name}}</span>
<span class="text-sm text-[color:var(--ink)]/60">lv {{.Level}} {{.ClassRace}}</span>
<span class="ml-auto text-sm {{if .OnRun}}font-semibold{{else}}text-[color:var(--ink)]/60{{end}}">
{{.Where}}{{if .Idle}} <span class="text-[color:var(--ink)]/45">· {{.Idle}}</span>{{end}}
</span>
</li>
{{else}}
<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody's out at the moment. Quiet week in the realm.</li>
{{end}}
</ul>
</div>
</section>
<script>
// The board is state, not a story: an open tab should keep telling the truth
// without a reload. Cheap poll — a handful of rows, no auth, no cache.
(function () {
var list = document.getElementById('roster-list');
var status = document.getElementById('roster-status');
var card = document.getElementById('roster-card');
if (!list) return;
function esc(s) {
var d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function row(a) {
var idle = a.Idle ? ' <span class="text-[color:var(--ink)]/45">· ' + esc(a.Idle) + '</span>' : '';
return '<li class="flex items-center gap-4 px-5 py-3">' +
'<span class="text-lg" aria-hidden="true">' + (a.OnRun ? '⚔' : '🏠') + '</span>' +
'<span class="font-semibold">' + esc(a.Name) + '</span>' +
'<span class="text-sm text-[color:var(--ink)]/60">lv ' + esc(a.Level) + ' ' + esc(a.ClassRace) + '</span>' +
'<span class="ml-auto text-sm ' + (a.OnRun ? 'font-semibold' : 'text-[color:var(--ink)]/60') + '">' +
esc(a.Where) + idle + '</span></li>';
}
function refresh() {
fetch('/api/roster', { headers: { 'Accept': 'application/json' } })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (!data) return;
var rows = data.adventurers || [];
list.innerHTML = rows.length
? rows.map(row).join('')
: '<li class="px-5 py-8 text-center text-[color:var(--ink)]/60">Nobody\'s out at the moment. Quiet week in the realm.</li>';
status.textContent = data.stale ? "last known — the wire's gone quiet" : 'live';
card.classList.toggle('opacity-60', !!data.stale);
})
.catch(function () { /* transient — the next tick will do */ });
}
setInterval(refresh, 60000);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) refresh();
});
})();
</script>
{{end}}
{{if .Stories}}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{{range .Stories}}

View File

@@ -4,6 +4,8 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}{{.SiteTitle}}{{end}}</title>
{{if .NoIndex}}<meta name="robots" content="noindex">{{end}}
{{if .OGImage}}<meta property="og:image" content="{{.OGImage}}"><meta name="twitter:card" content="summary_large_image">{{end}}
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">

View File

@@ -0,0 +1,27 @@
{{define "title"}}{{.Headline}} — {{.SiteTitle}}{{end}}
{{define "main"}}
<article class="mt-2 mb-10 max-w-3xl mx-auto">
<nav class="mb-4">
<a href="/adventure" class="inline-flex items-center gap-1.5 text-sm font-semibold text-[color:var(--ink)]/60 hover:text-[color:var(--ink)] transition">
<span aria-hidden="true"></span> All dispatches
</a>
</nav>
<header class="rounded-3xl bg-theme-adventure text-white p-6 sm:p-10 shadow-pete relative overflow-hidden">
<div class="absolute -top-6 -right-6 text-[12rem] opacity-20 select-none" aria-hidden="true">{{.Emoji}}</div>
<div class="relative">
<p class="text-sm uppercase tracking-[0.2em] opacity-80">{{.Emoji}} {{.EventLabel}}</p>
<h1 class="font-display text-3xl sm:text-4xl font-bold mt-2 leading-tight">{{.Headline}}</h1>
<p class="mt-4 text-xs uppercase tracking-wider opacity-75">Reported {{.When}}{{if .Region}} · {{.Region}}{{end}}</p>
</div>
</header>
<div class="mt-8 rounded-3xl bg-[color:var(--card)] border-2 border-[color:var(--ink)]/10 p-6 sm:p-8 shadow-pete">
<p class="text-lg leading-relaxed text-[color:var(--ink)]/90 whitespace-pre-line">{{.Body}}</p>
<p class="mt-8 pt-6 border-t border-[color:var(--ink)]/10 text-sm italic text-theme-adventure font-semibold">
Reporting from the realm, this is Pete.
</p>
</div>
</article>
{{end}}

View File

@@ -67,6 +67,14 @@ func thumbURL(src string) string {
if src == "" {
return ""
}
// Root-relative sources are our own local assets (e.g. the adventure
// emblems) — serve them directly; the thumbnailer only handles remote
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
// it's a protocol-relative remote image, and it has to keep going through
// the guarded thumbnailer like any other third-party URL.
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
return src
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}

View File

@@ -46,7 +46,7 @@ func TestIndexTrendingAndBadges(t *testing.T) {
storage.RecordStoryView(id)
storage.RecordStoryView(id)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true)
s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil)
if err != nil {
t.Fatal(err)
}

40
main.go
View File

@@ -248,6 +248,13 @@ func main() {
if postingEnabled && roundRobinMode {
channelNames := make([]string, 0, len(cfg.Matrix.Channels))
for name := range cfg.Matrix.Channels {
// The adventure channel drives its own posting: priority beats go
// live at ingest, bulletins wait for the daily digest. Leaving it in
// the rotation would let the scheduler post bulletins one-by-one
// (they're "classified, not yet posted") and steal them from the digest.
if cfg.Adventure.Enabled && name == cfg.Adventure.Channel {
continue
}
channelNames = append(channelNames, name)
}
rr := scheduler.New(channelNames, cfg.Posting.RoundRobin.IntervalHours, queue)
@@ -258,14 +265,40 @@ func main() {
}
}
// Adapter: the web ingest handler posts priority adventure beats live to
// Matrix through the same queue everything else uses (bypasses pacing).
//
// Adventure carries its own enable switch and is push-based: facts arrive
// from gogobee at ingest, they are not polled, classified or paced, and they
// never enter the round-robin rotation. So it is gated on [adventure] alone,
// NOT on posting.enabled — that flag governs the RSS pipeline's chatter, and
// an operator running "news on the web only, adventure live in the games
// room" is a legitimate configuration. A nil poster (adventure off, or no
// channel) still keeps both the live beats and the digest loop shut.
var advPost web.PriorityPoster
if cfg.Adventure.Enabled && cfg.Adventure.Channel != "" {
advPost = func(p web.AdvPost) {
queue.PostNow(poster.QueueItem{
GUID: p.GUID,
Headline: p.Headline,
Lede: p.Lede,
ImageURL: p.ImageURL,
ArticleURL: p.ArticleURL,
Source: p.Source,
Channel: p.Channel,
})
}
}
// Start the read-only web UI alongside the Matrix bot.
if cfg.Web.Enabled {
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled)
ws, err := web.New(cfg.Web, cfg.Sources, postingEnabled, cfg.Adventure, advPost)
if err != nil {
slog.Error("web server init failed", "err", err)
} else {
go ws.Start(ctx)
ws.StartPushSender(ctx)
ws.StartAdventureDigest(ctx)
}
}
@@ -370,8 +403,9 @@ func runLocal(cfg *config.Config) {
poller.Start(ctx)
slog.Info("local: pollers started")
// Local mode never connects to Matrix, so it's always web-only.
ws, err := web.New(cfg.Web, cfg.Sources, false)
// Local mode never connects to Matrix, so it's always web-only: adventure
// stories still ingest and render, but nothing posts (nil priority poster).
ws, err := web.New(cfg.Web, cfg.Sources, false, cfg.Adventure, nil)
if err != nil {
slog.Error("local: web server init failed", "err", err)
os.Exit(1)