2026-10-18 17:10:26 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-10-18 17:40:46 +02:00
|
|
|
"time"
|
2026-10-18 17:10:26 +02:00
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// maxBodyBytes limits request bodies (announcements are short; protection
|
|
|
|
|
// against accidental huge payloads).
|
2026-10-18 17:10:26 +02:00
|
|
|
const maxBodyBytes = 64 << 10
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 17:10:26 +02:00
|
|
|
type HandlerConfig struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// Queue is the daemon queue (Enqueue/Cancel/Current/Pending).
|
2026-10-18 17:10:26 +02:00
|
|
|
Queue *queue.Queue
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Mute is the persistent mute state (file mute in the config dir).
|
2026-10-18 17:10:26 +02:00
|
|
|
Mute queue.MuteStore
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Version is the daemon version for GET /status and serverInfo.
|
2026-10-18 17:10:26 +02:00
|
|
|
Version string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// KnownProviders are the valid provider names (validation of the
|
|
|
|
|
// provider field in POST /speak; the daemon passes provider.Names()).
|
2026-10-18 17:10:26 +02:00
|
|
|
KnownProviders []string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Provider resolves the active provider for GET /status (daemon side:
|
|
|
|
|
// pin of the running item or first available chain tier). Optional.
|
2026-10-18 17:10:26 +02:00
|
|
|
Provider func() string
|
2026-10-18 20:06:05 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// AllowedOrigins is the CORS allow list for POST /speak (empty = no CORS
|
|
|
|
|
// headers, the browser blocks; see cors.go).
|
2026-10-18 20:06:05 +02:00
|
|
|
AllowedOrigins []string
|
2026-10-24 09:49:16 +02:00
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
Shutdown func()
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 17:10:26 +02:00
|
|
|
func NewHandler(cfg HandlerConfig) http.Handler {
|
|
|
|
|
h := &handler{
|
2026-10-18 20:06:05 +02:00
|
|
|
queue: cfg.Queue,
|
|
|
|
|
mute: cfg.Mute,
|
|
|
|
|
version: cfg.Version,
|
|
|
|
|
known: make(map[string]bool, len(cfg.KnownProviders)),
|
|
|
|
|
provider: cfg.Provider,
|
|
|
|
|
allowedOrigins: cfg.AllowedOrigins,
|
2026-10-24 09:49:16 +02:00
|
|
|
shutdown: cfg.Shutdown,
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
for _, name := range cfg.KnownProviders {
|
|
|
|
|
h.known[name] = true
|
|
|
|
|
}
|
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
mux.HandleFunc("POST /speak", h.speak)
|
2026-10-24 11:15:35 +02:00
|
|
|
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight
|
2026-10-18 17:10:26 +02:00
|
|
|
mux.HandleFunc("GET /status", h.status)
|
|
|
|
|
mux.HandleFunc("POST /cancel", h.cancel)
|
2026-10-24 12:14:38 +02:00
|
|
|
mux.HandleFunc("POST /cancel-item", h.cancelItem)
|
2026-10-18 17:10:26 +02:00
|
|
|
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
|
|
|
|
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
2026-10-24 09:49:16 +02:00
|
|
|
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
|
2026-10-24 11:15:35 +02:00
|
|
|
mux.HandleFunc("POST /mcp", h.serveMCP) // MCP Streamable HTTP
|
|
|
|
|
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: no server-initiated streams
|
2026-10-24 09:35:45 +02:00
|
|
|
return h.originGuard(mux)
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// handler is the internal state of the HTTP surface.
|
2026-10-18 17:10:26 +02:00
|
|
|
type handler struct {
|
2026-10-18 20:06:05 +02:00
|
|
|
queue *queue.Queue
|
|
|
|
|
mute queue.MuteStore
|
|
|
|
|
version string
|
|
|
|
|
known map[string]bool
|
|
|
|
|
provider func() string
|
|
|
|
|
allowedOrigins []string
|
2026-10-24 09:49:16 +02:00
|
|
|
shutdown func()
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 17:10:26 +02:00
|
|
|
func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
|
2026-10-18 20:06:05 +02:00
|
|
|
h.setSpeakCors(w, r)
|
|
|
|
|
|
2026-10-18 17:10:26 +02:00
|
|
|
var req SpeakRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
2026-10-18 17:10:26 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 20:06:05 +02:00
|
|
|
adm := h.admitSpeak(req)
|
|
|
|
|
if adm.errMsg != "" {
|
|
|
|
|
writeError(w, adm.status, adm.errMsg)
|
2026-10-18 17:10:26 +02:00
|
|
|
return
|
|
|
|
|
}
|
2026-10-18 20:06:05 +02:00
|
|
|
writeJSON(w, adm.status, adm.resp)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 20:06:05 +02:00
|
|
|
type speakAdmission struct {
|
|
|
|
|
status int
|
|
|
|
|
resp SpeakResponse
|
|
|
|
|
errMsg string
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
|
|
|
|
|
if strings.TrimSpace(req.Text) == "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
return speakAdmission{status: http.StatusBadRequest, errMsg: "text is missing or empty"}
|
2026-10-18 20:06:05 +02:00
|
|
|
}
|
2026-10-18 17:10:26 +02:00
|
|
|
if req.Priority == "" {
|
|
|
|
|
req.Priority = string(queue.PriorityNormal)
|
|
|
|
|
}
|
|
|
|
|
if !queue.Priority(req.Priority).Valid() {
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusBadRequest,
|
2026-10-24 11:15:35 +02:00
|
|
|
errMsg: fmt.Sprintf("priority: invalid value %q (allowed: low, normal, high)", req.Priority)}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if req.Provider != "" && !h.known[req.Provider] {
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusBadRequest,
|
2026-10-24 11:15:35 +02:00
|
|
|
errMsg: fmt.Sprintf("provider: unknown value %q", req.Provider)}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
if req.Lang != "" && req.Lang != "de" && req.Lang != "en" {
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusBadRequest,
|
2026-10-24 11:15:35 +02:00
|
|
|
errMsg: fmt.Sprintf("lang: invalid value %q (allowed: de, en)", req.Lang)}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if h.mute.Muted() {
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{Muted: true}}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-18 17:40:46 +02:00
|
|
|
adm, err := h.queue.Enqueue(queue.Item{
|
2026-10-18 17:10:26 +02:00
|
|
|
Text: req.Text,
|
|
|
|
|
Lang: req.Lang,
|
|
|
|
|
Provider: req.Provider,
|
|
|
|
|
Voice: req.Voice,
|
|
|
|
|
Priority: queue.Priority(req.Priority),
|
|
|
|
|
Now: req.Now,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
2026-10-18 17:40:46 +02:00
|
|
|
if adm.Discarded != "" {
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-18 20:06:05 +02:00
|
|
|
return speakAdmission{status: http.StatusAccepted,
|
|
|
|
|
resp: SpeakResponse{Discarded: adm.Discarded, Toast: adm.Toast}}
|
2026-10-18 17:40:46 +02:00
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
return speakAdmission{status: http.StatusAccepted, resp: SpeakResponse{ID: adm.ID, Truncated: adm.Truncated}}
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 17:10:26 +02:00
|
|
|
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(),
|
2026-10-18 20:06:05 +02:00
|
|
|
MCP: true,
|
|
|
|
|
Cors: corsList(h.allowedOrigins),
|
2026-10-18 17:10:26 +02:00
|
|
|
}
|
|
|
|
|
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()
|
|
|
|
|
}
|
2026-10-24 09:54:40 +02:00
|
|
|
law := h.queue.LawState()
|
|
|
|
|
st.RateLimit, st.QuietHours = lawState(law)
|
|
|
|
|
st.Limits = &LimitsState{MaxText: law.MaxText, MaxAge: law.MaxAge.String()}
|
2026-10-18 17:10:26 +02:00
|
|
|
writeJSON(w, http.StatusOK, st)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// corsList copies the allow list as a JSON [] instead of null: the status
|
|
|
|
|
// should show "cors": [] when the door is closed.
|
2026-10-18 20:06:05 +02:00
|
|
|
func corsList(origins []string) []string {
|
|
|
|
|
out := make([]string, len(origins))
|
|
|
|
|
copy(out, origins)
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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).
|
2026-10-18 17:40:46 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// cancel handles POST /cancel: cancel the running announcement and clear
|
|
|
|
|
// the queue.
|
2026-10-18 17:10:26 +02:00
|
|
|
func (h *handler) cancel(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:14:38 +02:00
|
|
|
// cancelItem handles POST /cancel-item: cancels the announcement with the
|
|
|
|
|
// given id if it is the one playing (the Cancel button of its notification;
|
|
|
|
|
// on Windows it arrives through the talk: protocol handler). Waiting items
|
|
|
|
|
// stay. A stale id is not an error: {"cancelled": false}.
|
|
|
|
|
func (h *handler) cancelItem(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req CancelItemRequest
|
|
|
|
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if strings.TrimSpace(req.ID) == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "id is missing or empty")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, CancelItemResponse{Cancelled: h.queue.CancelItem(req.ID)})
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// muteEndpoint handles POST /mute and POST /unmute: persistent mute state
|
|
|
|
|
// (file in the config dir, survives daemon restarts).
|
2026-10-18 17:10:26 +02:00
|
|
|
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})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// 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.
|
2026-10-24 09:49:16 +02:00
|
|
|
func (h *handler) shutdownEndpoint(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if h.shutdown == nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
writeError(w, http.StatusNotImplemented, "this daemon has no shutdown trigger")
|
2026-10-24 09:49:16 +02:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
|
|
|
|
|
h.shutdown()
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// writeJSON writes a JSON response with a status code.
|
2026-10-18 17:10:26 +02:00
|
|
|
func writeJSON(w http.ResponseWriter, code int, body any) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.WriteHeader(code)
|
|
|
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// writeError writes an error as {"error": "..."} with a status code.
|
2026-10-18 17:10:26 +02:00
|
|
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
|
|
|
|
writeJSON(w, code, map[string]string{"error": msg})
|
|
|
|
|
}
|