2026-10-18 17:10:26 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-10-18 17:40:46 +02:00
|
|
|
"time"
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
|
|
|
|
|
// versehentliche Riesen-Payloads).
|
|
|
|
|
const maxBodyBytes = 64 << 10
|
|
|
|
|
|
|
|
|
|
// HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
|
|
|
|
|
// kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
|
|
|
|
|
// injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
|
|
|
|
|
type HandlerConfig struct {
|
|
|
|
|
// Queue ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
|
|
|
|
|
Queue *queue.Queue
|
|
|
|
|
|
|
|
|
|
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
|
|
|
|
|
Mute queue.MuteStore
|
|
|
|
|
|
|
|
|
|
// Version ist die Daemon-Version für GET /status.
|
|
|
|
|
Version string
|
|
|
|
|
|
|
|
|
|
// KnownProviders sind die gültigen Provider-Namen (Validierung des
|
|
|
|
|
// provider-Felds in POST /speak; talkd liefert provider.Names()).
|
|
|
|
|
KnownProviders []string
|
|
|
|
|
|
|
|
|
|
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
|
|
|
|
|
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
|
|
|
|
|
Provider func() string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per
|
|
|
|
|
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404.
|
|
|
|
|
func NewHandler(cfg HandlerConfig) http.Handler {
|
|
|
|
|
h := &handler{
|
|
|
|
|
queue: cfg.Queue,
|
|
|
|
|
mute: cfg.Mute,
|
|
|
|
|
version: cfg.Version,
|
|
|
|
|
known: make(map[string]bool, len(cfg.KnownProviders)),
|
|
|
|
|
provider: cfg.Provider,
|
|
|
|
|
}
|
|
|
|
|
for _, name := range cfg.KnownProviders {
|
|
|
|
|
h.known[name] = true
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /speak", h.speak)
|
|
|
|
|
mux.HandleFunc("GET /status", h.status)
|
|
|
|
|
mux.HandleFunc("POST /cancel", h.cancel)
|
|
|
|
|
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
|
|
|
|
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
|
|
|
|
return mux
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// handler ist der interne Zustand der HTTP-Oberfläche.
|
|
|
|
|
type handler struct {
|
|
|
|
|
queue *queue.Queue
|
|
|
|
|
mute queue.MuteStore
|
|
|
|
|
version string
|
|
|
|
|
known map[string]bool
|
|
|
|
|
provider func() string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
|
|
|
|
|
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
|
2026-10-18 17:40:46 +02:00
|
|
|
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz
|
|
|
|
|
// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen —
|
|
|
|
|
// die Antwort nennt dann den Grund ({"discarded":"…"}).
|
2026-10-18 17:10:26 +02:00
|
|
|
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 {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "ungültiger JSON-Body: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(req.Text) == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "text fehlt oder ist leer")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Priority == "" {
|
|
|
|
|
req.Priority = string(queue.PriorityNormal)
|
|
|
|
|
}
|
|
|
|
|
if !queue.Priority(req.Priority).Valid() {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Provider != "" && !h.known[req.Provider] {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("provider: unbekannter Wert %q", req.Provider))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.mute.Muted() {
|
|
|
|
|
writeJSON(w, http.StatusAccepted, SpeakResponse{Muted: true})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:46 +02:00
|
|
|
adm, err := h.queue.Enqueue(queue.Item{
|
2026-10-18 17:10:26 +02:00
|
|
|
Text: req.Text,
|
|
|
|
|
Lang: req.Lang,
|
|
|
|
|
Provider: req.Provider,
|
|
|
|
|
Voice: req.Voice,
|
|
|
|
|
Priority: queue.Priority(req.Priority),
|
|
|
|
|
Now: req.Now,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusServiceUnavailable, "enqueue: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 17:40:46 +02:00
|
|
|
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})
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
|
2026-10-18 17:40:46 +02:00
|
|
|
// Item, Mute-State, aktiver Provider, Version — und seit Task 05 den
|
|
|
|
|
// Gesetz-State (Rate-Limit mit nächstem Slot, Ruhezeit-Fenster).
|
2026-10-18 17:10:26 +02:00
|
|
|
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
pending := h.queue.Pending()
|
|
|
|
|
ids := make([]string, len(pending))
|
|
|
|
|
for i, it := range pending {
|
|
|
|
|
ids[i] = it.ID
|
|
|
|
|
}
|
|
|
|
|
st := StatusResponse{
|
|
|
|
|
Version: h.version,
|
|
|
|
|
Queue: len(pending),
|
|
|
|
|
QueueIDs: ids,
|
|
|
|
|
Muted: h.mute.Muted(),
|
|
|
|
|
}
|
|
|
|
|
if cur, ok := h.queue.Current(); ok {
|
|
|
|
|
st.Current = &CurrentItem{
|
|
|
|
|
ID: cur.ID,
|
|
|
|
|
Text: cur.Text,
|
|
|
|
|
Priority: string(cur.Priority),
|
|
|
|
|
Now: cur.Now,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if h.provider != nil {
|
|
|
|
|
st.Provider = h.provider()
|
|
|
|
|
}
|
2026-10-18 17:40:46 +02:00
|
|
|
st.RateLimit, st.QuietHours = lawState(h.queue.LawState())
|
2026-10-18 17:10:26 +02:00
|
|
|
writeJSON(w, http.StatusOK, st)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:46 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:10:26 +02:00
|
|
|
// 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()})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// muteEndpoint behandelt POST /mute bzw. POST /unmute: persistenter
|
|
|
|
|
// Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
|
|
|
|
|
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if err := h.mute.Set(muted); err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, MuteResponse{Muted: muted})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
|
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(code)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// writeError schreibt einen Fehler als {"error": "…"} mit Status-Code.
|
|
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
|
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
|
|
|
}
|