Files
talk/internal/api/server.go
T

274 lines
9.4 KiB
Go
Raw Normal View History

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})
}