Files
talk/cmd/talk/say.go
T
m3ta-chironandClaude Fable 5.1 bc26a73aeb refactor: ein Binary — talk startet den Daemon selbst, talkd und Degraded Mode entfallen
Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit
Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je
OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der
Degraded Mode kannte weder Rate-Limit noch Ruhezeiten.

Jetzt:
- internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage);
  Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite
  Zusammensetzung der Kette mehr.
- internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per
  Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei
  daemon.pid, Shutdown-Trigger.
- `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter
  Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg
  und die PID-Datei gelöscht ist (Prozess wirklich beendet).
- Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst
  (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein
  paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt
  den Daemon des anderen — kein Timeout-Warten.
- Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren
  Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis +
  Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst
  nicht; `talk status` zeigt „läuft nicht" (Exit 1).
- cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt
  neu.

Tests: Harness baut nur noch talk und startet `talk daemon run`;
TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test
hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart
beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter
start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile
aller sechs Targets mit CGO_ENABLED=0 verifiziert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 09:49:16 +02:00

144 lines
4.6 KiB
Go

package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default:
// os.Getenv.
var getenv = os.Getenv
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
// `talk "Text"`).
type sayOptions struct {
wait bool
now bool
priority string
voice string
lang string
provider string
}
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
// und validieren, dann über den Daemon sprechen — non-blocking als
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
// Warteschlange, aber nie still.
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
text := strings.Join(args, " ")
if text == "" {
if isCharDevice(stdin) {
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
return exitUsage
}
b, err := io.ReadAll(stdin)
if err != nil {
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
return exitFailure
}
text = strings.TrimSpace(string(b))
if text == "" {
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
return exitFailure
}
}
// Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags):
// Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen.
if code, bad := validateSayOptions(opts, stderr); bad {
return code
}
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return announce(cfg, text, opts, stdout, stderr)
}
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if client, ok := ensureDaemon(cfg, stderr); ok {
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
return code
}
}
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
return sayDirect(cfg, text, opts, info, stderr)
}
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
// es ohne Mute weiter, statt zu scheitern.
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
if dir, err := config.Dir(getenv); err == nil {
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
if mute.Muted() {
fmt.Fprintln(info, "muted")
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
return exitOK
}
}
engine, err := speak.New(cfg, getenv, stderr)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
err = engine.Speak(context.Background(), speak.Request{
Text: text,
Lang: opts.lang,
Provider: opts.provider,
Voice: opts.voice,
})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
return exitOK
}
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
// en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
// Benutzungsfehler, bereits auf stderr gemeldet.
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
return exitFailure, true
}
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
return exitFailure, true
}
return 0, false
}
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
func isCharDevice(r io.Reader) bool {
f, ok := r.(*os.File)
if !ok {
return false
}
st, err := f.Stat()
if err != nil {
return false
}
return st.Mode()&os.ModeCharDevice != 0
}