Config-Schlüssel [wrap] label für talk wrap (Task 07): ersetzt den Basisnamen des Befehls in der Ansage („Deployment erfolgreich. Dauer: …“ statt „terraform erfolgreich. …“). Auflösung wie üblich Defaults → config.toml → Env (TALK_WRAP_LABEL schlägt die Datei); Tisch-Tests für alle drei Schichten.
289 lines
10 KiB
Go
289 lines
10 KiB
Go
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"`
|
||
|
||
// 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"`
|
||
}
|
||
|
||
// 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.<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_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
|
||
}
|