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) { slog.Info("web: adventure digest scheduler started", "digest_hour_utc", s.adv.DigestHour, "channel", s.adv.Channel) for { wait := durUntilNextHour(time.Now().UTC(), s.adv.DigestHour) 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, err := storage.UnpostedAdventureSince(since, digestCap+1) if err != nil { slog.Error("adventure digest: query failed", "err", err) return } if len(bulletins) == 0 { return } truncated := false if len(bulletins) > digestCap { bulletins = bulletins[:digestCap] truncated = true slog.Warn("adventure digest: window exceeded cap, truncating", "cap", digestCap) } date := now.Format("2006-01-02") eventID := "adv-digest:" + date headline, lede := buildDigest(bulletins, truncated) 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. // Headlines come from stored, fact-guarded rows, so no player-controlled text is // introduced here. func buildDigest(bulletins []storage.Story, truncated bool) (headline, lede string) { n := len(bulletins) if n == 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.", n) 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 n > len(shown) || truncated { more = fmt.Sprintf(" …and %d more.", n-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 { base := strings.TrimRight(s.cfg.BaseURL, "/") return base + "/adventure?digest=" + date }