package config import ( "fmt" "log/slog" "os" "regexp" "gopkg.in/yaml.v3" ) // envBracketRe matches only ${VAR} style env references, not bare $VAR. var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`) type Config struct { Matrix MatrixConfig `yaml:"matrix"` Posting PostingConfig `yaml:"posting"` Storage StorageConfig `yaml:"storage"` Web WebConfig `yaml:"web"` Sources []SourceConfig `yaml:"sources"` } // WebConfig controls the read-only HTTP interface (news.parodia.dev style). type WebConfig struct { Enabled bool `yaml:"enabled"` ListenAddr string `yaml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080" SiteTitle string `yaml:"site_title"` // display name in the header BaseURL string `yaml:"base_url"` // public URL (used in metadata only) } type MatrixConfig struct { Homeserver string `yaml:"homeserver"` UserID string `yaml:"user_id"` Password string `yaml:"password"` PickleKey string `yaml:"pickle_key"` DisplayName string `yaml:"display_name"` DataDir string `yaml:"data_dir"` AdminRoom string `yaml:"admin_room"` Channels map[string]string `yaml:"channels"` } type PostingConfig struct { MinIntervalSeconds int `yaml:"min_interval_seconds"` BurstCapCount int `yaml:"burst_cap_count"` BurstCapWindowSeconds int `yaml:"burst_cap_window_seconds"` DedupCooldownHours int `yaml:"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 `yaml:"daily_cap_total"` RoundRobin RoundRobinConfig `yaml:"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 `yaml:"enabled"` IntervalHours int `yaml:"interval_hours"` } type StorageConfig struct { DBPath string `yaml:"db_path"` RecentWindowHours int `yaml:"recent_window_hours"` } type SourceConfig struct { Name string `yaml:"name"` FeedURL string `yaml:"feed_url"` Tier int `yaml:"tier"` PollIntervalMinutes int `yaml:"poll_interval_minutes"` DirectRoute string `yaml:"direct_route"` Enabled bool `yaml:"enabled"` } 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 := yaml.Unmarshal([]byte(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") } 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") } 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 { return fmt.Errorf("sources[%d] (%s): direct_route %q is not a configured matrix.channels key", i, s.Name, 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.Matrix.PickleKey == "" { c.Matrix.PickleKey = "pete_pickle_key" } 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" } for i := range c.Sources { if c.Sources[i].PollIntervalMinutes == 0 { c.Sources[i].PollIntervalMinutes = 20 } } }