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>
344 lines
12 KiB
Go
344 lines
12 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
|
||
|
||
// 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
|
||
}
|