One story per interval_hours (default 4), cycling through enabled sources in config order. Empty sources are skipped and the pointer advances to whichever source actually posted. State persists across restarts. Duplicate-flagged stories now get a _duplicate sentinel channel so they stay out of the rotation pool alongside _discarded.
187 lines
5.2 KiB
Go
187 lines
5.2 KiB
Go
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"`
|
|
Ollama OllamaConfig `yaml:"ollama"`
|
|
Posting PostingConfig `yaml:"posting"`
|
|
Storage StorageConfig `yaml:"storage"`
|
|
Sources []SourceConfig `yaml:"sources"`
|
|
}
|
|
|
|
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 OllamaConfig struct {
|
|
BaseURL string `yaml:"base_url"`
|
|
Model string `yaml:"model"`
|
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
|
}
|
|
|
|
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 source in
|
|
// config 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"`
|
|
ClassificationLogDays int `yaml:"classification_log_days"`
|
|
}
|
|
|
|
type SourceConfig struct {
|
|
Name string `yaml:"name"`
|
|
FeedURL string `yaml:"feed_url"`
|
|
Tier int `yaml:"tier"`
|
|
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
|
FeedHint string `yaml:"feed_hint"`
|
|
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.Ollama.BaseURL == "" {
|
|
return fmt.Errorf("ollama.base_url is required")
|
|
}
|
|
if c.Ollama.Model == "" {
|
|
return fmt.Errorf("ollama.model is required")
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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.Ollama.TimeoutSeconds == 0 {
|
|
c.Ollama.TimeoutSeconds = 30
|
|
}
|
|
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.Storage.ClassificationLogDays == 0 {
|
|
c.Storage.ClassificationLogDays = 7
|
|
}
|
|
for i := range c.Sources {
|
|
if c.Sources[i].PollIntervalMinutes == 0 {
|
|
c.Sources[i].PollIntervalMinutes = 20
|
|
}
|
|
}
|
|
}
|