Files
Pete/internal/config/config.go
prosolis 78fc3ef811 Add per-source language filter
When a source sets language = "en", drop items whose per-item
language tag is present and doesn't prefix-match. Items without
a language tag pass through unchanged. Politico Europe is the
motivating case — same headlines appear in en, fr, and de.
2026-05-26 23:03:12 -07:00

197 lines
5.8 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"`
Sources []SourceConfig `toml:"sources"`
}
// 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)
}
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"`
}
type PostingConfig struct {
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"`
}
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")
}
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 {
// 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.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
}
}
}