package api import ( "encoding/json" "fmt" "net/http" "strings" "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 // Sonderbehandlung brauchen), sonst enqueue → 202 {id}. 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 } id, err := h.queue.Enqueue(queue.Item{ 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 } writeJSON(w, http.StatusAccepted, SpeakResponse{ID: id}) } // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles // Item, Mute-State, aktiver Provider, Version. 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() } writeJSON(w, http.StatusOK, st) } // 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}) }