- 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
156 lines
5.4 KiB
Go
156 lines
5.4 KiB
Go
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),
|
|
Source: advSource,
|
|
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)
|
|
}
|