Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der Degraded Mode kannte weder Rate-Limit noch Ruhezeiten. Jetzt: - internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage); Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite Zusammensetzung der Kette mehr. - internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei daemon.pid, Shutdown-Trigger. - `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg und die PID-Datei gelöscht ist (Prozess wirklich beendet). - Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt den Daemon des anderen — kein Timeout-Warten. - Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis + Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst nicht; `talk status` zeigt „läuft nicht" (Exit 1). - cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt neu. Tests: Harness baut nur noch talk und startet `talk daemon run`; TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile aller sechs Targets mit CGO_ENABLED=0 verifiziert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
275 lines
9.4 KiB
Go
275 lines
9.4 KiB
Go
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})
|
|
}
|