Files
talk/internal/config/config.go
T
m3ta-chironandClaude Fable 5.1 97650ad0ec feat(queue): Gesetze max_text und max_age — gekürzte Langtexte, verfallene Stau-Ansagen
Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.

- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
  werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
  /speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
  Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
  Fälligwerden länger gewartet haben, werden verworfen (discarded
  "expired", Protokoll nennt Wartezeit und Grenze). now:true ist
  ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.

Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 09:54:40 +02:00

344 lines
12 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
// DefaultMaxText ist die Default-Obergrenze der Ansage-Länge in Zeichen —
// etwa zwei bis drei Sätze, rund 25 Sekunden Sprache. 0 = unbegrenzt.
const DefaultMaxText = 400
// DefaultMaxAge ist die Default-Höchstwartezeit einer Ansage in der Queue;
// was länger parkt, ist veraltet und wird verworfen. „0s" = unbegrenzt.
const DefaultMaxAge = 2 * time.Minute
// 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.<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"`
}
// 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"`
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen; längere
// Texte werden an einer Wortgrenze gekürzt (Default 400; 0 =
// unbegrenzt; Env TALK_MAX_TEXT).
MaxText int `toml:"max_text"`
// MaxAge ist die längste Wartezeit einer Ansage in der Queue; ältere
// werden beim Fälligwerden verworfen (Default "2m"; "0s" = unbegrenzt;
// Env TALK_MAX_AGE).
MaxAge time.Duration `toml:"max_age"`
}
// 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, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
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_MAX_TEXT"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 0 {
return cfg, fmt.Errorf("TALK_MAX_TEXT: ungültiger Wert %q (Zeichen; 0 = unbegrenzt)", v)
}
cfg.Queue.MaxText = n
}
if v := getenv("TALK_MAX_AGE"); v != "" {
d, err := time.ParseDuration(v)
if err != nil || d < 0 {
return cfg, fmt.Errorf("TALK_MAX_AGE: ungültiger Wert %q (Dauer, z. B. \"2m\"; \"0s\" = unbegrenzt)", v)
}
cfg.Queue.MaxAge = d
}
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
}
if cfg.Queue.MaxText < 0 || cfg.Queue.MaxAge < 0 || cfg.Queue.RateLimit < 0 {
return cfg, fmt.Errorf("queue: max_text, max_age und rate_limit dürfen nicht negativ sein (0 = unbegrenzt)")
}
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
}