Files
Pete/internal/config/config.go
prosolis c9318d7bb0 Hard daily cap, no-flood shutdown, ctx-aware poller, double-image fix
Four related fixes after Pete flooded a channel and ignored Ctrl-C:

1. Global daily cap (posting.daily_cap_total, default 5): hard ceiling on
   posts across ALL channels in a rolling 24h window. Checked before the
   per-channel min-interval and burst-cap.

2. Shutdown no longer flushes the queue. Previous drainAll posted every
   remaining item with rate limits disabled — which was literally the
   flood. Replaced with dropOnShutdown that clears queues and logs
   the count.

3. Poller respects ctx mid-loop. pollOnceWithErr now takes ctx and bails
   between items, so Ctrl-C doesn't have to wait for ~30s of network
   per pending story before shutdown can complete.

4. Double-image fix. PostStory now reports imageSent; the queue clears
   ImageURL before retry so a text-send failure after a successful
   image upload doesn't re-post the image.
2026-05-22 18:51:33 -07:00

175 lines
4.7 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"`
}
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.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
}
}
}