package api import ( "encoding/json" "fmt" "net/http" "strings" "time" "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 und serverInfo. 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 // AllowedOrigins ist die CORS-Erlaubnisliste für POST /speak (leer = // keine CORS-Header, der Browser blockt; siehe cors.go). AllowedOrigins []string // Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon // stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional: // nil → der Endpunkt antwortet 501. Shutdown func() } // NewHandler liefert die HTTP-Routen des Daemons. Methoden-Bindung per // ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. Vor dem // Router steht der originGuard (cors.go): Browser-Anfragen (Origin-Header) // erreichen nur POST /speak von gelisteten Origins; alles andere ist 403. 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, allowedOrigins: cfg.AllowedOrigins, shutdown: cfg.Shutdown, } for _, name := range cfg.KnownProviders { h.known[name] = true } mux := http.NewServeMux() mux.HandleFunc("POST /speak", h.speak) mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06) 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)) mux.HandleFunc("POST /shutdown", h.shutdownEndpoint) mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP (Task 06) mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams return h.originGuard(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 allowedOrigins []string shutdown func() } // speak behandelt POST /speak: validieren, bei Mute verwerfen (202, // dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine // Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz // (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen — // die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur // hier und nur bei gelisteter Origin (siehe cors.go). func (h *handler) speak(w http.ResponseWriter, r *http.Request) { h.setSpeakCors(w, r) 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 } adm := h.admitSpeak(req) if adm.errMsg != "" { writeError(w, adm.status, adm.errMsg) return } writeJSON(w, adm.status, adm.resp) } // speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von // POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body — // 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit // errMsg als Fehler (MCP übersetzt beides, siehe mcp.go). type speakAdmission struct { status int resp SpeakResponse errMsg string } // admitSpeak ist der eine Aufnahme-Pfad hinter POST /speak und dem // MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive // Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide // Türen, kein Contract-Drift. func (h *handler) admitSpeak(req SpeakRequest) speakAdmission { if strings.TrimSpace(req.Text) == "" { return speakAdmission{status: http.StatusBadRequest, errMsg: "text fehlt oder ist leer"} } if req.Priority == "" { req.Priority = string(queue.PriorityNormal) } if !queue.Priority(req.Priority).Valid() { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("priority: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)} } if req.Provider != "" && !h.known[req.Provider] { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("provider: unbekannter Wert %q", req.Provider)} } if req.Lang != "" && req.Lang != "de" && req.Lang != "en" { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("lang: ungültiger Wert %q (erlaubt: de, en)", req.Lang)} } if h.mute.Muted() { return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Muted: true}} } adm, 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 { return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()} } if adm.Discarded != "" { // Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes // Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit // Aufrufer keine Sonderbehandlung brauchen. return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}} } return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID}} } // status behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles // 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)) for i, it := range pending { ids[i] = it.ID } st := StatusResponse{ Version: h.version, Queue: len(pending), QueueIDs: ids, Muted: h.mute.Muted(), MCP: true, Cors: corsList(h.allowedOrigins), } 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() } st.RateLimit, st.QuietHours = lawState(h.queue.LawState()) writeJSON(w, http.StatusOK, st) } // corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status // soll "cors": [] zeigen, wenn die Tür zu ist. func corsList(origins []string) []string { out := make([]string, len(origins)) copy(out, origins) return out } // 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()}) } // 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}) } } // shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort // zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor // der Server die Verbindung schließt. func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) { if h.shutdown == nil { writeError(w, http.StatusNotImplemented, "dieser Daemon kennt keinen Shutdown-Trigger") return } writeJSON(w, http.StatusOK, ShutdownResponse{OK: true}) h.shutdown() } // 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}) }