feat(config): Gesetz-Felder rate_limit, allow_high_burst, quiet_hours, quiet_mode
Queue-Section ([queue] rate_limit = "30s", allow_high_burst = true) und
Ruhezeiten (quiet_hours = "18:00-08:00" lokal über Mitternacht,
quiet_mode = discard|toast) mit Env-Overrides TALK_RATE_LIMIT,
TALK_ALLOW_HIGH_BURST, TALK_QUIET_HOURS ("off" = aus), TALK_QUIET_MODE.
Default: 1 Ansage / 30 s, Burst an, Ruhezeit aus (Opt-in wie Mute —
ein Uhrzeit-Gesetz als Default würde jeden Cron in den Abendstunden
stilllegen). Validierung gegen queue.ParseQuietHours als eine Quelle;
partielle [queue]-Tabellen behalten Defaults (tischgetestet).
This commit is contained in:
1 parent
dc94f45d11
commit
b20e6dd319
2 files changed
+261
-6
No files matched your search
@@ -6,8 +6,11 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Erlaubte Werte für audio.sink (Teil des öffentlichen Vertrags; Spec:
|
||||
@@ -21,17 +24,25 @@ const (
|
||||
// 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);
|
||||
// spätere Tasks ergänzen weitere Felder (queue …), ohne die Auflösungs-
|
||||
// reihenfolge zu ändern.
|
||||
// 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
|
||||
@@ -48,6 +59,17 @@ type Config struct {
|
||||
// `[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.
|
||||
@@ -65,6 +87,17 @@ type Server struct {
|
||||
Port int `toml:"port"`
|
||||
}
|
||||
|
||||
// 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 /
|
||||
@@ -82,6 +115,8 @@ func Defaults() Config {
|
||||
return Config{
|
||||
Audio: Audio{Sink: SinkOto},
|
||||
Server: Server{Port: DefaultServerPort},
|
||||
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true},
|
||||
QuietMode: queue.QuietModeDiscard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +134,9 @@ type Options struct {
|
||||
}
|
||||
|
||||
// Load löst die Konfiguration schichtweise auf: Defaults → config.toml
|
||||
// (falls vorhanden) → Env-Overrides (TALK_AUDIO_SINK 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),
|
||||
// (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) {
|
||||
@@ -143,11 +178,52 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
|
||||
}
|
||||
cfg.Server.Port = port
|
||||
}
|
||||
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:
|
||||
return cfg, nil
|
||||
default:
|
||||
return cfg, fmt.Errorf("audio.sink: ungültiger Wert %q (erlaubt: %q, %q)",
|
||||
cfg.Audio.Sink, SinkOto, SinkNull)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
func env(kv map[string]string) func(string) string {
|
||||
@@ -352,3 +355,179 @@ func TestLoadServerPort(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadGesetze deckt die Task-05-Felder ab: Rate-Limit (Default 30 s,
|
||||
// "0s" = unbegrenzt), High-Burst (Default an), Ruhezeiten (Default aus;
|
||||
// Format validiert) samt Env-Overrides und klaren Fehlern.
|
||||
func TestLoadGesetze(t *testing.T) {
|
||||
writeTOML := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.toml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
fehlendeDatei := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "gibt-es-nicht.toml")
|
||||
}
|
||||
|
||||
t.Run("Defaults: 30 s, Burst an, Ruhezeit aus, Modus discard", func(t *testing.T) {
|
||||
cfg, err := Load(Options{File: fehlendeDatei(t), Getenv: env(nil)})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.RateLimit != DefaultRateLimit {
|
||||
t.Errorf("Queue.RateLimit = %v, want Default %v", cfg.Queue.RateLimit, DefaultRateLimit)
|
||||
}
|
||||
if !cfg.Queue.AllowHighBurst {
|
||||
t.Error("Queue.AllowHighBurst = false, want Default true")
|
||||
}
|
||||
if cfg.QuietHours != "" {
|
||||
t.Errorf("QuietHours = %q, want Default leer (aus)", cfg.QuietHours)
|
||||
}
|
||||
if cfg.QuietMode != queue.QuietModeDiscard {
|
||||
t.Errorf("QuietMode = %q, want Default %q", cfg.QuietMode, queue.QuietModeDiscard)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TOML setzt alle Gesetz-Felder", func(t *testing.T) {
|
||||
cfg, err := Load(Options{
|
||||
File: writeTOML(t, `
|
||||
quiet_hours = "18:00-08:00"
|
||||
quiet_mode = "toast"
|
||||
|
||||
[queue]
|
||||
rate_limit = "10s"
|
||||
allow_high_burst = false
|
||||
`),
|
||||
Getenv: env(nil),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.RateLimit != 10*time.Second {
|
||||
t.Errorf("Queue.RateLimit = %v, want 10s", cfg.Queue.RateLimit)
|
||||
}
|
||||
if cfg.Queue.AllowHighBurst {
|
||||
t.Error("Queue.AllowHighBurst = true, want false aus TOML")
|
||||
}
|
||||
if cfg.QuietHours != "18:00-08:00" {
|
||||
t.Errorf("QuietHours = %q, want Fenster aus TOML", cfg.QuietHours)
|
||||
}
|
||||
if cfg.QuietMode != queue.QuietModeToast {
|
||||
t.Errorf("QuietMode = %q, want toast", cfg.QuietMode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("partielle [queue]-Tabelle behält Defaults", func(t *testing.T) {
|
||||
cfg, err := Load(Options{
|
||||
File: writeTOML(t, "[queue]\nrate_limit = \"0s\"\n"),
|
||||
Getenv: env(nil),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.RateLimit != 0 {
|
||||
t.Errorf("Queue.RateLimit = %v, want 0 (unbegrenzt)", cfg.Queue.RateLimit)
|
||||
}
|
||||
if !cfg.Queue.AllowHighBurst {
|
||||
t.Error("Queue.AllowHighBurst = false, want unveränderten Default true")
|
||||
}
|
||||
if cfg.QuietMode != queue.QuietModeDiscard {
|
||||
t.Errorf("QuietMode = %q, want unveränderten Default discard", cfg.QuietMode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Env überschreibt Datei (inkl. off)", func(t *testing.T) {
|
||||
cfg, err := Load(Options{
|
||||
File: writeTOML(t, `
|
||||
quiet_hours = "18:00-08:00"
|
||||
quiet_mode = "discard"
|
||||
|
||||
[queue]
|
||||
rate_limit = "10s"
|
||||
allow_high_burst = false
|
||||
`),
|
||||
Getenv: env(map[string]string{
|
||||
"TALK_RATE_LIMIT": "1s",
|
||||
"TALK_ALLOW_HIGH_BURST": "true",
|
||||
"TALK_QUIET_HOURS": "off",
|
||||
"TALK_QUIET_MODE": "toast",
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.Queue.RateLimit != time.Second {
|
||||
t.Errorf("Queue.RateLimit = %v, want 1s aus Env", cfg.Queue.RateLimit)
|
||||
}
|
||||
if !cfg.Queue.AllowHighBurst {
|
||||
t.Error("Queue.AllowHighBurst = false, want true aus Env")
|
||||
}
|
||||
if cfg.QuietHours != "" {
|
||||
t.Errorf("QuietHours = %q, want leer (off aus Env)", cfg.QuietHours)
|
||||
}
|
||||
if cfg.QuietMode != queue.QuietModeToast {
|
||||
t.Errorf("QuietMode = %q, want toast aus Env", cfg.QuietMode)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ungültige Gesetz-Werte sind klare Fehler", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
toml string
|
||||
envs map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "TALK_RATE_LIMIT Müll",
|
||||
envs: map[string]string{"TALK_RATE_LIMIT": "schnell"},
|
||||
want: "TALK_RATE_LIMIT",
|
||||
},
|
||||
{
|
||||
name: "TALK_RATE_LIMIT negativ",
|
||||
envs: map[string]string{"TALK_RATE_LIMIT": "-5s"},
|
||||
want: "TALK_RATE_LIMIT",
|
||||
},
|
||||
{
|
||||
name: "rate_limit Müll in Datei",
|
||||
toml: "[queue]\nrate_limit = \"müll\"\n",
|
||||
want: "rate_limit",
|
||||
},
|
||||
{
|
||||
name: "TALK_ALLOW_HIGH_BURST Müll",
|
||||
envs: map[string]string{"TALK_ALLOW_HIGH_BURST": "vielleicht"},
|
||||
want: "TALK_ALLOW_HIGH_BURST",
|
||||
},
|
||||
{
|
||||
name: "quiet_hours Müll in Datei",
|
||||
toml: "quiet_hours = \"abends\"\n",
|
||||
want: "quiet_hours",
|
||||
},
|
||||
{
|
||||
name: "TALK_QUIET_HOURS Müll",
|
||||
envs: map[string]string{"TALK_QUIET_HOURS": "25:00-26:00"},
|
||||
want: "TALK_QUIET_HOURS",
|
||||
},
|
||||
{
|
||||
name: "TALK_QUIET_MODE Müll",
|
||||
envs: map[string]string{"TALK_QUIET_MODE": "vibrieren"},
|
||||
want: "TALK_QUIET_MODE",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
file := fehlendeDatei(t)
|
||||
if tc.toml != "" {
|
||||
file = writeTOML(t, tc.toml)
|
||||
}
|
||||
_, err := Load(Options{File: file, Getenv: env(tc.envs)})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("Fehler = %v, will klaren Fehler mit %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user