Files
talk/internal/api/server.go
T
m3ta-chironandClaude Fable 5.1 00eb76128b feat(notify): Cancel button on Windows toasts via the talk: protocol handler
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>
2026-10-24 12:14:38 +02:00

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