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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+19 -20
View File
@@ -11,19 +11,19 @@ import (
"time"
)
// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
// dieselbe Vertragsschnittstelle, die server.go bedient.
// Client is the CLI's HTTP client for the daemon on 127.0.0.1; the same
// contract that server.go serves.
type Client struct {
// BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
// BaseURL is the daemon address (http://127.0.0.1:<port>).
BaseURL string
// HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
// HTTP is the client in use; NewClient sets it when nil.
HTTP *http.Client
}
// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
// großzügiger); das Client-Timeout ist nur die harte Decke.
// NewClient builds the client for a daemon port. The caller controls time
// budgets per call via the context (daemon detection short, /speak more
// generous); the client timeout is only the hard ceiling.
func NewClient(port int) *Client {
return &Client{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
@@ -31,10 +31,10 @@ func NewClient(port int) *Client {
}
}
// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
// Netz-Fehler → Degraded Mode.
// HTTPError is a daemon response with an unexpected status code (e.g. 400
// validation). Network errors (daemon dead) are regular *url.Error values
// instead; the CLI distinguishes by that: show HTTP errors, fall into
// degraded mode on network errors.
type HTTPError struct {
Status int
Body string
@@ -44,7 +44,7 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
// Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
// Status calls GET /status (daemon detection, --wait polling).
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
var st StatusResponse
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
@@ -53,7 +53,7 @@ func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
return &st, nil
}
// Speak ruft POST /speak (enqueue).
// Speak calls POST /speak (enqueue).
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
var resp SpeakResponse
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
@@ -62,8 +62,7 @@ func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, e
return &resp, nil
}
// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
// Ansagen.
// Cancel calls POST /cancel; returns the number of cancelled announcements.
func (c *Client) Cancel(ctx context.Context) (int, error) {
var resp CancelResponse
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
@@ -72,7 +71,7 @@ func (c *Client) Cancel(ctx context.Context) (int, error) {
return resp.Cancelled, nil
}
// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
// SetMute calls POST /mute (muted=true) or POST /unmute (muted=false).
func (c *Client) SetMute(ctx context.Context, muted bool) error {
path := "/mute"
if !muted {
@@ -81,14 +80,14 @@ func (c *Client) SetMute(ctx context.Context, muted bool) error {
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
}
// Shutdown ruft POST /shutdown (`talk daemon stop`).
// Shutdown calls POST /shutdown (`talk daemon stop`).
func (c *Client) Shutdown(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
}
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
// über errors.As(&HTTPError)).
// do is the shared core: build the request, send it, check the status,
// decode. Network errors are returned unfiltered (the caller distinguishes
// via errors.As(&HTTPError)).
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
var rd io.Reader
if body != nil {
+39 -42
View File
@@ -5,28 +5,26 @@ import (
"strings"
)
// CORS-Tür für POST /speak (Task 06) — und NUR für diesen Endpunkt: Ein
// Browser (z. B. eine spätere LibreChat-Integration) darf genau die Ansage-
// Schnittstelle cross-origin rufen, keine Steuer-Endpunkte. Die Erlaubnis
// kommt ausschließlich aus der Config-Liste server.allowed_origins
// (Env TALK_ALLOWED_ORIGINS): exakte Origins wie
// "https://chat.az-gruppe.com"; der Eintrag "*" erlaubt jede Origin
// ausdrücklich. Bewusster Default: LEER — dann sendet der Daemon gar keine
// CORS-Header und der Browser blockt (Tür zu, bis sie konfiguriert wird).
// CORS door for POST /speak, and ONLY for this endpoint: a browser (e.g. a
// later LibreChat integration) may call exactly the announcement interface
// cross-origin, never the control endpoints. Permission comes exclusively
// from the config list server.allowed_origins (env TALK_ALLOWED_ORIGINS):
// exact origins such as "https://chat.example.com"; the entry "*" explicitly
// allows any origin. Deliberate default: EMPTY. The daemon then sends no
// CORS headers at all and the browser blocks (door closed until configured).
//
// Die Tür ist serverseitig zu, nicht nur browserseitig (originGuard): CORS
// allein verhindert nur das LESEN der Antwort, nicht die Wirkung — ein
// Browser schickt einen POST mit text/plain-Body oder ein Formular ohne
// Preflight ab, und ein Server, der den Body dennoch als JSON liest, hätte
// gesprochen, gemutet oder abgebrochen. Deshalb lehnt talkd jede Anfrage
// mit fremdem Origin-Header ab (403), die nicht der erlaubte Weg über
// /speak ist. Nur der Preflight OPTIONS /speak bleibt für ungelistete
// Origins ein 204 ohne CORS-Header (er hat keine Wirkung; der Browser
// verwirft den geplanten POST). CLI, curl und andere Nicht-Browser-Clients
// senden keinen Origin-Header und sind nicht betroffen.
// The door is closed server-side, not only browser-side (originGuard): CORS
// alone only prevents READING the response, not the effect. A browser sends
// a POST with a text/plain body or a form without preflight, and a server
// that still reads the body as JSON would have spoken, muted or cancelled.
// Therefore talkd rejects every request with a foreign Origin header (403)
// that is not the permitted path via /speak. Only the preflight OPTIONS
// /speak stays a 204 without CORS headers for unlisted origins (it has no
// effect; the browser drops the planned POST). CLI, curl and other
// non-browser clients send no Origin header and are unaffected.
// CORS-Antwort-Header (Namen zentral, damit Preflight und echter POST
// dieselben setzen).
// CORS response headers (names in one place so preflight and the real POST
// set the same ones).
const (
headerAllowOrigin = "Access-Control-Allow-Origin"
headerAllowMethods = "Access-Control-Allow-Methods"
@@ -35,13 +33,13 @@ const (
headerVary = "Vary"
)
// preflightAge ist die Preflight-Cache-Dauer: 10 Minuten reichen für eine
// Chat-Session und halten den OPTIONS-Chatter klein.
// preflightAge is the preflight cache duration: 10 minutes cover a chat
// session and keep the OPTIONS chatter small.
const preflightAge = "600"
// corsACAOLiefert den Wert für Access-Control-Allow-Origin: bei Wildcard-
// Konfiguration das literale "*", sonst die exakt gematchte Origin (die
// identisch mit dem Config-Eintrag ist). Leer heißt: nicht erlaubt.
// corsACAO returns the value for Access-Control-Allow-Origin: the literal
// "*" for a wildcard configuration, otherwise the exactly matched origin
// (identical to the config entry). Empty means: not allowed.
func (h *handler) corsACAO(r *http.Request) string {
origin := r.Header.Get("Origin")
if origin == "" {
@@ -58,10 +56,9 @@ func (h *handler) corsACAO(r *http.Request) string {
return ""
}
// setSpeakCors setzt die CORS-Header auf echten POST-/speak-Antworten,
// wenn die Anfrage-Origin erlaubt ist. Vary: Origin steht immer (auch bei
// geschlossener Tür), damit Caches die Antwort nie fälschlich origin-
// übergreifend wiederverwenden.
// setSpeakCors sets the CORS headers on real POST /speak responses when the
// request origin is allowed. Vary: Origin is always present (even with the
// door closed) so caches never wrongly reuse the response across origins.
func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -69,11 +66,11 @@ func (h *handler) setSpeakCors(w http.ResponseWriter, r *http.Request) {
}
}
// speakPreflight behandelt OPTIONS /speak (CORS-Preflight): 204 mit den
// Freigabe-Headern bei gelisteter Origin, 204 ohne jegliche CORS-Header
// sonst (ungelistet, fehlend oder Tür komplett zu) — der Browser verwirft
// den eigentlichen Request, während der Daemon für Nicht-Browser-Clients
// erreichbar bleibt.
// speakPreflight handles OPTIONS /speak (CORS preflight): 204 with the
// permission headers for a listed origin, 204 without any CORS headers
// otherwise (unlisted, missing or door completely closed). The browser
// drops the actual request while the daemon stays reachable for
// non-browser clients.
func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.Header().Add(headerVary, "Origin")
if acao := h.corsACAO(r); acao != "" {
@@ -85,13 +82,13 @@ func (h *handler) speakPreflight(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// originGuard ist die Middleware vor dem Router: Anfragen mit Origin-Header
// (also aus einem Browser) kommen nur als OPTIONS /speak (Preflight, ohne
// Wirkung) oder als POST /speak von gelisteter Origin durch. Alles andere —
// ungelistete Origin auf /speak, jeder Browser-Zugriff auf Steuer-Endpunkte
// und /mcp — wird mit 403 abgelehnt, bevor ein Body gelesen oder eine
// Wirkung ausgelöst wird. Ohne Origin-Header (CLI, curl, MCP-Clients)
// passiert nichts.
// originGuard is the middleware in front of the router: requests with an
// Origin header (i.e. from a browser) only pass as OPTIONS /speak (preflight,
// no effect) or as POST /speak from a listed origin. Everything else
// (unlisted origin on /speak, any browser access to control endpoints and
// /mcp) is rejected with 403 before a body is read or an effect is
// triggered. Without an Origin header (CLI, curl, MCP clients) nothing
// happens.
func (h *handler) originGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
@@ -106,6 +103,6 @@ func (h *handler) originGuard(next http.Handler) http.Handler {
}
w.Header().Add(headerVary, "Origin")
writeError(w, http.StatusForbidden,
"Origin "+origin+" ist nicht erlaubt (Browser-Zugriff nur auf POST /speak von gelisteten Origins in server.allowed_origins)")
"origin "+origin+" is not allowed (browsers may only POST /speak from origins listed in server.allowed_origins)")
})
}
+9 -10
View File
@@ -1,11 +1,10 @@
// Package api definiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
// POST /speak (enqueue, 202 {id}; bei Mute 202 {muted:true}; bei Gesetz-
// Verwurf 202 {discarded:"rate_limit"|"quiet_hours"} — Task 05),
// GET /status (inkl. Gesetz-State, mcp/cors — Task 06), POST /cancel,
// POST /mute, POST /unmute. Task 06 ergänzt die CORS-Tür für POST /speak
// (OPTIONS-Preflight + ACAO aus server.allowed_origins, siehe cors.go)
// und den MCP-Endpoint POST /mcp als schlankes eigenes JSON-RPC-Subset
// (initialize, notifications/initialized, tools/list, tools/call mit dem
// speak-Tool — siehe mcp.go). Der Client-Teil ist die CLI-Seite derselben
// Vertragsschnittstelle.
// Package api defines the daemon's HTTP surface on 127.0.0.1: POST /speak
// (enqueue, 202 {id}; 202 {muted:true} when muted; 202
// {discarded:"rate_limit"|"quiet_hours"} when a law discards), GET /status
// (including law state, mcp/cors), POST /cancel, POST /mute, POST /unmute.
// It also provides the CORS door for POST /speak (OPTIONS preflight + ACAO
// from server.allowed_origins, see cors.go) and the MCP endpoint POST /mcp
// as a lean JSON-RPC subset of its own (initialize,
// notifications/initialized, tools/list, tools/call with the speak tool;
// see mcp.go). The client part is the CLI side of the same contract.
package api
+94 -98
View File
@@ -10,33 +10,32 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// MCP-Endpoint (Task 06): Streamable HTTP auf POST /mcp — eine eigene,
// schlanke Implementierung des JSON-RPC-2.0-Subsets, das MCP tatsächlich
// braucht (bewusst KEIN SDK-Dependency; der Protokollumfang hier ist vier
// Methoden plus ping). Semantik nach MCP 2025-06-18, mit zwei
// dokumentierten Vereinfachungen, die der Transport hergibt:
// MCP endpoint: Streamable HTTP on POST /mcp, a lean implementation of our
// own of the JSON-RPC 2.0 subset that MCP actually needs (deliberately NO
// SDK dependency; the protocol scope here is four methods plus ping).
// Semantics per MCP 2025-06-18, with two documented simplifications the
// transport allows:
//
// - Zustandslos: Jeder POST ist unabhängig; es gibt keine Session-IDs
// (Mcp-Session-Id wird ignoriert) und damit auch keine Durchsetzung
// der Initialize-vor-allem-Regel — ein curl kann tools/call direkt
// rufen. MCP-Clients stört das nicht: Sie fahren den Handshake
// freiwillig.
// - Keine server-initiierten Streams: talkd pusht nie (die Tool-Liste
// ist statisch). GET /mcp antwortet daher 405 — die vom Streamable-
// HTTP-Transport vorgesehene Antwort für Server ohne SSE-Stream.
// - Stateless: every POST is independent; there are no session IDs
// (Mcp-Session-Id is ignored) and therefore no enforcement of the
// initialize-first rule; a curl can call tools/call directly. MCP
// clients do not mind: they run the handshake voluntarily.
// - No server-initiated streams: talkd never pushes (the tool list is
// static). GET /mcp therefore answers 405, the response the Streamable
// HTTP transport specifies for servers without an SSE stream.
//
// Antworten: Requests → 200 application/json (JSON-RPC-Fehlerobjekte
// ebenfalls 200 — der HTTP-Teil war ja erfolgreich); Notifications → 202
// ohne Body (JSON-RPC 2.0). Batches (Arrays) sind seit 2025-06-18 nicht
// mehr Teil von MCP → -32600.
// Responses: requests get 200 application/json (JSON-RPC error objects also
// 200; the HTTP part did succeed); notifications get 202 without a body
// (JSON-RPC 2.0). Batches (arrays) are no longer part of MCP since
// 2025-06-18 and yield -32600.
// mcpProtocolVersion ist die Version, die talkd selbst spricht, wenn der
// Client keine angibt (initialize ohne protocolVersion). Angefragte
// Versionen werden akzeptiert und zurückgegeben (Echo) — inhaltlich
// implementiert ist der oben dokumentierte 2025-06-18-Umfang.
// mcpProtocolVersion is the version talkd itself speaks when the client
// names none (initialize without protocolVersion). Requested versions are
// accepted and echoed back; what is implemented is the 2025-06-18 scope
// documented above.
const mcpProtocolVersion = "2025-06-18"
// JSON-RPC-2.0-Fehlercodes (benutztes Subset).
// JSON-RPC 2.0 error codes (subset in use).
const (
rpcParseError = -32700
rpcInvalidRequest = -32600
@@ -45,12 +44,11 @@ const (
rpcInternalError = -32603
)
// mcpServerName ist der serverInfo-Name im Initialize-Ergebnis.
// mcpServerName is the serverInfo name in the initialize result.
const mcpServerName = "talkd"
// jsonRPCRequest ist eine eingehende JSON-RPC-Nachricht. ID als
// RawMessage: So bleibt zwischen „Feld fehlt" (Notification) und id:0
// bzw. id:string unterscheidbar.
// jsonRPCRequest is an incoming JSON-RPC message. ID as RawMessage keeps
// "field missing" (notification) distinguishable from id:0 or id:string.
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -58,9 +56,9 @@ type jsonRPCRequest struct {
Params json.RawMessage `json:"params"`
}
// jsonRPCResponse ist eine ausgehende JSON-RPC-Nachricht. Genau eines aus
// Result/Error ist gesetzt. ID ist nie weggelassen — bei Parse-Fehlern
// ohne bekannte ID null (JSON-RPC 2.0).
// jsonRPCResponse is an outgoing JSON-RPC message. Exactly one of
// Result/Error is set. ID is never omitted; null on parse errors without a
// known ID (JSON-RPC 2.0).
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
@@ -68,112 +66,112 @@ type jsonRPCResponse struct {
Error *rpcError `json:"error,omitempty"`
}
// rpcError ist das Fehlerobjekt einer JSON-RPC-Antwort.
// rpcError is the error object of a JSON-RPC response.
type rpcError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// mcpInitializeParams sind die relevanten Teile der initialize-Parameter
// (Client-Capabilities interessieren talkd nicht — Tools genügen).
// mcpInitializeParams holds the relevant parts of the initialize params
// (client capabilities do not interest talkd; tools suffice).
type mcpInitializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
}
// mcpServerInfo beschreibt den Server im Initialize-Ergebnis.
// mcpServerInfo describes the server in the initialize result.
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// mcpCapabilities meldet im Initialize-Ergebnis, was talkd anbietet:
// Tools (speak) — sonst nichts.
// mcpCapabilities reports in the initialize result what talkd offers:
// tools (speak), nothing else.
type mcpCapabilities struct {
Tools map[string]any `json:"tools"`
}
// mcpInitializeResult ist das Ergebnis von initialize: die (geechote)
// Protokollversion, die Fähigkeiten und die Server-Info — 2025-06-18-Form.
// mcpInitializeResult is the result of initialize: the (echoed) protocol
// version, the capabilities and the server info, in 2025-06-18 form.
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities mcpCapabilities `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolsListResult ist das Ergebnis von tools/list: genau ein Tool
// (speak); ohne nextCursor (die Liste passt immer in eine Seite).
// mcpToolsListResult is the result of tools/list: exactly one tool (speak);
// no nextCursor (the list always fits on one page).
type mcpToolsListResult struct {
Tools []mcpTool `json:"tools"`
}
// mcpTool ist ein Tool-Eintrag in tools/list.
// mcpTool is a tool entry in tools/list.
type mcpTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema mcpSchema `json:"inputSchema"`
}
// mcpSchema ist das JSON-Schema-Objekt eines Tool-Inputs.
// mcpSchema is the JSON Schema object of a tool input.
type mcpSchema struct {
Type string `json:"type"`
Properties map[string]mcpProperty `json:"properties"`
Required []string `json:"required"`
}
// mcpProperty ist eine einzelne Schema-Property.
// mcpProperty is a single schema property.
type mcpProperty struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// mcpToolCallParams sind die Parameter von tools/call.
// mcpToolCallParams are the parameters of tools/call.
type mcpToolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
}
// mcpContent ist ein Content-Block eines Tool-Ergebnisses (hier: Text).
// mcpContent is a content block of a tool result (here: text).
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text"`
}
// mcpToolResult ist das Ergebnis von tools/call. isError:false (weggelassen)
// auch bei muted/discarded — das ist ein ERGEBNIS der Aufnahme, kein
// Protokollfehler (bewusste Entscheidung, siehe speakToolResult).
// mcpToolResult is the result of tools/call. isError:false (omitted) also
// for muted/discarded: that is a RESULT of admission, not a protocol error
// (deliberate decision, see speakToolResult).
type mcpToolResult struct {
Content []mcpContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// serveMCP behandelt POST /mcp: eine JSON-RPC-Nachricht pro Request.
// serveMCP handles POST /mcp: one JSON-RPC message per request.
func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
if err != nil {
writeRPCError(w, nil, rpcParseError, "Body lesen: "+err.Error())
writeRPCError(w, nil, rpcParseError, "reading body: "+err.Error())
return
}
if strings.HasPrefix(strings.TrimLeft(string(body), " \t\r\n"), "[") {
writeRPCError(w, nil, rpcInvalidRequest,
"Batch-Requests werden nicht unterstützt (MCP 2025-06-18 hat Batches entfernt) — eine Nachricht pro POST")
"batch requests are not supported (MCP 2025-06-18 removed batches); one message per POST")
return
}
var req jsonRPCRequest
if err := json.Unmarshal(body, &req); err != nil {
writeRPCError(w, nil, rpcParseError, "ungültiges JSON: "+err.Error())
writeRPCError(w, nil, rpcParseError, "invalid JSON: "+err.Error())
return
}
if req.JSONRPC != "2.0" {
writeRPCError(w, nil, rpcInvalidRequest,
`jsonrpc muss "2.0" sein (erhalten: `+strconv.Quote(req.JSONRPC)+`)`)
`jsonrpc must be "2.0" (got `+strconv.Quote(req.JSONRPC)+`)`)
return
}
if isNotification(req.ID) {
// Notifications (notifications/initialized, …) bekommen nie eine
// JSON-RPC-Antwort — nur den 202-Vermerk des Transports. Unbekannte
// Notifications werden still ignoriert (JSON-RPC 2.0).
// Notifications (notifications/initialized, ...) never get a
// JSON-RPC response, only the transport's 202. Unknown
// notifications are silently ignored (JSON-RPC 2.0).
w.WriteHeader(http.StatusAccepted)
return
}
@@ -182,8 +180,8 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
case "initialize":
h.mcpInitialize(w, req)
case "notifications/initialized":
// Mit ID gesendet: kein regulärer Client — die korrekte Antwort
// ist ein leeres Ergebnis, kein Fehler.
// Sent with an ID: not a regular client; the correct answer is an
// empty result, not an error.
writeRPCResult(w, req.ID, struct{}{})
case "ping":
writeRPCResult(w, req.ID, struct{}{})
@@ -193,26 +191,26 @@ func (h *handler) serveMCP(w http.ResponseWriter, r *http.Request) {
h.mcpToolsCall(w, req)
default:
writeRPCError(w, req.ID, rpcMethodNotFound,
"Methode "+strconv.Quote(req.Method)+" ist nicht implementiert (verfügbar: initialize, notifications/initialized, ping, tools/list, tools/call)")
"method "+strconv.Quote(req.Method)+" is not implemented (available: initialize, notifications/initialized, ping, tools/list, tools/call)")
}
}
// mcpGet behandelt GET /mcp: 405 mit Allow — talkd bietet keine
// server-initiierten Streams an (die dafür vorgesehene Streamable-HTTP-
// Antwort), Tools-Änderungen gibt es nicht.
// mcpGet handles GET /mcp: 405 with Allow. talkd offers no server-initiated
// streams (the Streamable HTTP response for that case); there are no tool
// changes.
func (h *handler) mcpGet(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Allow", "POST, OPTIONS")
w.WriteHeader(http.StatusMethodNotAllowed)
}
// mcpInitialize behandelt initialize: Die angefragte Protokollversion wird
// geechot (akzeptiert) — leer → die eigene (2025-06-18). Capabilities:
// Tools; serverInfo: talkd + Daemon-Version.
// mcpInitialize handles initialize: the requested protocol version is
// echoed (accepted); empty means our own (2025-06-18). Capabilities: tools;
// serverInfo: talkd + daemon version.
func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpInitializeParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "initialize-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "initialize params: "+err.Error())
return
}
}
@@ -227,18 +225,18 @@ func (h *handler) mcpInitialize(w http.ResponseWriter, req jsonRPCRequest) {
})
}
// mcpToolsCall behandelt tools/call: genau ein Tool (speak), dessen
// Argumente denselben Aufnahme-Pfad wie POST /speak fahren — inklusive
// Mute und Gesetz (Rate-Limit/Ruhezeit).
// mcpToolsCall handles tools/call: exactly one tool (speak) whose arguments
// take the same admission path as POST /speak, including mute and the laws
// (rate limit/quiet hours).
func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
var params mcpToolCallParams
if err := json.Unmarshal(req.Params, &params); err != nil {
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call-params: "+err.Error())
writeRPCError(w, req.ID, rpcInvalidParams, "tools/call params: "+err.Error())
return
}
if params.Name != "speak" {
writeRPCError(w, req.ID, rpcInvalidParams,
"unbekanntes Tool "+strconv.Quote(params.Name)+" (verfügbar: speak)")
"unknown tool "+strconv.Quote(params.Name)+" (available: speak)")
return
}
@@ -254,8 +252,8 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
adm := h.admitSpeak(speakReq)
if adm.status != http.StatusAccepted {
// Validierungs-/Interner-Fehler (HTTP-Äquivalent 400/503): als
// JSON-RPC-Fehler melden — das war kein Aufnahme-Ergebnis.
// Validation/internal error (HTTP equivalent 400/503): report as a
// JSON-RPC error; this was not an admission result.
code := rpcInvalidParams
if adm.status != http.StatusBadRequest {
code = rpcInternalError
@@ -266,38 +264,37 @@ func (h *handler) mcpToolsCall(w http.ResponseWriter, req jsonRPCRequest) {
writeRPCResult(w, req.ID, speakToolResult(adm.resp))
}
// speakTool ist die Tool-Beschreibung von speak: Input-Schema aus dem
// Task-06-Brief (text Pflicht; priority/provider/lang/now optional).
// speakTool is the tool description of speak: input schema with text
// required; priority/provider/lang/now optional.
func speakTool() mcpTool {
return mcpTool{
Name: "speak",
Description: "Spricht eine kurze Ansage (Status, nächster Schritt, " +
"Freigabe-Bedarf — nie Langantworten). Läuft über den Daemon: " +
"Queue, Rate-Limit, Mute und Ruhezeiten gelten.",
Description: "Speaks a short announcement (status, next step, what the user is needed for; " +
"never long answers). Goes through the daemon: queue, rate limit, mute and quiet hours apply.",
InputSchema: mcpSchema{
Type: "object",
Properties: map[string]mcpProperty{
"text": {
Type: "string",
Description: "Der Ansage-Text (1–2 Sätze Etikette)",
Description: "The announcement text (1 to 2 sentences by etiquette)",
},
"priority": {
Type: "string",
Enum: []string{"low", "normal", "high"},
Description: "Abarbeitungs-Priorität (Default normal)",
Description: "Processing priority (default normal)",
},
"provider": {
Type: "string",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); leer = Default-Kette",
Description: "Provider pin (elevenlabs, openai, edge, osbuiltin); empty = default chain",
},
"lang": {
Type: "string",
Enum: []string{"de", "en"},
Description: "Sprache pin; leer = automatische Erkennung",
Description: "Language pin; empty = automatic detection",
},
"now": {
Type: "boolean",
Description: "Springt vor die Warteschlange und bricht bewusst aus Rate-Limit und Ruhezeit aus",
Description: "Jumps ahead of the queue and deliberately bypasses rate limit and quiet hours",
},
},
Required: []string{"text"},
@@ -305,27 +302,26 @@ func speakTool() mcpTool {
}
}
// speakToolResult übersetzt eine /speak-Aufnahme-Antwort in das MCP-Tool-
// Ergebnis. Mute- und Gesetz-Verwürfe sind bewusst KEIN isError: Sie sind
// das korrekte dokumentierte Ergebnis der Aufnahme (kein Protokollfehler)
// — der Agent soll den Grund lesen, nicht crashen. Erfolgreiche Aufnahme:
// "queued: <id>".
// speakToolResult translates a /speak admission response into the MCP tool
// result. Mute and law discards are deliberately NOT isError: they are the
// correct, documented result of admission (no protocol error); the agent
// should read the reason, not crash. Successful admission: "queued: <id>".
func speakToolResult(resp SpeakResponse) mcpToolResult {
switch {
case resp.Muted:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "muted: Ansage verworfen (Mute ist aktiv)",
Text: "muted: announcement discarded (mute is on)",
}}}
case resp.Discarded == queue.DiscardRateLimit:
return mcpToolResult{Content: []mcpContent{{
Type: "text",
Text: "discarded: rate_limit — Ansage verworfen (Priorität low über dem Fenster-Budget)",
Text: "discarded: rate_limit; announcement discarded (priority low over the window budget)",
}}}
case resp.Discarded == queue.DiscardQuietHours:
text := "discarded: quiet_hours — Ruhezeit aktiv, Ansage verworfen"
text := "discarded: quiet_hours; quiet hours active, announcement discarded"
if resp.Toast {
text += " (durch Toast ersetzt, kein Audio)"
text += " (replaced by a toast, no audio)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
case resp.Discarded != "":
@@ -336,26 +332,26 @@ func speakToolResult(resp SpeakResponse) mcpToolResult {
default:
text := "queued: " + resp.ID
if resp.Truncated {
text += " (Text auf max_text gekürzt — Ansagen sind 1–2 Sätze, keine Langantworten)"
text += " (text truncated to max_text; announcements are 1 to 2 sentences, not long answers)"
}
return mcpToolResult{Content: []mcpContent{{Type: "text", Text: text}}}
}
}
// isNotification meldet, ob die ID eine Notification anzeigt (Feld fehlt
// oder explizit null — beides nach JSON-RPC 2.0 ohne Antwort).
// isNotification reports whether the ID marks a notification (field missing
// or explicitly null; both get no response per JSON-RPC 2.0).
func isNotification(id json.RawMessage) bool {
return len(id) == 0 || strings.TrimSpace(string(id)) == "null"
}
// writeRPCResult schreibt eine erfolgreiche JSON-RPC-Antwort (200,
// writeRPCResult writes a successful JSON-RPC response (200,
// application/json).
func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result any) {
writeRPC(w, jsonRPCResponse{JSONRPC: "2.0", ID: id, Result: result})
}
// writeRPCError schreibt eine JSON-RPC-Fehler-Antwort; id null, wenn die
// Anfrage-ID unbekannt ist (Parse-Fehler).
// writeRPCError writes a JSON-RPC error response; id null when the request
// ID is unknown (parse error).
func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg string) {
writeRPC(w, jsonRPCResponse{
JSONRPC: "2.0",
@@ -364,7 +360,7 @@ func writeRPCError(w http.ResponseWriter, id json.RawMessage, code int, msg stri
})
}
// writeRPC schreibt die JSON-RPC-Antwort in den Response-Writer.
// writeRPC writes the JSON-RPC response to the response writer.
func writeRPC(w http.ResponseWriter, resp jsonRPCResponse) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
+65 -64
View File
@@ -10,45 +10,45 @@ import (
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// maxBodyBytes begrenzt Anfrage-Körper (Ansagen sind kurz; Schutz gegen
// versehentliche Riesen-Payloads).
// maxBodyBytes limits request bodies (announcements are short; protection
// against accidental huge payloads).
const maxBodyBytes = 64 << 10
// HandlerConfig bündelt die Abhängigkeiten der HTTP-Oberfläche. Der Handler
// kennt weder Config-Dateien noch Env — alles wird vom Daemon (cmd/talkd)
// injiziert; Prozessgrenzen-Tests fahren genau diese Oberfläche als Black Box.
// 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 ist die Daemon-Queue (Enqueue/Cancel/Current/Pending).
// Queue is the daemon queue (Enqueue/Cancel/Current/Pending).
Queue *queue.Queue
// Mute ist der persistente Mute-State (Datei mute im Config-Dir).
// Mute is the persistent mute state (file mute in the config dir).
Mute queue.MuteStore
// Version ist die Daemon-Version für GET /status und serverInfo.
// Version is the daemon version for GET /status and serverInfo.
Version string
// KnownProviders sind die gültigen Provider-Namen (Validierung des
// provider-Felds in POST /speak; talkd liefert provider.Names()).
// KnownProviders are the valid provider names (validation of the
// provider field in POST /speak; the daemon passes provider.Names()).
KnownProviders []string
// Provider löst den aktiven Provider für GET /status auf (Daemon-seitig:
// Pin des laufenden Items bzw. erste verfügbare Ketten-Stufe). Optional.
// 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 ist die CORS-Erlaubnisliste für POST /speak (leer =
// keine CORS-Header, der Browser blockt; siehe cors.go).
// AllowedOrigins is the CORS allow list for POST /speak (empty = no CORS
// headers, the browser blocks; see cors.go).
AllowedOrigins []string
// Shutdown fährt den Daemon herunter (POST /shutdown, `talk daemon
// stop`). Muss nicht blockieren und mehrfach aufrufbar sein. Optional:
// nil → der Endpunkt antwortet 501.
// 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 liefert die HTTP-Routen des Daemons. Methoden-Bindung per
// ServeMux-Muster: falsche Methode → 405, unbekannter Pfad → 404. Vor dem
// Router steht der originGuard (cors.go): Browser-Anfragen (Origin-Header)
// erreichen nur POST /speak von gelisteten Origins; alles andere ist 403.
// 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,
@@ -64,18 +64,18 @@ func NewHandler(cfg HandlerConfig) http.Handler {
}
mux := http.NewServeMux()
mux.HandleFunc("POST /speak", h.speak)
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS-Preflight (Task 06)
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 (Task 06)
mux.HandleFunc("GET /mcp", h.mcpGet) // 405: keine server-initiierten Streams
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 ist der interne Zustand der HTTP-Oberfläche.
// handler is the internal state of the HTTP surface.
type handler struct {
queue *queue.Queue
mute queue.MuteStore
@@ -86,18 +86,18 @@ type handler struct {
shutdown func()
}
// speak behandelt POST /speak: validieren, bei Mute verwerfen (202,
// dokumentiertes Verhalten — bewusst kein Fehler, damit Aufrufer keine
// Sonderbehandlung brauchen), sonst enqueue → 202 {id}. Das Gesetz
// (Rate-Limit-Budget, Ruhezeit) kann beim Eintritt ebenfalls verwerfen —
// die Antwort nennt dann den Grund ({"discarded":"…"}). CORS-Header nur
// hier und nur bei gelisteter Origin (siehe cors.go).
// 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, "ungültiger JSON-Body: "+err.Error())
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
adm := h.admitSpeak(req)
@@ -108,38 +108,38 @@ func (h *handler) speak(w http.ResponseWriter, r *http.Request) {
writeJSON(w, adm.status, adm.resp)
}
// speakAdmission ist das Ergebnis des gemeinsamen Aufnahme-Pfads von
// POST /speak und dem MCP-Tool speak: HTTP-Status-Äquivalent plus Body —
// 202 mit {id}/{muted}/{discarded} als Aufnahme-Ergebnis, 400/503 mit
// errMsg als Fehler (MCP übersetzt beides, siehe mcp.go).
// 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 ist der eine Aufnahme-Pfad hinter POST /speak und dem
// MCP-Tool speak (Task 06): Validierung, Mute-Check, Enqueue inklusive
// Gesetz (Rate-Limit-Budget, Ruhezeit) — identische Semantik für beide
// Türen, kein Contract-Drift.
// 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 fehlt oder ist leer"}
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: ungültiger Wert %q (erlaubt: low, normal, high)", req.Priority)}
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: unbekannter Wert %q", req.Provider)}
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: ungültiger Wert %q (erlaubt: de, en)", req.Lang)}
errMsg: fmt.Sprintf("lang: invalid value %q (allowed: de, en)", req.Lang)}
}
if h.mute.Muted() {
@@ -158,18 +158,18 @@ func (h *handler) admitSpeak(req SpeakRequest) speakAdmission {
return speakAdmission{status: http.StatusServiceUnavailable, errMsg: "enqueue: " + err.Error()}
}
if adm.Discarded != "" {
// Gesetz-Verwurf (Rate-Limit-Budget / Ruhezeit): dokumentiertes
// Verhalten wie bei Mute — 202 mit Grund statt Fehler, damit
// Aufrufer keine Sonderbehandlung brauchen.
// 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 behandelt GET /status: Queue-Länge (+ wartende IDs), aktuelles
// Item, Mute-State, aktiver Provider, Version — und seit Task 05 den
// Gesetz-State (Rate-Limit mit nächstem Slot, Ruhezeit-Fenster).
// 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))
@@ -201,17 +201,17 @@ func (h *handler) status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, st)
}
// corsList kopiert die Erlaubnisliste als JSON-[] statt null: Der Status
// soll "cors": [] zeigen, wenn die Tür zu ist.
// 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 überführt die Gesetz-Sicht der Queue in die JSON-Form von
// /status: Zeitstempel nur, wenn sie in der Zukunft liegen (null = Slot
// frei / Ruhezeit inaktiv).
// 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{
@@ -234,13 +234,14 @@ func lawState(l queue.LawState) (*RateLimitState, *QuietHoursState) {
return rl, qh
}
// cancel behandelt POST /cancel: laufende Ansage abbrechen + Queue leeren.
// 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 behandelt POST /mute bzw. POST /unmute: persistenter
// Mute-State (Datei im Config-Dir, überlebt Daemon-Restarts).
// 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 {
@@ -251,26 +252,26 @@ func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
}
}
// shutdownEndpoint behandelt POST /shutdown (`talk daemon stop`): Antwort
// zuerst, dann der Shutdown-Trigger — der Aufrufer bekommt sein 200, bevor
// der Server die Verbindung schließt.
// 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, "dieser Daemon kennt keinen Shutdown-Trigger")
writeError(w, http.StatusNotImplemented, "this daemon has no shutdown trigger")
return
}
writeJSON(w, http.StatusOK, ShutdownResponse{OK: true})
h.shutdown()
}
// writeJSON schreibt eine JSON-Antwort mit Status-Code.
// 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 schreibt einen Fehler als {"error": "…"} mit Status-Code.
// 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})
}
+56 -55
View File
@@ -1,53 +1,53 @@
package api
// Request/Response-Typen des Daemon-HTTP-Vertrags (Task-04-Brief, bindende
// Felder). Die CLI und spätere Konsumenten (MCP, Task 06) benutzen dieselben
// Typen — eine Quelle, kein Contract-Drift zwischen Daemon und Client.
// Request/response types of the daemon HTTP contract (binding fields). The
// CLI and other consumers (MCP) use the same types: one source, no contract
// drift between daemon and client.
// SpeakRequest ist der Body von POST /speak.
// SpeakRequest is the body of POST /speak.
type SpeakRequest struct {
// Text ist der Ansage-Text (Pflicht).
// Text is the announcement text (required).
Text string `json:"text"`
// Priority: low | normal | high (Default normal, wenn leer).
// Priority: low | normal | high (default normal when empty).
Priority string `json:"priority,omitempty"`
// Provider pinnt die Provider-Kette (--provider).
// Provider pins the provider chain (--provider).
Provider string `json:"provider,omitempty"`
// Voice pinnt eine Voice-ID (--voice).
// Voice pins a voice ID (--voice).
Voice string `json:"voice,omitempty"`
// Lang pinnt die Sprache de | en (leer = automatische Erkennung).
// Lang pins the language de | en (empty = automatic detection).
Lang string `json:"lang,omitempty"`
// Now lässt die Ansage vor allen wartenden springen (--now).
// Now lets the announcement jump ahead of all waiting ones (--now).
Now bool `json:"now,omitempty"`
}
// SpeakResponse ist die Antwort von POST /speak: 202 {"id":"…"} — bei
// aktivem Mute 202 {"id":"","muted":true} (Ansage verworfen; dokumentiertes
// Verhalten, kein Fehler). Seit Task 05 meldet verworfenen Spam ebenso
// dokumentiert: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"} —
// plus "toast":true, wenn die Ruhezeit-Ansage durch eine Benachrichtigung
// ersetzt wurde (quiet_mode = "toast").
// SpeakResponse is the response of POST /speak: 202 {"id":"..."}; with mute
// active 202 {"id":"","muted":true} (announcement discarded; documented
// behaviour, not an error). Discarded spam is reported just as
// documented: 202 {"id":"","discarded":"rate_limit"|"quiet_hours"}, plus
// "toast":true when the quiet-hours announcement was replaced by a
// notification (quiet_mode = "toast").
type SpeakResponse struct {
ID string `json:"id"`
Muted bool `json:"muted,omitempty"`
// Discarded ist der Verwurfs-Grund ("rate_limit" | "quiet_hours");
// leer, wenn die Ansage aufgenommen wurde.
// Discarded is the discard reason ("rate_limit" | "quiet_hours"); empty
// when the announcement was admitted.
Discarded string `json:"discarded,omitempty"`
// Toast meldet die Ersetzung durch eine Benachrichtigung.
// Toast reports the replacement by a notification.
Toast bool `json:"toast,omitempty"`
// Truncated meldet, dass der Text auf queue.max_text gekürzt und so
// aufgenommen wurde (Gesetz gegen vorgelesene Langantworten).
// Truncated reports that the text was cut to queue.max_text and
// admitted that way (the law against read-aloud long answers).
Truncated bool `json:"truncated,omitempty"`
}
// CurrentItem beschreibt das laufende Item in GET /status.
// CurrentItem describes the running item in GET /status.
type CurrentItem struct {
ID string `json:"id"`
Text string `json:"text"`
@@ -55,12 +55,12 @@ type CurrentItem struct {
Now bool `json:"now"`
}
// StatusResponse ist die Antwort von GET /status: Queue-Länge, aktuelles
// Item (null im Leerlauf), Mute-State, aktiver Provider, Daemon-Version.
// QueueIDs ist die Ergänzung der wartenden IDs in Abarbeitungs-Reihenfolge
// — die CLI braucht sie für ihr --wait-Polling. Task 05 ergänzt additiv
// den Gesetz-State (rate_limit, quiet_hours); Task 06 additiv mcp/cors
// (Compat-Felder: MCP-Endpoint vorhanden, konfigurierte CORS-Origins).
// StatusResponse is the response of GET /status: queue length, current item
// (null when idle), mute state, active provider, daemon version. QueueIDs
// lists the waiting IDs in processing order; the CLI needs them for its
// --wait polling. The law state (rate_limit, quiet_hours) and mcp/cors
// (compat fields: MCP endpoint present, configured CORS origins) are
// additive.
type StatusResponse struct {
Version string `json:"version"`
Queue int `json:"queue"`
@@ -72,59 +72,60 @@ type StatusResponse struct {
QuietHours *QuietHoursState `json:"quiet_hours,omitempty"`
Limits *LimitsState `json:"limits,omitempty"`
// MCP meldet, dass der Daemon den MCP-Endpoint anbietet (immer true;
// Feld existiert für additive Compat-Sicht von Clients/Browsern).
// MCP reports that the daemon offers the MCP endpoint (always true; the
// field exists for an additive compat view of clients/browsers).
MCP bool `json:"mcp"`
// Cors ist die konfigurierte CORS-Erlaubnisliste von POST /speak
// (leeres [] = Tür zu). Immer vorhanden (auch leer), nicht omitempty.
// Cors is the configured CORS allow list of POST /speak (empty [] =
// door closed). Always present (even when empty), not omitempty.
Cors []string `json:"cors"`
}
// RateLimitState ist der Rate-Limit-State in GET /status (Task 05).
// RateLimitState is the rate-limit state in GET /status.
type RateLimitState struct {
// Interval ist der konfigurierte Mindestabstand als Dauer-String
// ("30s"; "0s" = unbegrenzt).
// Interval is the configured minimum spacing as a duration string
// ("30s"; "0s" = unlimited).
Interval string `json:"interval"`
// AllowHighBurst meldet, ob priority=high verstoßen darf.
// AllowHighBurst reports whether priority=high may violate it.
AllowHighBurst bool `json:"allow_high_burst"`
// NextSlot ist der nächste freie Ansage-Slot als RFC3339-Zeitstempel;
// null, wenn sofort gespielt werden dürfte (oder unbegrenzt).
// NextSlot is the next free announcement slot as an RFC3339 timestamp;
// null when playback would be allowed immediately (or unlimited).
NextSlot *string `json:"next_slot"`
}
// LimitsState sind die Längen- und Alters-Gesetze in GET /status.
// LimitsState holds the length and age laws in GET /status.
type LimitsState struct {
// MaxText ist die Obergrenze der Ansage-Länge in Zeichen (0 = unbegrenzt).
// MaxText is the upper bound of the announcement length in characters
// (0 = unlimited).
MaxText int `json:"max_text"`
// MaxAge ist die längste Wartezeit in der Queue als Dauer-String
// ("2m0s"; "0s" = unbegrenzt).
// MaxAge is the longest wait in the queue as a duration string ("2m0s";
// "0s" = unlimited).
MaxAge string `json:"max_age"`
}
// QuietHoursState ist der Ruhezeit-State in GET /status (Task 05).
// QuietHoursState is the quiet-hours state in GET /status.
type QuietHoursState struct {
// Window ist das konfigurierte Fenster ("18:00-08:00"; "" = deaktiviert).
// Window is the configured window ("18:00-08:00"; "" = disabled).
Window string `json:"window"`
// Active meldet, ob die lokale Zeit gerade im Fenster liegt.
// Active reports whether local time is currently inside the window.
Active bool `json:"active"`
// Mode ist der konfigurierte Ruhezeit-Modus ("discard" | "toast").
// Mode is the configured quiet-hours mode ("discard" | "toast").
Mode string `json:"mode"`
// Until ist das Fenster-Ende als RFC3339-Zeitstempel, wenn aktiv;
// sonst null.
// Until is the window end as an RFC3339 timestamp when active;
// otherwise null.
Until *string `json:"until"`
}
// PendingOrCurrent meldet, ob die Ansage mit der ID noch wartet oder
// gerade läuft — Grundlage des --wait-Pollings der CLI. Ist die Ansage
// weder wartend noch laufend, ist sie fertig (oder abgebrochen) — in
// beiden Fällen hat der Aufrufer nichts mehr zu warten.
// PendingOrCurrent reports whether the announcement with the ID is still
// waiting or currently playing; the basis of the CLI's --wait polling. If
// it is neither waiting nor playing, it is done (or cancelled); in both
// cases the caller has nothing left to wait for.
func (s StatusResponse) PendingOrCurrent(id string) bool {
if s.Current != nil && s.Current.ID == id {
return true
@@ -137,17 +138,17 @@ func (s StatusResponse) PendingOrCurrent(id string) bool {
return false
}
// CancelResponse ist die Antwort von POST /cancel.
// CancelResponse is the response of POST /cancel.
type CancelResponse struct {
Cancelled int `json:"cancelled"`
}
// MuteResponse ist die Antwort von POST /mute und POST /unmute.
// MuteResponse is the response of POST /mute and POST /unmute.
type MuteResponse struct {
Muted bool `json:"muted"`
}
// ShutdownResponse ist die Antwort von POST /shutdown.
// ShutdownResponse is the response of POST /shutdown.
type ShutdownResponse struct {
OK bool `json:"ok"`
}