- 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
354 lines
13 KiB
Go
354 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"regexp"
|
|
|
|
"github.com/BurntSushi/toml"
|
|
)
|
|
|
|
// envBracketRe matches only ${VAR} style env references, not bare $VAR.
|
|
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
|
|
|
type Config struct {
|
|
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"`
|
|
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
|
SiteTitle string `toml:"site_title"` // display name in the header
|
|
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
|
Auth AuthConfig `toml:"auth"` // optional OIDC sign-in (Authentik)
|
|
Push PushConfig `toml:"push"` // optional Web Push digests (VAPID)
|
|
TTS TTSConfig `toml:"tts"` // optional server-side neural read-aloud (Piper)
|
|
// AdminSubs is the allowlist of OIDC subjects allowed to view the
|
|
// owner-facing source-health dashboard at /status. Empty means the page is
|
|
// inaccessible to everyone (returns 404). Requires auth to be enabled.
|
|
AdminSubs []string `toml:"admin_subs"`
|
|
}
|
|
|
|
// PushConfig wires the Web Push digest sender. Push is signed-in only (it keys
|
|
// off the OIDC subject) so it does nothing unless auth is also enabled. Generate
|
|
// a VAPID keypair once with `pete -genvapid` and paste the two keys here.
|
|
type PushConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
VAPIDPublicKey string `toml:"vapid_public_key"`
|
|
VAPIDPrivateKey string `toml:"vapid_private_key"`
|
|
// Subject identifies the sender to the push service; a mailto: or https: URL
|
|
// per RFC 8292. Defaults to mailto:admin@<base_url host> is not attempted —
|
|
// set it explicitly.
|
|
Subject string `toml:"subject"`
|
|
// IntervalMinutes is how often the digest sender wakes to look for new
|
|
// stories per subscriber. Defaults to 360 (6h).
|
|
IntervalMinutes int `toml:"interval_minutes"`
|
|
// MinStories is the smallest number of new stories that triggers a digest
|
|
// for a subscriber, so they aren't pinged for a single item. Defaults to 3.
|
|
MinStories int `toml:"min_stories"`
|
|
}
|
|
|
|
// TTSConfig wires server-side neural read-aloud (Piper). When enabled,
|
|
// signed-in users get the reader's "Listen" button backed by real Piper voices
|
|
// instead of the browser's robotic Web Speech voice. Read-aloud is a signed-in
|
|
// perk, so this does nothing unless auth is also enabled.
|
|
type TTSConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
PiperBin string `toml:"piper_bin"` // path to the piper executable
|
|
VoicesDir string `toml:"voices_dir"` // directory holding <id>.onnx (+ .onnx.json) models
|
|
// Voices lists the voices to offer, in menu order. Each id is a model
|
|
// filename stem, so id "en_US-ryan-high" maps to <voices_dir>/en_US-ryan-high.onnx.
|
|
// Leave empty to auto-discover every *.onnx in voices_dir.
|
|
Voices []VoiceConfig `toml:"voices"`
|
|
// Default is the voice id selected until the reader picks another. Empty
|
|
// falls back to the first available voice.
|
|
Default string `toml:"default"`
|
|
}
|
|
|
|
// VoiceConfig is one selectable Piper voice.
|
|
type VoiceConfig struct {
|
|
ID string `toml:"id"` // model filename stem, e.g. "en_US-ryan-high"
|
|
Label string `toml:"label"` // menu label, e.g. "Ryan — US, male"
|
|
}
|
|
|
|
// AuthConfig wires Pete's web UI to an OIDC provider (our Authentik instance).
|
|
// When enabled, signed-in users get their preferences stored server-side keyed
|
|
// by the OIDC subject; anonymous visitors keep using browser localStorage.
|
|
type AuthConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
Issuer string `toml:"issuer"` // e.g. https://authentik.parodia.dev/application/o/pete/
|
|
ClientID string `toml:"client_id"`
|
|
ClientSecret string `toml:"client_secret"`
|
|
RedirectURL string `toml:"redirect_url"` // e.g. https://news.parodia.dev/auth/callback
|
|
SessionSecret string `toml:"session_secret"` // HMAC key for signing the session cookie
|
|
}
|
|
|
|
type MatrixConfig struct {
|
|
Homeserver string `toml:"homeserver"`
|
|
UserID string `toml:"user_id"`
|
|
Password string `toml:"password"`
|
|
PickleKey string `toml:"pickle_key"`
|
|
DisplayName string `toml:"display_name"`
|
|
DataDir string `toml:"data_dir"`
|
|
AdminRoom string `toml:"admin_room"`
|
|
Channels map[string]string `toml:"channels"`
|
|
// Admins is the allowlist of Matrix user IDs permitted to run in-room
|
|
// commands like !post. Empty means commands are disabled — the channel
|
|
// rooms are public, so an empty list must NOT mean "anyone".
|
|
Admins []string `toml:"admins"`
|
|
}
|
|
|
|
type PostingConfig struct {
|
|
// Enabled is the master switch for automatic news posting to Matrix.
|
|
// When false, stories are still ingested, classified, and served to the
|
|
// web UI, but Pete never auto-posts them to rooms. Command replies
|
|
// (!post, !petestats) still work. Pointer so an absent key defaults to
|
|
// true (posting on) rather than Go's zero-value false.
|
|
Enabled *bool `toml:"enabled"`
|
|
MinIntervalSeconds int `toml:"min_interval_seconds"`
|
|
BurstCapCount int `toml:"burst_cap_count"`
|
|
BurstCapWindowSeconds int `toml:"burst_cap_window_seconds"`
|
|
DedupCooldownHours int `toml:"dedup_cooldown_hours"`
|
|
// DailyCapTotal is the hard global cap on posts across ALL channels in a
|
|
// rolling 24-hour window. 0 disables the cap.
|
|
DailyCapTotal int `toml:"daily_cap_total"`
|
|
RoundRobin RoundRobinConfig `toml:"round_robin"`
|
|
}
|
|
|
|
// RoundRobinConfig switches Pete from immediate-on-classify posting to a
|
|
// paced rotation: one story per IntervalHours, picking the next channel in
|
|
// rotation order that has a postable story (skip-and-advance, newest-first).
|
|
type RoundRobinConfig struct {
|
|
Enabled bool `toml:"enabled"`
|
|
IntervalHours int `toml:"interval_hours"`
|
|
}
|
|
|
|
type StorageConfig struct {
|
|
DBPath string `toml:"db_path"`
|
|
RecentWindowHours int `toml:"recent_window_hours"`
|
|
}
|
|
|
|
type SourceConfig struct {
|
|
Name string `toml:"name"`
|
|
FeedURL string `toml:"feed_url"`
|
|
Tier int `toml:"tier"`
|
|
PollIntervalMinutes int `toml:"poll_interval_minutes"`
|
|
DirectRoute string `toml:"direct_route"`
|
|
Enabled bool `toml:"enabled"`
|
|
// Language, when set, drops feed items whose per-item language tag is
|
|
// present and does not match (prefix). Useful for multilingual feeds
|
|
// like Politico Europe that publish English + French side-by-side.
|
|
Language string `toml:"language"`
|
|
// UserAgent overrides the User-Agent sent when fetching this feed. Empty
|
|
// uses Pete's honest default bot UA. Set a browser-like string only for
|
|
// sources whose WAF (e.g. AWS WAF on The Portugal News) blocks bot UAs.
|
|
UserAgent string `toml:"user_agent"`
|
|
}
|
|
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
|
|
// Expand only ${VAR} style env references, not bare $VAR
|
|
// (bare $ in passwords like "pa$$word" must not be mangled)
|
|
expanded := envBracketRe.ReplaceAllStringFunc(string(data), func(match string) string {
|
|
varName := match[2 : len(match)-1] // strip ${ and }
|
|
val := os.Getenv(varName)
|
|
if val == "" {
|
|
slog.Warn("config: env var referenced but not set", "var", varName)
|
|
}
|
|
return val
|
|
})
|
|
|
|
var cfg Config
|
|
if _, err := toml.Decode(expanded, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
|
|
cfg.applyDefaults()
|
|
|
|
if err := cfg.validate(); err != nil {
|
|
return nil, fmt.Errorf("validate config: %w", err)
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
func (c *Config) validate() error {
|
|
if c.Matrix.Homeserver == "" {
|
|
return fmt.Errorf("matrix.homeserver is required")
|
|
}
|
|
if c.Matrix.UserID == "" {
|
|
return fmt.Errorf("matrix.user_id is required")
|
|
}
|
|
if c.Matrix.Password == "" {
|
|
return fmt.Errorf("matrix.password is required")
|
|
}
|
|
// pickle_key encrypts the E2EE crypto store (olm/megolm + cross-signing
|
|
// keys) at rest. Never fall back to a hardcoded default: a key baked into
|
|
// the source is no protection if crypto.db ever leaks.
|
|
if len(c.Matrix.PickleKey) < 16 {
|
|
return fmt.Errorf("matrix.pickle_key must be set to a strong secret (>=16 chars); it encrypts the E2EE crypto store at rest")
|
|
}
|
|
if len(c.Matrix.Channels) == 0 {
|
|
return fmt.Errorf("matrix.channels must have at least one entry")
|
|
}
|
|
if c.Storage.DBPath == "" {
|
|
return fmt.Errorf("storage.db_path is required")
|
|
}
|
|
|
|
if c.Web.Auth.Enabled {
|
|
a := c.Web.Auth
|
|
if a.Issuer == "" || a.ClientID == "" || a.ClientSecret == "" || a.RedirectURL == "" {
|
|
return fmt.Errorf("web.auth requires issuer, client_id, client_secret, and redirect_url when enabled")
|
|
}
|
|
if len(a.SessionSecret) < 16 {
|
|
return fmt.Errorf("web.auth.session_secret must be at least 16 characters")
|
|
}
|
|
}
|
|
|
|
if c.Web.Push.Enabled {
|
|
if !c.Web.Auth.Enabled {
|
|
return fmt.Errorf("web.push requires web.auth to be enabled (push is signed-in only)")
|
|
}
|
|
p := c.Web.Push
|
|
if p.VAPIDPublicKey == "" || p.VAPIDPrivateKey == "" {
|
|
return fmt.Errorf("web.push requires vapid_public_key and vapid_private_key (generate with: pete -genvapid)")
|
|
}
|
|
if p.Subject == "" {
|
|
return fmt.Errorf("web.push.subject is required (a mailto: or https: URL identifying the sender)")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if s.FeedURL == "" {
|
|
return fmt.Errorf("sources[%d].feed_url is required", i)
|
|
}
|
|
if s.Tier < 1 || s.Tier > 3 {
|
|
return fmt.Errorf("sources[%d].tier must be 1-3", i)
|
|
}
|
|
if s.PollIntervalMinutes <= 0 {
|
|
return fmt.Errorf("sources[%d].poll_interval_minutes must be > 0", i)
|
|
}
|
|
if !s.Enabled {
|
|
continue
|
|
}
|
|
if s.DirectRoute == "" {
|
|
return fmt.Errorf("sources[%d] (%s): direct_route is required for enabled sources", i, s.Name)
|
|
}
|
|
if _, ok := c.Matrix.Channels[s.DirectRoute]; !ok {
|
|
// Not a Matrix channel — treated as web-only (stories visible in the
|
|
// UI but never posted). Warn so typos still surface.
|
|
slog.Warn("source routes to non-Matrix channel (web-only)",
|
|
"source", s.Name, "direct_route", s.DirectRoute)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) applyDefaults() {
|
|
if c.Matrix.DataDir == "" {
|
|
c.Matrix.DataDir = "./data"
|
|
}
|
|
if c.Matrix.DisplayName == "" {
|
|
c.Matrix.DisplayName = "Pete"
|
|
}
|
|
if c.Posting.Enabled == nil {
|
|
on := true
|
|
c.Posting.Enabled = &on
|
|
}
|
|
if c.Posting.MinIntervalSeconds == 0 {
|
|
c.Posting.MinIntervalSeconds = 300
|
|
}
|
|
if c.Posting.BurstCapCount == 0 {
|
|
c.Posting.BurstCapCount = 3
|
|
}
|
|
if c.Posting.BurstCapWindowSeconds == 0 {
|
|
c.Posting.BurstCapWindowSeconds = 1800
|
|
}
|
|
if c.Posting.DedupCooldownHours == 0 {
|
|
c.Posting.DedupCooldownHours = 48
|
|
}
|
|
if c.Posting.RoundRobin.IntervalHours == 0 {
|
|
c.Posting.RoundRobin.IntervalHours = 4
|
|
}
|
|
if c.Storage.RecentWindowHours == 0 {
|
|
c.Storage.RecentWindowHours = 24
|
|
}
|
|
if c.Web.ListenAddr == "" {
|
|
c.Web.ListenAddr = ":8080"
|
|
}
|
|
if c.Web.SiteTitle == "" {
|
|
c.Web.SiteTitle = "Pete"
|
|
}
|
|
if c.Web.Push.IntervalMinutes == 0 {
|
|
c.Web.Push.IntervalMinutes = 360
|
|
}
|
|
if c.Web.Push.MinStories == 0 {
|
|
c.Web.Push.MinStories = 3
|
|
}
|
|
for i := range c.Sources {
|
|
if c.Sources[i].PollIntervalMinutes == 0 {
|
|
c.Sources[i].PollIntervalMinutes = 20
|
|
}
|
|
}
|
|
}
|