package config import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "time" "github.com/BurntSushi/toml" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec: // „Audio-Sink wählbar inkl. null"). const ( SinkOto = "oto" SinkNull = "null" ) // DefaultServerPort ist der Default-Port des Daemon-HTTP-Servers auf // 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); // 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"` // Daemon steuert den Hintergrundprozess (Autostart). Daemon Daemon `toml:"daemon"` // Provider pinnt die Kette auf genau einen Provider (z. B. // `provider = "elevenlabs"`); leer = komplette Default-Kette // (elevenlabs → openai → edge → osbuiltin). Das CLI-Flag --provider // schlägt diesen Wert. Gültige Namen prüft die Provider-Schicht. Provider string `toml:"provider"` // Providers enthält per Provider-Einstellungen (Endpunkt, Modell) — // TOML `[providers.elevenlabs] endpoint = "…"`. Key `providers` // (Mehrzahl), damit der Pin ( singular `provider`) und die // Einstellungs-Tabelle in TOML nebeneinander existieren können. Providers map[string]ProviderSettings `toml:"providers"` // Voice ist das Voice-Mapping `voice.. = id` — z. B. // `[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"` // Wrap steuert die Befehlsummwicklung `talk wrap` (Task 07). Wrap Wrap `toml:"wrap"` // 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. type Audio struct { // Sink wählt die Wiedergabe: "oto" (embedded Playback, Default) oder // "null" (verwirft — CI, Server, Tests). Sink string `toml:"sink"` } // Server sind die Netz-Einstellungen des Daemons (nur Loopback; Binding // auf 127.0.0.1 macht cmd/talkd, die Config liefert nur den Port). type Server struct { // Port des Daemon-HTTP-Servers auf 127.0.0.1 — config // `[server] port = 7800`, Env-Override TALK_PORT, Default 7800. Port int `toml:"port"` // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (Task 06): // exakte Origins wie "https://chat.az-gruppe.com". Der Eintrag "*" // erlaubt jede Origin ausdrücklich. Bewusster Default: LEER — ohne // Einträge sendet der Daemon gar keine CORS-Header und der Browser // blockt Cross-Origin-Anfragen; die Tür ist zu, bis sie konfiguriert // wird (Env-Override TALK_ALLOWED_ORIGINS, kommagetrennt; "off" leert). AllowedOrigins []string `toml:"allowed_origins"` } // Daemon sind die Einstellungen des Hintergrundprozesses (TOML `[daemon]`). type Daemon struct { // Autostart lässt talk den Daemon beim ersten Sprechbefehl selbst im // Hintergrund starten (Default true; Env TALK_AUTOSTART). false ist für // Setups, die den Daemon selbst verwalten (systemd, launchd, Task // Scheduler): say spricht dann ohne Daemon direkt, mit Hinweis. Autostart bool `toml:"autostart"` } // Wrap sind die Einstellungen von `talk wrap` (TOML `[wrap]`). type Wrap struct { // Label ersetzt den Basisnamen des Befehls in der Ansage — z. B. // `label = "Deployment"` für „Deployment erfolgreich. Dauer: …" // statt „terraform erfolgreich. Dauer: …" (Env-Override // TALK_WRAP_LABEL schlägt die Datei). Label string `toml:"label"` } // 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 / // TALK_EDGE_ENDPOINT (ausgewertet in der Provider-Schicht) schlagen die // Datei — Keys laufen ausschließlich über TALK_ELEVENLABS_KEY bzw. // TALK_OPENAI_KEY und werden nie in die Datei geschrieben. type ProviderSettings struct { Endpoint string `toml:"endpoint"` Model string `toml:"model"` // nur openai (Default "tts-1") } // Defaults liefert die eingebaute Default-Konfiguration (unterste Schicht // der Auflösung). func Defaults() Config { return Config{ Audio: Audio{Sink: SinkOto}, Server: Server{Port: DefaultServerPort}, Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true}, Daemon: Daemon{Autostart: true}, QuietMode: queue.QuietModeDiscard, } } // Options steuert Load. Alle Felder sind injizierbar, damit die Unit-Tests // ohne echte HOME-/Env-Umgebung und ohne echte Dateien auskommen. type Options struct { // File überschreibt den platform-nativen Pfad der config.toml (Tests // injizieren hier eine Temp-Datei). Leer: Dir() + "config.toml". File string // Getenv liefert Umgebungsvariablen (Default: os.Getenv). Über TALK_* // laufen die Env-Overrides sowie die Pfadauflösung (HOME, // XDG_CONFIG_HOME, APPDATA). Getenv func(string) string } // Load löst die Konfiguration schichtweise auf: Defaults → config.toml // (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) { if opts.Getenv == nil { opts.Getenv = os.Getenv } cfg := Defaults() path := opts.File if path == "" { dir, err := Dir(opts.Getenv) if err != nil { // Kein auffindbares Config-Verzeichnis: optionale Datei entfällt. return applyEnv(cfg, opts.Getenv) } path = filepath.Join(dir, fileName) } if _, err := toml.DecodeFile(path, &cfg); err != nil { if !errors.Is(err, os.ErrNotExist) { return cfg, fmt.Errorf("config %s: %w", path, err) } } return applyEnv(cfg, opts.Getenv) } // applyEnv wendet die Env-Overrides an und validiert das Ergebnis. func applyEnv(cfg Config, getenv func(string) string) (Config, error) { if v := getenv("TALK_AUDIO_SINK"); v != "" { cfg.Audio.Sink = v } if v := getenv("TALK_PROVIDER"); v != "" { cfg.Provider = v } if v := getenv("TALK_PORT"); v != "" { port, err := strconv.Atoi(v) if err != nil || port < 1 || port > 65535 { return cfg, fmt.Errorf("TALK_PORT: ungültiger Wert %q (1–65535)", v) } cfg.Server.Port = port } if v := getenv("TALK_ALLOWED_ORIGINS"); v != "" { if v == "off" { cfg.Server.AllowedOrigins = nil } else { origins, err := splitOrigins(v) if err != nil { return cfg, fmt.Errorf("TALK_ALLOWED_ORIGINS: %w", err) } cfg.Server.AllowedOrigins = origins } } if v := getenv("TALK_AUTOSTART"); v != "" { b, err := strconv.ParseBool(v) if err != nil { return cfg, fmt.Errorf("TALK_AUTOSTART: ungültiger Wert %q (true/false)", v) } cfg.Daemon.Autostart = b } if v := getenv("TALK_WRAP_LABEL"); v != "" { cfg.Wrap.Label = v } 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: default: return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)", cfg.Audio.Sink, SinkOto, SinkNull) } // allowed_origins aus der Datei prüfen (Env wurde oben geparst): Eine // leere Liste ist gültig (Tür zu), einzelne Einträge müssen aber // substanziell sein — klarer Fehler statt stiller Ignoranz. for _, o := range cfg.Server.AllowedOrigins { if strings.TrimSpace(o) == "" { return cfg, fmt.Errorf("server.allowed_origins: leerer Eintrag in %v", cfg.Server.AllowedOrigins) } } // 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 } // splitOrigins zerlegt eine kommagetrennte Origin-Liste (Env-Form von // server.allowed_origins, z. B. "https://a.example, https://b.example"). // Whitespace um Einträge toleriert, leere Einträge sind ein klarer Fehler. func splitOrigins(v string) ([]string, error) { parts := strings.Split(v, ",") origins := make([]string, 0, len(parts)) for _, p := range parts { p = strings.TrimSpace(p) if p == "" { return nil, fmt.Errorf("leerer Eintrag in %q (komma-getrennte Origins oder \"off\")", v) } origins = append(origins, p) } return origins, nil }