diff --git a/cmd/talk/control.go b/cmd/talk/control.go index e1113ec..d11190a 100644 --- a/cmd/talk/control.go +++ b/cmd/talk/control.go @@ -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. diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go index 65af5f8..78406dc 100644 --- a/cmd/talk/daemon.go +++ b/cmd/talk/daemon.go @@ -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 diff --git a/cmd/talkd/main.go b/cmd/talkd/main.go index 1115875..9d175c1 100644 --- a/cmd/talkd/main.go +++ b/cmd/talkd/main.go @@ -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) diff --git a/internal/api/doc.go b/internal/api/doc.go index 6876a79..b06bb2e 100644 --- a/internal/api/doc.go +++ b/internal/api/doc.go @@ -1,6 +1,8 @@ // Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1: -// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}), GET /status, -// POST /cancel, POST /mute, POST /unmute (Task 04). CORS für /speak und -// der MCP-Endpoint (Streamable HTTP) folgen in Task 06. Der Client-Teil -// ist die CLI-Seite derselben Vertragsschnittstelle. +// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}; bei Gesetz- +// Verwurf 202 {discarded:"rate_limit"|"quiet_hours"} — Task 05), +// GET /status (inkl. Rate-Limit- und Ruhezeit-State), POST /cancel, +// POST /mute, POST /unmute. CORS für /speak und der MCP-Endpoint +// (Streamable HTTP) folgen in Task 06. Der Client-Teil ist die CLI-Seite +// derselben Vertragsschnittstelle. package api diff --git a/internal/api/server.go b/internal/api/server.go index 2d13bac..88ca369 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) @@ -68,7 +69,9 @@ type handler struct { // speak behandelt POST /speak: validieren, bei Mute verwerfen (202, // dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine -// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. +// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz +// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen — +// die Antwort nennt dann den Grund ({"discarded":"…"}). func (h *handler) speak(w http.ResponseWriter, r *http.Request) { var req SpeakRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil { @@ -100,7 +103,7 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) { return } - id, err := h.queue.Enqueue(queue.Item{ + adm, err := h.queue.Enqueue(queue.Item{ Text: req.Text, Lang: req.Lang, Provider: req.Provider, @@ -112,11 +115,19 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error()) return } - writeJSON(w, http.StatusAccepted, SpeakResponse{ID: id}) + if adm.Discarded != "" { + // Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes + // Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit + // Aufrufer keine Sonderbehandlung brauchen. + writeJSON(w, http.StatusAccepted, SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}) + return + } + writeJSON(w, http.StatusAccepted, SpeakResponse{ID: adm.ID}) } // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles -// Item, Mute-State, aktiver Provider, Version. +// Item, Mute-State, aktiver Provider, Version — und seit Task 05 den +// Gesetz-State (Rate-Limit mit nächstem Slot, Ruhezeit-Fenster). func (h *handler) status(w http.ResponseWriter, r *http.Request) { pending := h.queue.Pending() ids := make([]string, len(pending)) @@ -140,9 +151,35 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) { if h.provider != nil { st.Provider = h.provider() } + st.RateLimit, st.QuietHours = lawState(h.queue.LawState()) writeJSON(w, http.StatusOK, st) } +// lawState überführt die Gesetz-Sicht der Queue in die JSON-Form von +// /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot +// frei / Ruhezeit inaktiv). +func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) { + now := time.Now() + rl := &RateLimitState{ + Interval: l.RateLimit.String(), + AllowHighBurst: l.AllowHighBurst, + } + if l.NextSlot.After(now) { + slot := l.NextSlot.Format(time.RFC3339) + rl.NextSlot = &slot + } + qh := &QuietHoursState{ + Window: l.Quiet.String(), + Active: l.QuietActive, + Mode: l.QuietMode, + } + if !l.QuietUntil.IsZero() { + until := l.QuietUntil.Format(time.RFC3339) + qh.Until = &until + } + return rl, qh +} + // cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren. func (h *handler) cancel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()}) diff --git a/internal/api/types.go b/internal/api/types.go index 0a3ed40..308632d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -27,10 +27,20 @@ type SpeakRequest struct { // SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei // aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes -// Verhalten, kein Fehler). +// Verhalten, kein Fehler). Seit Task 05 meldet verworfenen Spam ebenso +// dokumentiert: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"} — +// plus "toast":true, wenn die Ruhezeit-Ansage durch eine Benachrichtigung +// ersetzt wurde (quiet_mode = "toast"). type SpeakResponse struct { ID string `json:"id"` Muted bool `json:"muted,omitempty"` + + // Discarded ist der Verwurfs-Grund ("rate_limit" | "quiet_hours"); + // leer, wenn die Ansage aufgenommen wurde. + Discarded string `json:"discarded,omitempty"` + + // Toast meldet die Ersetzung durch eine Benachrichtigung. + Toast bool `json:"toast,omitempty"` } // CurrentItem beschreibt das laufende Item in GET /status. @@ -44,14 +54,47 @@ type CurrentItem struct { // StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles // Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version. // QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge -// — die CLI braucht sie für ihr --wait-Polling. +// — die CLI braucht sie für ihr --wait-Polling. Task 05 ergänzt additiv +// den Gesetz-State (rate_limit, quiet_hours). type StatusResponse struct { - Version string `json:"version"` - Queue int `json:"queue"` - QueueIDs []string `json:"queue_ids,omitempty"` - Current *CurrentItem `json:"current"` - Muted bool `json:"muted"` - Provider string `json:"provider"` + Version string `json:"version"` + Queue int `json:"queue"` + QueueIDs []string `json:"queue_ids,omitempty"` + Current *CurrentItem `json:"current"` + Muted bool `json:"muted"` + Provider string `json:"provider"` + RateLimit *RateLimitState `json:"rate_limit,omitempty"` + QuietHours *QuietHoursState `json:"quiet_hours,omitempty"` +} + +// RateLimitState ist der Rate-Limit-State in GET /status (Task 05). +type RateLimitState struct { + // Interval ist der konfigurierte Mindestabstand als Dauer-String + // ("30s"; "0s" = unbegrenzt). + Interval string `json:"interval"` + + // AllowHighBurst meldet, ob priority=high verstoßen darf. + AllowHighBurst bool `json:"allow_high_burst"` + + // NextSlot ist der nächste freie Ansage-Slot als RFC3339-Zeitstempel; + // null, wenn sofort gespielt werden dürfte (oder unbegrenzt). + NextSlot *string `json:"next_slot"` +} + +// QuietHoursState ist der Ruhezeit-State in GET /status (Task 05). +type QuietHoursState struct { + // Window ist das konfigurierte Fenster ("18:00-08:00"; "" = deaktiviert). + Window string `json:"window"` + + // Active meldet, ob die lokale Zeit gerade im Fenster liegt. + Active bool `json:"active"` + + // Mode ist der konfigurierte Ruhezeit-Modus ("discard" | "toast"). + Mode string `json:"mode"` + + // Until ist das Fenster-Ende als RFC3339-Zeitstempel, wenn aktiv; + // sonst null. + Until *string `json:"until"` } // PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder