package api import ( "encoding/json" "fmt" "net/http" "strings" "time" "code.m3ta.dev/m3tam3re/talk/internal/queue" ) // maxBodyBytes limits request bodies (announcements are short; protection // against accidental huge payloads). const maxBodyBytes = 64 << 10 // HandlerConfig bundles the dependencies of the HTTP surface. The handler // knows neither config files nor env; the daemon injects everything, and // process-boundary tests drive exactly this surface as a black box. type HandlerConfig struct { // Queue is the daemon queue (Enqueue/Cancel/Current/Pending). Queue *queue.Queue // Mute is the persistent mute state (file mute in the config dir). Mute queue.MuteStore // Version is the daemon version for GET /status and serverInfo. Version string // KnownProviders are the valid provider names (validation of the // provider field in POST /speak; the daemon passes provider.Names()). KnownProviders []string // Provider resolves the active provider for GET /status (daemon side: // pin of the running item or first available chain tier). Optional. Provider func() string // AllowedOrigins is the CORS allow list for POST /speak (empty = no CORS // headers, the browser blocks; see cors.go). AllowedOrigins []string // Shutdown shuts the daemon down (POST /shutdown, `talk daemon stop`). // Must not block and must be callable more than once. Optional: nil // means the endpoint answers 501. Shutdown func() } // NewHandler returns the daemon's HTTP routes. Method binding via ServeMux // patterns: wrong method 405, unknown path 404. The originGuard (cors.go) // sits in front of the router: browser requests (Origin header) only reach // POST /speak from listed origins; everything else is 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 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 mux.HandleFunc("GET /mcp", h.mcpGet) // 405: no server-initiated streams return h.originGuard(mux) } // handler is the internal state of the HTTP surface. type handler struct { queue *queue.Queue mute queue.MuteStore version string known map[string]bool provider func() string allowedOrigins []string shutdown func() } // speak handles POST /speak: validate, discard when muted (202, documented // behaviour; deliberately not an error so callers need no special // handling), otherwise enqueue and answer 202 {id}. The laws (rate-limit // budget, quiet hours) may also discard on admission; the response then // names the reason ({"discarded":"..."}). CORS headers only here and only // for a listed origin (see 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, "invalid 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 is the result of the shared admission path of POST /speak // and the MCP tool speak: an HTTP status equivalent plus body. 202 with // {id}/{muted}/{discarded} as the admission result, 400/503 with errMsg as // an error (MCP translates both, see mcp.go). type speakAdmission struct { status int resp SpeakResponse errMsg string } // admitSpeak is the one admission path behind POST /speak and the MCP tool // speak: validation, mute check, enqueue including the laws (rate-limit // budget, quiet hours). Identical semantics for both doors, no contract // drift. func (h *handler) admitSpeak(req SpeakRequest) speakAdmission { if strings.TrimSpace(req.Text) == "" { return speakAdmission{status: http.StatusBadRequest, errMsg: "text is missing or empty"} } if req.Priority == "" { req.Priority = string(queue.PriorityNormal) } if !queue.Priority(req.Priority).Valid() { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("priority: invalid value %q (allowed: low, normal, high)", req.Priority)} } if req.Provider != "" && !h.known[req.Provider] { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("provider: unknown value %q", req.Provider)} } if req.Lang != "" && req.Lang != "de" && req.Lang != "en" { return speakAdmission{status: http.StatusBadRequest, errMsg: fmt.Sprintf("lang: invalid value %q (allowed: 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 != "" { // Law discard (rate-limit budget / quiet hours): documented // behaviour like mute, 202 with a reason instead of an error, so // callers need no special handling. return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}} } return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID, Truncated: adm.Truncated}} } // status handles GET /status: queue length (+ waiting IDs), current item, // mute state, active provider, version, and the law state (rate limit with // next slot, quiet-hours window). 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() } law := h.queue.LawState() st.RateLimit, st.QuietHours = lawState(law) st.Limits = &LimitsState{MaxText: law.MaxText, MaxAge: law.MaxAge.String()} writeJSON(w, http.StatusOK, st) } // corsList copies the allow list as a JSON [] instead of null: the status // should show "cors": [] when the door is closed. func corsList(origins []string) []string { out := make([]string, len(origins)) copy(out, origins) return out } // lawState converts the queue's law view into the JSON form of /status: // timestamps only when they lie in the future (null = slot free / quiet // hours inactive). 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 handles POST /cancel: cancel the running announcement and clear // the queue. func (h *handler) cancel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()}) } // muteEndpoint handles POST /mute and POST /unmute: persistent mute state // (file in the config dir, survives 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 handles POST /shutdown (`talk daemon stop`): response // first, then the shutdown trigger, so the caller gets its 200 before the // server closes the connection. func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) { if h.shutdown == nil { writeError(w, http.StatusNotImplemented, "this daemon has no shutdown trigger") return } writeJSON(w, http.StatusOK, ShutdownResponse{OK: true}) h.shutdown() } // writeJSON writes a JSON response with a 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 writes an error as {"error": "..."} with a status code. func writeError(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg}) }