diff --git a/internal/api/client.go b/internal/api/client.go new file mode 100644 index 0000000..a15282a --- /dev/null +++ b/internal/api/client.go @@ -0,0 +1,123 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 — +// dieselbe Vertragsschnittstelle, die server.go bedient. +type Client struct { + // BaseURL ist die Daemon-Adresse (http://127.0.0.1:). + BaseURL string + + // HTTP ist der verwendete Client; nil wird von NewClient gesetzt. + HTTP *http.Client +} + +// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der +// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak +// großzügiger); das Client-Timeout ist nur die harte Decke. +func NewClient(port int) *Client { + return &Client{ + BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port), + HTTP: &http.Client{Timeout: 10 * time.Second}, + } +} + +// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B. +// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre +// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen, +// Netz-Fehler → Degraded Mode. +type HTTPError struct { + Status int + Body string +} + +func (e *HTTPError) Error() string { + return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body) +} + +// Status ruft GET /status (Daemon-Erkennung, --wait-Polling). +func (c *Client) Status(ctx context.Context) (*StatusResponse, error) { + var st StatusResponse + if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil { + return nil, err + } + return &st, nil +} + +// Speak ruft POST /speak (enqueue). +func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) { + var resp SpeakResponse + if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen +// Ansagen. +func (c *Client) Cancel(ctx context.Context) (int, error) { + var resp CancelResponse + if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil { + return 0, err + } + return resp.Cancelled, nil +} + +// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false). +func (c *Client) SetMute(ctx context.Context, muted bool) error { + path := "/mute" + if !muted { + path = "/unmute" + } + return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil) +} + +// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen, +// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet +// über errors.As(&HTTPError)). +func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error { + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if err != nil { + return err + } + if resp.StatusCode != wantStatus { + return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))} + } + if out == nil { + return nil + } + return json.Unmarshal(data, out) +} diff --git a/internal/api/doc.go b/internal/api/doc.go index 6a787b5..6876a79 100644 --- a/internal/api/doc.go +++ b/internal/api/doc.go @@ -1,4 +1,6 @@ -// Package api implementiert die HTTP-Oberfläche des Daemons auf 127.0.0.1: -// POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute (Task 04), -// CORS für /speak und den MCP-Endpoint (Streamable HTTP, Task 06). +// Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1: +// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}), GET /status, +// POST /cancel, POST /mute, POST /unmute (Task 04). CORS für /speak und +// der MCP-Endpoint (Streamable HTTP) folgen in Task 06. Der Client-Teil +// ist die CLI-Seite derselben Vertragsschnittstelle. package api diff --git a/internal/api/server.go b/internal/api/server.go new file mode 100644 index 0000000..2d13bac --- /dev/null +++ b/internal/api/server.go @@ -0,0 +1,173 @@ +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}) +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..0a3ed40 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,81 @@ +package api + +// Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende +// Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben +// Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client. + +// SpeakRequest ist der Body von POST /speak. +type SpeakRequest struct { + // Text ist der Ansage-Text (Pflicht). + Text string `json:"text"` + + // Priority: low | normal | high (Default normal, wenn leer). + Priority string `json:"priority,omitempty"` + + // Provider pinnt die Provider-Kette (--provider). + Provider string `json:"provider,omitempty"` + + // Voice pinnt eine Voice-ID (--voice). + Voice string `json:"voice,omitempty"` + + // Lang pinnt die Sprache de | en (leer = automatische Erkennung). + Lang string `json:"lang,omitempty"` + + // Now lässt die Ansage vor allen wartenden springen (--now). + Now bool `json:"now,omitempty"` +} + +// SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei +// aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes +// Verhalten, kein Fehler). +type SpeakResponse struct { + ID string `json:"id"` + Muted bool `json:"muted,omitempty"` +} + +// CurrentItem beschreibt das laufende Item in GET /status. +type CurrentItem struct { + ID string `json:"id"` + Text string `json:"text"` + Priority string `json:"priority"` + Now bool `json:"now"` +} + +// StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles +// Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version. +// QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge +// — die CLI braucht sie für ihr --wait-Polling. +type StatusResponse struct { + Version string `json:"version"` + Queue int `json:"queue"` + QueueIDs []string `json:"queue_ids,omitempty"` + Current *CurrentItem `json:"current"` + Muted bool `json:"muted"` + Provider string `json:"provider"` +} + +// PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder +// gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage +// weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in +// beiden Fällen hat der Aufrufer nichts mehr zu warten. +func (s StatusResponse) PendingOrCurrent(id string) bool { + if s.Current != nil && s.Current.ID == id { + return true + } + for _, qid := range s.QueueIDs { + if qid == id { + return true + } + } + return false +} + +// CancelResponse ist die Antwort von POST /cancel. +type CancelResponse struct { + Cancelled int `json:"cancelled"` +} + +// MuteResponse ist die Antwort von POST /mute und POST /unmute. +type MuteResponse struct { + Muted bool `json:"muted"` +}