Windows toasts cannot call back into the daemon, so the Cancel button opens a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon registers the talk: URL protocol for the current user (HKCU, no admin) and points it at `talk uri "%1"`; Windows starts that on the click, and `talk uri` posts to the new endpoint POST /cancel-item, which cancels only the announcement with that id (queue.CancelItem). The toast is tagged and removed again when the announcement ends. For now the toast is shown under Windows PowerShell's app identity, which unpackaged programs can use without a Start Menu shortcut. Verified on the Windows 11 VM: registry entry written by the daemon, Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops the playing announcement within two seconds, no toast errors in the log. Tests: `talk uri` against a real handler on a loopback port (playing vs waiting id, dead daemon, talk:test, garbage links), POST /cancel-item process test (only the playing item, waiting one plays next, 400 without id). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
296 lines
10 KiB
Go
296 lines
10 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 /cancel-item", h.cancelItem)
|
|
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()})
|
|
}
|
|
|
|
// 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)})
|
|
}
|
|
|
|
// 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})
|
|
}
|