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

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