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

+41 -4
View File
@@ -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()})