diff --git a/internal/config/config.go b/internal/config/config.go index 084704d..e661bd7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,8 +6,11 @@ import ( "os" "path/filepath" "strconv" + "time" "github.com/BurntSushi/toml" + + "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec: @@ -21,17 +24,25 @@ const ( // 127.0.0.1 (Task-04-Brief: „Port Default 7800"). const DefaultServerPort = 7800 +// DefaultRateLimit ist das Daemon-Gesetz gegen Spam (Task-05-Brief): +// Default 1 Ansage / 30 s. „0s" schaltet das Limit ab. +const DefaultRateLimit = 30 * time.Second + // Config ist der effektiv geladene Konfigurations-Stand von talk. Task 02 // führt audio.sink; Task 03 ergänzt den Provider-Pin, Provider-Endpunkte // und das Voice-Mapping; Task 04 den Daemon-Port (server.port, TALK_PORT); -// spätere Tasks ergänzen weitere Felder (queue …), ohne die Auflösungs- -// reihenfolge zu ändern. +// Task 05 die Anti-Spam-Gesetze (queue.rate_limit, queue.allow_high_burst, +// quiet_hours, quiet_mode); spätere Tasks ergänzen weitere Felder, ohne die +// Auflösungs-Reihenfolge zu ändern. type Config struct { Audio Audio `toml:"audio"` // Server steuert den Daemon-HTTP-Server (talkd) auf 127.0.0.1. Server Server `toml:"server"` + // Queue steuert die Anti-Spam-Gesetze des Daemons (Task 05). + Queue Queue `toml:"queue"` + // Provider pinnt die Kette auf genau einen Provider (z. B. // `provider = "elevenlabs"`); leer = komplette Default-Kette // (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider @@ -48,6 +59,17 @@ type Config struct { // `[voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM"`. Schlägt die // eingebauten neutralen Defaults; das CLI-Flag --voice schlägt alles. Voice map[string]map[string]string `toml:"voice"` + + // QuietHours ist das Ruhezeit-Fenster "HH:MM-HH:MM" in lokaler Zeit + // (überspannt Mitternacht, z. B. "18:00-08:00"); leer = deaktiviert. + // Bewusst kein Default-Fenster: Ein aktives Gesetz, das nach Uhrzeit + // verwirft, würde jeden Cron/Test in den Abendstunden stilllegen — + // Ruhezeiten sind Opt-in wie Mute. + QuietHours string `toml:"quiet_hours"` + + // QuietMode bestimmt das Verhalten in der Ruhezeit: "discard" (Default, + // still verwerfen) oder "toast" (Benachrichtigung statt Audio). + QuietMode string `toml:"quiet_mode"` } // Audio steuert die Audio-Ausgabe. @@ -65,6 +87,17 @@ type Server struct { Port int `toml:"port"` } +// Queue sind die Rate-Limit-Gesetze des Daemons (TOML `[queue]`). +type Queue struct { + // RateLimit ist der Mindestabstand zwischen zwei Ansage-Starten als + // Dauer-String (z. B. "30s"; "0s" = unbegrenzt). Default: 30 s. + RateLimit time.Duration `toml:"rate_limit"` + + // AllowHighBurst erlaubt priority=high, das Rate-Limit bewusst zu + // verstoßen (Default true). Die Ruhezeit gilt davon unberührt. + AllowHighBurst bool `toml:"allow_high_burst"` +} + // ProviderSettings sind die per Provider konfigurierbaren Endpunkte und // Modelle (TOML `[providers.]`). Leer = eingebaute Defaults; die // Env-Overrides TALK_ELEVENLABS_ENDPOINT / TALK_OPENAI_ENDPOINT / @@ -80,8 +113,10 @@ type ProviderSettings struct { // der Auflösung). func Defaults() Config { return Config{ - Audio: Audio{Sink: SinkOto}, - Server: Server{Port: DefaultServerPort}, + Audio: Audio{Sink: SinkOto}, + Server: Server{Port: DefaultServerPort}, + Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true}, + QuietMode: queue.QuietModeDiscard, } } @@ -99,9 +134,9 @@ type Options struct { } // Load löst die Konfiguration schichtweise auf: Defaults → config.toml -// (falls vorhanden) → Env-Overrides (TALK_AUDIO_SINK schlägt die Datei). -// Env- und Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie -// still ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable), +// (falls vorhanden) → Env-Overrides (TALK_* schlägt die Datei). Env- und +// Datei-Validierungsfehler werden als klarer Fehler gemeldet, nie still +// ignoriert. Fehlt das Config-Verzeichnis (z. B. keine HOME-Variable), // läuft talk mit Defaults + Env weiter — die Datei ist optional, kaputte // Umgebungen dürfen talk nicht unbrauchbar machen. func Load(opts Options) (Config, error) { @@ -143,11 +178,52 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { } cfg.Server.Port = port } + if v := getenv("TALK_RATE_LIMIT"); v != "" { + d, err := time.ParseDuration(v) + if err != nil || d < 0 { + return cfg, fmt.Errorf("TALK_RATE_LIMIT: ungültiger Wert %q (Dauer, z. B. \"30s\"; \"0s\" = unbegrenzt)", v) + } + cfg.Queue.RateLimit = d + } + if v := getenv("TALK_ALLOW_HIGH_BURST"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return cfg, fmt.Errorf("TALK_ALLOW_HIGH_BURST: ungültiger Wert %q (true/false)", v) + } + cfg.Queue.AllowHighBurst = b + } + if v := getenv("TALK_QUIET_HOURS"); v != "" { + if v == "off" { + cfg.QuietHours = "" + } else { + if _, err := queue.ParseQuietHours(v); err != nil { + return cfg, fmt.Errorf("TALK_QUIET_HOURS: ungültiges Fenster %q (HH:MM-HH:MM, \"off\" = deaktiviert)", v) + } + cfg.QuietHours = v + } + } + if v := getenv("TALK_QUIET_MODE"); v != "" { + if v != queue.QuietModeDiscard && v != queue.QuietModeToast { + return cfg, fmt.Errorf("TALK_QUIET_MODE: ungültiger Wert %q (erlaubt: %q, %q)", + v, queue.QuietModeDiscard, queue.QuietModeToast) + } + cfg.QuietMode = v + } switch cfg.Audio.Sink { case SinkOto, SinkNull: - return cfg, nil default: return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", cfg.Audio.Sink, SinkOto, SinkNull) } + // quiet_hours aus der Datei validieren (Env wurde oben geprüft) und + // quiet_mode leer (Datei ohne Wert) auf den Default heben. + if cfg.QuietHours != "" { + if _, err := queue.ParseQuietHours(cfg.QuietHours); err != nil { + return cfg, err + } + } + if cfg.QuietMode == "" { + cfg.QuietMode = queue.QuietModeDiscard + } + return cfg, nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9623785..bc58add 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,6 +5,9 @@ import ( "path/filepath" "strings" "testing" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/queue" ) func env(kv map[string]string) func(string) string { @@ -352,3 +355,179 @@ func TestLoadServerPort(t *testing.T) { } }) } + +// TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s, +// "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus; +// Format validiert) samt Env-Overrides und klaren Fehlern. +func TestLoadGesetze(t *testing.T) { + writeTOML := func(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + return path + } + fehlendeDatei := func(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "gibt-es-nicht.toml") + } + + t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) { + cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Queue.RateLimit != DefaultRateLimit { + t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit) + } + if !cfg.Queue.AllowHighBurst { + t.Error("Queue.AllowHighBurst = false, want Default true") + } + if cfg.QuietHours != "" { + t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours) + } + if cfg.QuietMode != queue.QuietModeDiscard { + t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard) + } + }) + + t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, ` +quiet_hours = "18:00-08:00" +quiet_mode = "toast" + +[queue] +rate_limit = "10s" +allow_high_burst = false +`), + Getenv: env(nil), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Queue.RateLimit != 10*time.Second { + t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit) + } + if cfg.Queue.AllowHighBurst { + t.Error("Queue.AllowHighBurst = true, want false aus TOML") + } + if cfg.QuietHours != "18:00-08:00" { + t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours) + } + if cfg.QuietMode != queue.QuietModeToast { + t.Errorf("QuietMode = %q, want toast", cfg.QuietMode) + } + }) + + t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"), + Getenv: env(nil), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Queue.RateLimit != 0 { + t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit) + } + if !cfg.Queue.AllowHighBurst { + t.Error("Queue.AllowHighBurst = false, want unveränderten Default true") + } + if cfg.QuietMode != queue.QuietModeDiscard { + t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode) + } + }) + + t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) { + cfg, err := Load(Options{ + File: writeTOML(t, ` +quiet_hours = "18:00-08:00" +quiet_mode = "discard" + +[queue] +rate_limit = "10s" +allow_high_burst = false +`), + Getenv: env(map[string]string{ + "TALK_RATE_LIMIT": "1s", + "TALK_ALLOW_HIGH_BURST": "true", + "TALK_QUIET_HOURS": "off", + "TALK_QUIET_MODE": "toast", + }), + }) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Queue.RateLimit != time.Second { + t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit) + } + if !cfg.Queue.AllowHighBurst { + t.Error("Queue.AllowHighBurst = false, want true aus Env") + } + if cfg.QuietHours != "" { + t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours) + } + if cfg.QuietMode != queue.QuietModeToast { + t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode) + } + }) + + t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) { + cases := []struct { + name string + toml string + envs map[string]string + want string + }{ + { + name: "TALK_RATE_LIMIT Müll", + envs: map[string]string{"TALK_RATE_LIMIT": "schnell"}, + want: "TALK_RATE_LIMIT", + }, + { + name: "TALK_RATE_LIMIT negativ", + envs: map[string]string{"TALK_RATE_LIMIT": "-5s"}, + want: "TALK_RATE_LIMIT", + }, + { + name: "rate_limit Müll in Datei", + toml: "[queue]\nrate_limit = \"müll\"\n", + want: "rate_limit", + }, + { + name: "TALK_ALLOW_HIGH_BURST Müll", + envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"}, + want: "TALK_ALLOW_HIGH_BURST", + }, + { + name: "quiet_hours Müll in Datei", + toml: "quiet_hours = \"abends\"\n", + want: "quiet_hours", + }, + { + name: "TALK_QUIET_HOURS Müll", + envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"}, + want: "TALK_QUIET_HOURS", + }, + { + name: "TALK_QUIET_MODE Müll", + envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"}, + want: "TALK_QUIET_MODE", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + file := fehlendeDatei(t) + if tc.toml != "" { + file = writeTOML(t, tc.toml) + } + _, err := Load(Options{File: file, Getenv: env(tc.envs)}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want) + } + }) + } + }) +}