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@ 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 .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 /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 } } }