feat(api): /speak meldet Gesetz-Verwürfe, /status zeigt Gesetz-State

POST /speak antwortet bei Verwurf dokumentiert mit 202 {id:"",
discarded:"rate_limit"|"quiet_hours"} (+ toast:true im Toast-Modus)
— analog zum Mute-Muster, kein Fehler. GET /status ergänzt additiv
rate_limit {interval, allow_high_burst, next_slot} und quiet_hours
{window, active, mode, until} (RFC3339 bzw. null). talkd verwired die
Gesetze aus der Config und loggt sie beim Start. Die CLI meldet
'discarded: <grund>' plain (wie muted) und zeigt in talk status die
Zeilen rate-limit/ruhezeit mit Slot bzw. Fenster-Ende.
This commit is contained in:
m3ta-chiron committed 2026-10-18 17:40:46 +02:00
1 parent 5385a4b87d
commit b56c8abfae
6 files changed
+194 -27

No files matched your search

+43 -2
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"path/filepath"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
@@ -71,8 +72,9 @@ func printMuteState(muted bool, stdout io.Writer) {
// runStatus implementiert `talk status`: plain Block aus GET /status —
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
// aktiver Provider. Ohne Daemon: klarer Fehler (status ist eine Anfrage an
// den Daemon; der Degraded Mode hat keinen Status).
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon:
// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode
// hat keinen Status).
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
@@ -101,9 +103,48 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
}
fmt.Fprintf(stdout, "mute: %s\n", muteState)
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
printLawState(stdout, st)
return exitOK
}
// printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
// Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
func printLawState(stdout io.Writer, st *api.StatusResponse) {
if st.RateLimit != nil {
interval := st.RateLimit.Interval
if interval == "0s" {
interval = "aus (unbegrenzt)"
}
burst := "verboten"
if st.RateLimit.AllowHighBurst {
burst = "erlaubt"
}
line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
if st.RateLimit.NextSlot != nil {
if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
}
}
fmt.Fprintln(stdout, line)
}
if st.QuietHours != nil {
window := st.QuietHours.Window
if window == "" {
window = "aus"
}
state := "inaktiv"
if st.QuietHours.Active {
state = "aktiv"
if st.QuietHours.Until != nil {
if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
state += " bis " + ts.Format("15:04")
}
}
}
fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
}
}
// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
// Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
// Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
+29
View File
@@ -9,6 +9,7 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Zeit-Budgets der Daemon-Kommunikation. Die Daemon-Erkennung (GET /status)
@@ -64,6 +65,18 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK, true
}
if resp.Discarded != "" {
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
// Verhalten wie bei Mute — plain Token statt Fehler; --wait hat
// nichts zu warten, es gibt keine Item-ID.
line := "discarded: " + resp.Discarded
if resp.Toast {
line += " (toast)"
}
fmt.Fprintln(stdout, line)
fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast))
return exitOK, true
}
fmt.Fprintln(stdout, resp.ID)
if !opts.wait {
return exitOK, true
@@ -71,6 +84,22 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde
return waitForItem(client, resp.ID, stderr), true
}
// discardHint übersetzt den Verwurfs-Grund der Daemon-Antwort in einen
// menschlichen Hinweis.
func discardHint(reason string, toast bool) string {
switch reason {
case queue.DiscardQuietHours:
if toast {
return "Ruhezeit aktiv — Ansage durch Toast ersetzt (kein Audio)"
}
return "Ruhezeit aktiv — Ansage verworfen (quiet_mode = discard; now bricht bewusst aus)"
case queue.DiscardRateLimit:
return "Rate-Limit — Ansage verworfen (Priorität low über dem Fenster-Budget)"
default:
return "Ansage verworfen (" + reason + ")"
}
}
// waitForItem pollt /status, bis die Ansage id weder läuft noch wartet —
// sie ist dann fertig (oder abgebrochen; in beiden Fällen hat der Aufrufer
// nichts mehr zu warten). Stirbt der Daemon während des Wartens, ist das
+24 -9
View File
@@ -1,11 +1,11 @@
// talkd ist der Daemon für talk 2.0: HTTP-API auf 127.0.0.1 (Default-Port
// 7800, server.port/TALK_PORT), serialisierte Ansage-Queue mit Prioritäten
// und now-Sprung, persistenter Mute-State (Datei mute im Config-Dir).
//
// Task 04: POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute.
// Rate-Limit/Ruhezeiten folgen in Task 05, CORS/MCP in Task 06 — die
// Rate-Limit-Struktur existiert bereits (internal/queue/rate.go), ist hier
// aber bewusst noch nicht angebunden (Task-04-Default: unbegrenzt).
// und now-Sprung, persistenter Mute-State (Datei mute im Config-Dir) —
// und seit Task 05 das Anti-Spam-Gesetz: Rate-Limit (queue.rate_limit,
// Default 1 Ansage / 30 s; low über Budget wird verworfen, normal parkt,
// high darf mit queue.allow_high_burst verstoßen) plus Ruhezeiten
// (quiet_hours/quiet_mode; now:true bricht bewusst aus beiden aus).
// CORS/MCP folgen in Task 06.
package main
import (
@@ -88,7 +88,21 @@ func run() error {
return chain.Speak(ctx, item.Text, lang)
}
q := queue.New(speak, lw)
// Anti-Spam-Gesetz (Task 05): Rate-Limit + High-Burst + Ruhezeiten.
// config.Load hat quiet_hours bereits validiert — das Parsen hier kann
// praktisch nicht scheitern, aber ein Fehler darf nie still werden.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
if err != nil {
return err
}
laws := queue.Laws{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
QuietMode: cfg.QuietMode,
}
q := queue.New(laws, speak, lw)
// Persistenter Mute-State (Datei mute im Config-Dir). Ohne Config-Dir
// läuft der Daemon ohne Mute weiter (dokumentierte Warnung) statt zu
@@ -125,8 +139,9 @@ func run() error {
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v)",
addr, versionString(), sink.Name(), mute.Muted())
log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s)",
addr, versionString(), sink.Name(), mute.Muted(),
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode)
sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)