Files
talk/internal/api/server.go
T
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

278 lines
9.3 KiB
Go

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