Files
talk/internal/config/config.go
T
m3ta-chiron cef6e6b9f2 feat(config): CORS-Erlaubnisliste server.allowed_origins (TALK_ALLOWED_ORIGINS)
Exakte Origins wie https://chat.az-gruppe.com; Wildcard "*" nur explizit.
Bewusster Default: leer = keine CORS-Header (Tür zu). Env schlägt Datei
(kommagetrennt; "off" leert), leere Einträge sind klare Fehler.
2026-10-18 20:06:05 +02:00

274 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// 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.<provider>.<lang> = 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"`
// 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"`
}
// 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.<name>]`). 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},
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_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
}