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>
This commit is contained in:
1 parent
b86632006e
commit
bc26a73aeb
29 files changed
+1145
-584
No files matched your search
+8
-4
@@ -67,6 +67,13 @@ var commands = []command{
|
||||
detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version (plain, eine Zeile pro Feld).",
|
||||
handler: runStatus,
|
||||
},
|
||||
{
|
||||
name: "daemon",
|
||||
short: "Hintergrundprozess steuern (run | start | stop)",
|
||||
args: "[run|start|stop]",
|
||||
detail: "run (Default) betreibt den Daemon im Vordergrund — für systemd, launchd oder ein Terminal. start startet ihn im Hintergrund (Log: daemon.log im Config-Verzeichnis), stop fährt einen laufenden Daemon herunter. Normalerweise nicht nötig: talk startet den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an).",
|
||||
handler: runDaemon,
|
||||
},
|
||||
{
|
||||
name: "cancel",
|
||||
short: "Laufende und geplante Ansagen abbrechen",
|
||||
@@ -286,10 +293,7 @@ func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
// versionString rendert die Versionsausgabe: `talk version <version>` plus
|
||||
// Commit-Hash in Klammern, falls per ldflags injiziert.
|
||||
func versionString() string {
|
||||
if commit == "" {
|
||||
return fmt.Sprintf("talk version %s", version)
|
||||
}
|
||||
return fmt.Sprintf("talk version %s (%s)", version, commit)
|
||||
return "talk version " + buildVersion()
|
||||
}
|
||||
|
||||
// notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
||||
|
||||
+8
-5
@@ -19,7 +19,9 @@ import (
|
||||
// runMute implementiert `talk mute`: POST /mute an den Daemon. Ist kein
|
||||
// Daemon erreichbar, wird die Mute-Datei direkt geschrieben — dieselbe
|
||||
// Persistenz-Quelle, die der Daemon bei jeder Ansage neu liest; der
|
||||
// Zustand greift also auch offline und überlebt Daemon-Restarts.
|
||||
// Zustand greift also auch offline und überlebt Daemon-Restarts. Die
|
||||
// Steuer-Befehle starten den Daemon bewusst NICHT (nur Sprechen tut das):
|
||||
// Stummschalten braucht keinen laufenden Prozess.
|
||||
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
return setMute(true, stdout, stderr)
|
||||
}
|
||||
@@ -72,9 +74,9 @@ func printMuteState(muted bool, stdout io.Writer) {
|
||||
|
||||
// runStatus implementiert `talk status`: plain Block aus GET /status —
|
||||
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
|
||||
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Ohne Daemon:
|
||||
// klarer Fehler (status ist eine Anfrage an den Daemon; der Degraded Mode
|
||||
// hat keinen Status).
|
||||
// aktiver Provider, Gesetz-State (Rate-Limit, Ruhezeit). Läuft kein Daemon,
|
||||
// sagt status genau das (Exit 1) und startet ihn nicht — status soll die
|
||||
// Wirklichkeit zeigen, nicht verändern.
|
||||
func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
@@ -86,7 +88,8 @@ func runStatus(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr
|
||||
defer cancel()
|
||||
st, err := client.Status(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s (läuft talkd? say fällt in den Degraded Mode)\n", client.BaseURL)
|
||||
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
|
||||
fmt.Fprintln(stderr, "talk: kein Daemon — er startet beim nächsten Sprechbefehl von selbst (oder: talk daemon start)")
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ import (
|
||||
// Prozessgrenzen-Tests in /test.
|
||||
|
||||
// TestStatusOhneDaemon: `talk status` ohne Daemon ist ein klarer Fehler
|
||||
// mit Hinweis (kein Stillversagen).
|
||||
// (Exit 1) mit Hinweis auf stderr — und stdout nennt den Zustand trotzdem
|
||||
// als Status-Zeile, damit der Block nie leer bleibt. Status startet den
|
||||
// Daemon nicht.
|
||||
func TestStatusOhneDaemon(t *testing.T) {
|
||||
setGetenv(t, nil)
|
||||
var out, errBuf bytes.Buffer
|
||||
@@ -24,11 +26,11 @@ func TestStatusOhneDaemon(t *testing.T) {
|
||||
if code != exitFailure {
|
||||
t.Fatalf("talk status ohne Daemon: Exit-Code = %d, want %d", code, exitFailure)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "nicht erreichbar") {
|
||||
if !strings.Contains(errBuf.String(), "kein Daemon") {
|
||||
t.Errorf("stderr = %q, will Hinweis auf fehlenden Daemon", errBuf.String())
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("stdout = %q, will leer", out.String())
|
||||
if !strings.HasPrefix(out.String(), "daemon: läuft nicht") {
|
||||
t.Errorf("stdout = %q, will Status-Zeile „läuft nicht“", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +114,7 @@ func TestSayOhneConfigDir(t *testing.T) {
|
||||
if _, err := os.Stat(seen); err != nil {
|
||||
t.Errorf("fake espeak nicht gerufen: %v", err)
|
||||
}
|
||||
if !strings.Contains(errBuf.String(), "Degraded Mode") {
|
||||
if !strings.Contains(errBuf.String(), "spreche direkt") {
|
||||
t.Errorf("stderr = %q, will Degraded-Hinweis", errBuf.String())
|
||||
}
|
||||
}
|
||||
+97
-19
@@ -5,10 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
@@ -19,31 +22,106 @@ import (
|
||||
const (
|
||||
daemonProbeTimeout = time.Second // Daemon-Erkennung (GET /status)
|
||||
daemonCallTimeout = 5 * time.Second // einzelne Daemon-Aufrufe
|
||||
daemonStartTimeout = 5 * time.Second // Autostart: so lange auf /status warten
|
||||
daemonStartPoll = 50 * time.Millisecond // Autostart-Poll-Frequenz
|
||||
waitPollInterval = 100 * time.Millisecond // --wait-Poll-Frequenz
|
||||
)
|
||||
|
||||
// sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status,
|
||||
// dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking),
|
||||
// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder
|
||||
// läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage —
|
||||
// info sagt „muted". info ist der Writer für Informations-Token (Item-ID,
|
||||
// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines
|
||||
// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden.
|
||||
//
|
||||
// handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den
|
||||
// Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue
|
||||
// (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus
|
||||
// (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon
|
||||
// angekommen und wurde bewusst abgelehnt.
|
||||
func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
// daemonAlive meldet, ob unter dem Client ein Daemon antwortet (GET /status
|
||||
// mit 200). Jeder Fehler — Verbindung abgelehnt, Timeout, fremder Dienst
|
||||
// mit anderem Status — zählt als „kein Daemon".
|
||||
func daemonAlive(client *api.Client, timeout time.Duration) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
_, err := client.Status(ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout)
|
||||
defer cancelProbe()
|
||||
if _, err := client.Status(probeCtx); err != nil {
|
||||
return 0, false
|
||||
// ensureDaemon liefert einen Client zu einem laufenden Daemon und startet
|
||||
// ihn bei Bedarf selbst (daemon.autostart, Default an). ok=false heißt:
|
||||
// kein Daemon erreichbar und keiner startbar (Autostart aus, Start
|
||||
// gescheitert) — der Aufrufer spricht dann direkt. Gründe stehen auf
|
||||
// stderr; der Daemon selbst protokolliert in daemon.log im Config-Dir.
|
||||
func ensureDaemon(cfg config.Config, stderr io.Writer) (client *api.Client, ok bool) {
|
||||
client = api.NewClient(cfg.Server.Port)
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
return client, true
|
||||
}
|
||||
if !cfg.Daemon.Autostart {
|
||||
return client, false
|
||||
}
|
||||
return client, startDaemon(client, stderr)
|
||||
}
|
||||
|
||||
// startDaemon startet den Daemon im Hintergrund (dasselbe Binary mit
|
||||
// `daemon run`) und wartet, bis er auf /status antwortet. Endet das Kind
|
||||
// vorher (typisch: Port belegt, weil ein paralleler talk-Aufruf schneller
|
||||
// war), wird noch einmal geprüft — der Daemon des anderen zählt genauso.
|
||||
func startDaemon(client *api.Client, stderr io.Writer) bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: eigenes Binary nicht bestimmbar — Daemon-Start ausgesetzt (%v)\n", err)
|
||||
return false
|
||||
}
|
||||
logPath := ""
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
logPath = filepath.Join(dir, daemon.LogFileName)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
logPath = ""
|
||||
}
|
||||
}
|
||||
exited, err := daemon.Spawn(exe, logPath, os.Environ())
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return false
|
||||
}
|
||||
logHint := ""
|
||||
if logPath != "" {
|
||||
logHint = " (Log: " + logPath + ")"
|
||||
}
|
||||
deadline := time.Now().Add(daemonStartTimeout)
|
||||
for {
|
||||
if daemonAlive(client, daemonStartPoll*4) {
|
||||
fmt.Fprintf(stderr, "talk: Daemon gestartet unter %s%s\n", client.BaseURL, logHint)
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case err := <-exited:
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
return true // ein anderer Aufruf hat den Daemon gestartet
|
||||
}
|
||||
fmt.Fprintf(stderr, "talk: Daemon-Start fehlgeschlagen (%v)%s\n", exitReason(err), logHint)
|
||||
return false
|
||||
case <-time.After(daemonStartPoll):
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
fmt.Fprintf(stderr, "talk: Daemon antwortet nicht binnen %s%s\n", daemonStartTimeout, logHint)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// exitReason macht das Wait-Ergebnis eines beendeten Kindes lesbar.
|
||||
func exitReason(err error) string {
|
||||
if err == nil {
|
||||
return "Prozess hat sich sofort beendet"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// sayViaDaemon reicht eine Ansage beim Daemon ein (POST /speak). Default:
|
||||
// sofort zurückkehren (non-blocking), info erhält die Item-ID; --wait pollt
|
||||
// /status, bis die Ansage weder läuft noch wartet. Bei aktivem Mute
|
||||
// verwirft der Daemon — info sagt „muted"; Gesetz-Verwürfe nennen den
|
||||
// Grund. info ist der Writer für diese Informations-Token: say reicht
|
||||
// stdout, wrap stderr durch (stdout eines gewrappten Befehls darf nicht um
|
||||
// talk-Zeilen verunreinigt werden).
|
||||
//
|
||||
// handled=false heißt: Netz-Fehler (Daemon zwischen Erkennung und Aufruf
|
||||
// gestorben) → der Aufrufer spricht direkt. Eine Daemon-Antwort mit
|
||||
// Fehlerstatus (z. B. 400) wird dagegen angezeigt — die Ansage war beim
|
||||
// Daemon angekommen und wurde bewusst abgelehnt.
|
||||
func sayViaDaemon(client *api.Client, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.Speak(ctx, api.SpeakRequest{
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/daemon"
|
||||
)
|
||||
|
||||
// daemonStopTimeout ist die Frist, in der ein per /shutdown angestoßener
|
||||
// Daemon verschwunden sein muss.
|
||||
const daemonStopTimeout = 5 * time.Second
|
||||
|
||||
// runDaemon implementiert `talk daemon [run|start|stop]`:
|
||||
//
|
||||
// - run (Default): Daemon im Vordergrund bis SIGINT/SIGTERM — der Modus
|
||||
// für systemd/launchd/Task Scheduler oder ein Terminal. Protokoll auf
|
||||
// stderr.
|
||||
// - start: Daemon im Hintergrund starten (dasselbe wie der Autostart von
|
||||
// say, nur explizit und unabhängig von daemon.autostart) und melden,
|
||||
// wo er läuft. Läuft schon einer: kein Fehler.
|
||||
// - stop: laufenden Daemon per POST /shutdown herunterfahren und warten,
|
||||
// bis er weg ist. Läuft keiner: Hinweis, Exit 0 (idempotent).
|
||||
func runDaemon(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int {
|
||||
sub := "run"
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
if len(args) > 1 {
|
||||
fmt.Fprintln(stderr, "talk daemon: zu viele Argumente — Verwendung: talk daemon [run|start|stop]")
|
||||
return exitUsage
|
||||
}
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk daemon: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
switch sub {
|
||||
case "run":
|
||||
return daemonRun(cfg, stderr)
|
||||
case "start":
|
||||
return daemonStart(cfg, stdout, stderr)
|
||||
case "stop":
|
||||
return daemonStop(cfg, stdout, stderr)
|
||||
default:
|
||||
fmt.Fprintf(stderr, "talk daemon: unbekanntes Unterkommando %q (erlaubt: run, start, stop)\n", sub)
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
func daemonRun(cfg config.Config, stderr io.Writer) int {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
err := daemon.Run(ctx, daemon.Options{
|
||||
Config: cfg,
|
||||
Getenv: getenv,
|
||||
Version: buildVersion(),
|
||||
Log: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk daemon: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func daemonStart(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
if daemonAlive(client, daemonProbeTimeout) {
|
||||
fmt.Fprintf(stdout, "daemon: läuft bereits unter %s\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
if !startDaemon(client, stderr) {
|
||||
return exitFailure
|
||||
}
|
||||
fmt.Fprintf(stdout, "daemon: gestartet unter %s\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func daemonStop(cfg config.Config, stdout, stderr io.Writer) int {
|
||||
client := api.NewClient(cfg.Server.Port)
|
||||
if !daemonAlive(client, daemonProbeTimeout) {
|
||||
fmt.Fprintf(stdout, "daemon: läuft nicht (%s)\n", client.BaseURL)
|
||||
return exitOK
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
|
||||
defer cancel()
|
||||
if err := client.Shutdown(ctx); err != nil {
|
||||
fmt.Fprintf(stderr, "talk daemon stop: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
// Warten, bis der Daemon wirklich weg ist: erst verstummt HTTP, dann
|
||||
// bricht er die laufende Ansage ab und löscht als Letztes die PID-Datei.
|
||||
// Ohne Config-Dir (keine PID-Datei) genügt das HTTP-Ende.
|
||||
pidDir := ""
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
pidDir = dir
|
||||
}
|
||||
deadline := time.Now().Add(daemonStopTimeout)
|
||||
for daemonAlive(client, daemonStartPoll*4) || (pidDir != "" && daemon.ReadPID(pidDir) != 0) {
|
||||
if time.Now().After(deadline) {
|
||||
fmt.Fprintf(stderr, "talk daemon stop: Daemon ist nach %s noch nicht beendet — PID siehe %s im Config-Verzeichnis\n",
|
||||
daemonStopTimeout, daemon.PIDFileName)
|
||||
return exitFailure
|
||||
}
|
||||
time.Sleep(daemonStartPoll)
|
||||
}
|
||||
fmt.Fprintln(stdout, "daemon: gestoppt")
|
||||
return exitOK
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Degraded Mode (Spec: „ohne talkd: Lock-File + sequenzielles Abspielen;
|
||||
// nichts bricht still"). Die Lock-Datei talk.lock im Config-Dir
|
||||
// serialisiert konkurrierende talk-Prozesse; Kollision heißt warten.
|
||||
const (
|
||||
lockFileName = "talk.lock"
|
||||
lockStaleAfter = time.Minute // abgestürzter Prozess: Lock gilt als verwais
|
||||
lockWaitTimeout = 10 * time.Second // Kollision: so lange auf das Lock warten
|
||||
lockPollInterval = 50 * time.Millisecond
|
||||
)
|
||||
|
||||
// sayDegraded ist der lokale Pfad ohne Daemon: mute respektieren (dieselbe
|
||||
// Mute-Datei, die der Daemon liest), über die Lock-Datei serialisieren,
|
||||
// dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im
|
||||
// Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das
|
||||
// Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und
|
||||
// Mute weiter, statt zu scheitern. info ist der Writer für Informations-
|
||||
// Token („muted"): say reicht stdout, wrap stderr durch.
|
||||
func sayDegraded(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
|
||||
}
|
||||
release, err := acquireLock(filepath.Join(dir, lockFileName))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: Lock-Datei nicht erhalten (%v) — spiele ohne Lock (best-effort)\n", err)
|
||||
} else {
|
||||
defer release()
|
||||
}
|
||||
}
|
||||
|
||||
lang := opts.lang
|
||||
if lang == "" {
|
||||
lang = langdetect.Detect(text)
|
||||
}
|
||||
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink, stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
chain, err := provider.NewChain(provider.ChainConfig{
|
||||
Pin: pickProvider(opts.provider, cfg.Provider),
|
||||
Voice: opts.voice,
|
||||
Voices: cfg.Voice,
|
||||
Endpoints: endpointsOf(cfg),
|
||||
Model: cfg.Providers["openai"].Model,
|
||||
Getenv: getenv,
|
||||
Player: sink,
|
||||
Log: stderr,
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
if err := chain.Speak(context.Background(), text, lang); err != nil {
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// acquireLock legt die Lock-Datei exklusiv an (O_CREATE|O_EXCL — reine
|
||||
// stdlib, auf allen Plattformen verfügbar). Ist sie belegt, wird gepollt
|
||||
// (Kollision = warten); wirkt sie verwais (Prozess-Crash), wird sie nach
|
||||
// lockStaleAfter einmalig entfernt. Rückgabe ist die release-Funktion
|
||||
// (Datei schließen und freigeben); ihr Fehler ist bewusst geschluckt —
|
||||
// Best-effort heißt: ein hängengebliebenes Lock blockiert künftige Läufe
|
||||
// nur bis zur Stale-Frist.
|
||||
func acquireLock(path string) (release func(), err error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deadline := time.Now().Add(lockWaitTimeout)
|
||||
for {
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
return func() {
|
||||
f.Close()
|
||||
_ = os.Remove(path)
|
||||
}, nil
|
||||
}
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, err
|
||||
}
|
||||
if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > lockStaleAfter {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
if !time.Now().Before(deadline) {
|
||||
return nil, fmt.Errorf("Lock-Datei %s bleibt belegt", path)
|
||||
}
|
||||
time.Sleep(lockPollInterval)
|
||||
}
|
||||
}
|
||||
+23
-11
@@ -1,17 +1,20 @@
|
||||
// talk ist die CLI für Sprach-Ausgaben (talk 2.0).
|
||||
// talk ist das eine Binary von talk 2.0: CLI und Daemon.
|
||||
//
|
||||
// Dieser Stand (Task 07): say (und der Default-Aufruf `talk "Text"`) reicht
|
||||
// Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur
|
||||
// Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded
|
||||
// Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/
|
||||
// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- <Befehl>`
|
||||
// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code
|
||||
// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die
|
||||
// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den
|
||||
// Tasks 08–09.
|
||||
// say (und der Default-Aufruf `talk "Text"`) reicht Ansagen in den Daemon
|
||||
// ein — non-blocking, --wait pollt bis zur Fertigstellung, --now springt
|
||||
// vor. Läuft kein Daemon, startet talk ihn selbst im Hintergrund
|
||||
// (daemon.autostart); gelingt auch das nicht, spricht talk direkt (ohne
|
||||
// Warteschlange, mit Hinweis) — nichts bricht still. `talk daemon run`
|
||||
// betreibt den Daemon im Vordergrund, start/stop steuern ihn explizit.
|
||||
// mute/unmute/status/cancel sind Daemon-Aufrufe mit plain Output;
|
||||
// `talk wrap -- <Befehl>` führt Befehle aus und sagt das Ergebnis an. Die
|
||||
// übrigen Befehle sind Stubs.
|
||||
package main
|
||||
|
||||
import "os"
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
||||
//
|
||||
@@ -23,6 +26,15 @@ var (
|
||||
commit = ""
|
||||
)
|
||||
|
||||
// buildVersion rendert Version plus Commit-Hash in Klammern, falls injiziert
|
||||
// — der Wert, den der Daemon in /status und MCP serverInfo meldet.
|
||||
func buildVersion() string {
|
||||
if commit == "" {
|
||||
return version
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", version, commit)
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
+51
-32
@@ -1,13 +1,16 @@
|
||||
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,
|
||||
@@ -27,13 +30,11 @@ type sayOptions struct {
|
||||
}
|
||||
|
||||
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
||||
// und validieren, dann Daemon-Anbindung (Task 04): Läuft ein talkd
|
||||
// (erkannt via GET /status), wird die Ansage dort eingereiht — non-blocking
|
||||
// als Default, --wait pollt bis zur Fertigstellung, --now springt vor,
|
||||
// Priorität/Provider/Voice/Lang werden durchgereicht. Antwortet kein
|
||||
// Daemon, fällt talk in den Degraded Mode (Lock-Datei, sequenziell lokal,
|
||||
// mute respektiert — sayDegraded). Die Provider-Kette selbst ist in beiden
|
||||
// Pfarden dieselbe (internal/provider, unverändert).
|
||||
// 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 == "" {
|
||||
@@ -64,13 +65,52 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.
|
||||
fmt.Fprintf(stderr, "talk: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
return announce(cfg, text, opts, stdout, stderr)
|
||||
}
|
||||
|
||||
if code, handled := sayViaDaemon(cfg, text, opts, stdout, stderr); handled {
|
||||
return code
|
||||
// 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)
|
||||
}
|
||||
|
||||
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
|
||||
return sayDegraded(cfg, text, opts, stdout, 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 |
|
||||
@@ -88,27 +128,6 @@ func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt
|
||||
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
|
||||
func pickProvider(flagValue, cfgValue string) string {
|
||||
if flagValue != "" {
|
||||
return flagValue
|
||||
}
|
||||
return cfgValue
|
||||
}
|
||||
|
||||
// endpointsOf überführt die Provider-Einstellungen der Config in die
|
||||
// Endpunkt-Map der Kette.
|
||||
func endpointsOf(cfg config.Config) map[string]string {
|
||||
endpoints := make(map[string]string, len(cfg.Providers))
|
||||
for name, settings := range cfg.Providers {
|
||||
if settings.Endpoint != "" {
|
||||
endpoints[name] = settings.Endpoint
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -16,14 +16,16 @@ import (
|
||||
// Kette enthält edge, und kein Test darf je den echten Microsoft-Endpunkt
|
||||
// anfassen. Dasselbe gilt seit Task 04 für TALK_PORT: Die Daemon-Erkennung
|
||||
// von say zeigt auf einen toten Port, damit die Tests hier Deterministisch
|
||||
// den Degraded Mode laufen (und nie einen zufällig laufenden echten talkd
|
||||
// treffen). Wer einen dieser Ports bewusst anders braucht, überschreibt
|
||||
// den Direkt-Pfad laufen (und nie einen zufällig laufenden echten Daemon
|
||||
// treffen); TALK_AUTOSTART=false verhindert, dass ein Test das Test-Binary
|
||||
// selbst als Daemon spawnt. Wer einen dieser Ports bewusst anders braucht, überschreibt
|
||||
// den Key.
|
||||
func setGetenv(t *testing.T, kv map[string]string) {
|
||||
t.Helper()
|
||||
base := map[string]string{
|
||||
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
|
||||
"TALK_PORT": "1",
|
||||
"TALK_AUTOSTART": "false", // In-Prozess-Tests dürfen nie das Test-Binary als Daemon starten
|
||||
}
|
||||
for k, v := range kv {
|
||||
base[k] = v
|
||||
|
||||
+5
-16
@@ -22,8 +22,8 @@ const exitNotFound = 127
|
||||
// sagt danach das Ergebnis an: „<label> erfolgreich. Dauer: X." bzw.
|
||||
// „<label> fehlgeschlagen, Code N." (Label = config `wrap.label` oder der
|
||||
// Basisname des Befehls; lokalisiert de/en nach langdetect des Labels).
|
||||
// Die Ansage läuft über denselben Weg wie say: Daemon-Pfad (enqueue,
|
||||
// non-blocking; --wait respektiert), ohne Daemon Degraded Mode. wrap erbt
|
||||
// Die Ansage läuft über denselben Weg wie say: Daemon (enqueue, non-
|
||||
// blocking; --wait respektiert; Autostart), ohne Daemon direkt. wrap erbt
|
||||
// die say-Flags und reicht sie an die Ansage durch.
|
||||
//
|
||||
// Exit-Code-Politik: wrap exitet IMMER mit dem Code des Befehls (talk-
|
||||
@@ -71,9 +71,8 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
||||
return code
|
||||
}
|
||||
|
||||
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say:
|
||||
// Daemon-Pfad (enqueue, non-blocking; --wait respektiert), ohne Daemon
|
||||
// Degraded Mode (Lock-Datei, sequenziell lokal). Der Exit-Code der
|
||||
// announceWrap bringt die wrap-Ansage unter — über denselben Weg wie say
|
||||
// (announce: Daemon, ggf. Autostart, sonst direkt). Der Exit-Code der
|
||||
// Ansage-Maschinerie verwirft NIE den Code des Befehls: Scheitert sie,
|
||||
// gibt es eine Warning auf stderr (talk-Fehler ≠ Befehls-Fehler).
|
||||
// Informationszeilen der Ansage (Item-ID, muted, discarded) gehen bei wrap
|
||||
@@ -81,19 +80,9 @@ func runWrap(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io
|
||||
// Pipes und Redirects des Befehls nicht durch talk-Ausgaben verunreinigt
|
||||
// werden.
|
||||
func announceWrap(cfg config.Config, text string, opts *sayOptions, commandCode int, stderr io.Writer) {
|
||||
warn := func() {
|
||||
if announce(cfg, text, opts, stderr, stderr) != exitOK {
|
||||
fmt.Fprintf(stderr, "talk wrap: Ansage fehlgeschlagen — Exit-Code %d des Befehls bleibt\n", commandCode)
|
||||
}
|
||||
if code, handled := sayViaDaemon(cfg, text, opts, stderr, stderr); handled {
|
||||
if code != 0 {
|
||||
warn()
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(stderr, "talk: Daemon nicht erreichbar — Degraded Mode (lokal, sequenziell über Lock-Datei)")
|
||||
if sayDegraded(cfg, text, opts, stderr, stderr) != exitOK {
|
||||
warn()
|
||||
}
|
||||
}
|
||||
|
||||
// execChild startet argv als Kindprozess und wartet auf sein Ende.
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
// talkd ist der Daemon für talk 2.0: HTTP-API auf 127.0.0.1 (Default-Port
|
||||
// 7800, server.port/TALK_PORT), serialisierte Ansage-Queue mit Prioritäten
|
||||
// und now-Sprung, persistenter Mute-State (Datei mute im Config-Dir) —
|
||||
// und seit Task 05 das Anti-Spam-Gesetz: Rate-Limit (queue.rate_limit,
|
||||
// Default 1 Ansage / 30 s; low über Budget wird verworfen, normal parkt,
|
||||
// high darf mit queue.allow_high_burst verstoßen) plus Ruhezeiten
|
||||
// (quiet_hours/quiet_mode; now:true bricht bewusst aus beiden aus).
|
||||
// Task 06 ergänzt die CORS-Tür für POST /speak (server.allowed_origins /
|
||||
// TALK_ALLOWED_ORIGINS; leer = kein CORS) und den MCP-Endpoint POST /mcp
|
||||
// (Streamable HTTP, ein speak-Tool).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/api"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/audio"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/langdetect"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/provider"
|
||||
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
||||
)
|
||||
|
||||
// Build-Informationen. Werden beim Release per -ldflags injiziert (gleicher
|
||||
// Vertrag wie cmd/talk, siehe .goreleaser.yaml in Task 11). Default: dev.
|
||||
var (
|
||||
version = "dev"
|
||||
commit = ""
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags)
|
||||
log.SetPrefix("talkd: ")
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// run startet den Daemon und kehrt erst beim Herunterfahren (SIGINT/
|
||||
// SIGTERM) zurück. Abbrüche der laufenden Ansage beim Shutdown sind
|
||||
// beabsichtigt: talkd soll auf Signale prompt reagieren.
|
||||
func run() error {
|
||||
getenv := os.Getenv
|
||||
|
||||
cfg, err := config.Load(config.Options{Getenv: getenv})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Audio-Sink (audio.sink; "null" verwirft tonlos — CI/Server).
|
||||
// Ketten-, Queue- und Sink-Protokoll laufen über denselben Logger.
|
||||
lw := lineLogger{}
|
||||
sink, err := audio.NewSink(cfg.Audio.Sink, lw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Basiskette nur für die /status-Auflösung „aktiver Provider" (erste
|
||||
// verfügbare Stufe; Available ist billig — Key-/Endpunkt-Binary-Checks
|
||||
// ohne Netz-Call). Das eigentliche Abspielen baut pro Item eine eigene
|
||||
// Kette (Pin/Voice je Anfrage) — die Kette aus internal/provider wird
|
||||
// unverändert wiederverwendet.
|
||||
baseChain, err := provider.NewChain(chainConfigFor(cfg, "", "", getenv, nil))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state := &providerState{}
|
||||
speak := func(ctx context.Context, item queue.Item) error {
|
||||
state.set(resolveProviderName(baseChain, item))
|
||||
lang := item.Lang
|
||||
if lang == "" {
|
||||
lang = langdetect.Detect(item.Text)
|
||||
}
|
||||
chain, err := provider.NewChain(chainConfigFor(cfg, item.Provider, item.Voice, getenv, sink))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return chain.Speak(ctx, item.Text, lang)
|
||||
}
|
||||
|
||||
// Anti-Spam-Gesetz (Task 05): Rate-Limit + High-Burst + Ruhezeiten.
|
||||
// config.Load hat quiet_hours bereits validiert — das Parsen hier kann
|
||||
// praktisch nicht scheitern, aber ein Fehler darf nie still werden.
|
||||
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
laws := queue.Laws{
|
||||
RateLimit: cfg.Queue.RateLimit,
|
||||
AllowHighBurst: cfg.Queue.AllowHighBurst,
|
||||
Quiet: quiet,
|
||||
QuietMode: cfg.QuietMode,
|
||||
}
|
||||
|
||||
q := queue.New(laws, speak, lw)
|
||||
|
||||
// Persistenter Mute-State (Datei mute im Config-Dir). Ohne Config-Dir
|
||||
// läuft der Daemon ohne Mute weiter (dokumentierte Warnung) statt zu
|
||||
// scheitern — alles andere an talk darf ja auch ohne Config-Datei.
|
||||
var mutePath string
|
||||
if dir, err := config.Dir(getenv); err == nil {
|
||||
mutePath = filepath.Join(dir, queue.MuteFileName)
|
||||
} else {
|
||||
log.Printf("warnung: Config-Dir nicht bestimmbar — Mute-State deaktiviert (%v)", err)
|
||||
}
|
||||
mute := queue.MuteStore{Path: mutePath}
|
||||
|
||||
handler := api.NewHandler(api.HandlerConfig{
|
||||
Queue: q,
|
||||
Mute: mute,
|
||||
Version: versionString(),
|
||||
KnownProviders: provider.Names(),
|
||||
AllowedOrigins: cfg.Server.AllowedOrigins,
|
||||
Provider: func() string {
|
||||
if cur, ok := q.Current(); ok {
|
||||
return resolveProviderName(baseChain, cur)
|
||||
}
|
||||
if last := state.get(); last != "" {
|
||||
return last
|
||||
}
|
||||
return resolveProviderName(baseChain, queue.Item{})
|
||||
},
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Port belegen (%s): %w — läuft bereits ein talkd?", addr, err)
|
||||
}
|
||||
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- srv.Serve(ln) }()
|
||||
corsLog := "zu (kein CORS)"
|
||||
if len(cfg.Server.AllowedOrigins) > 0 {
|
||||
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
|
||||
}
|
||||
log.Printf("listening on http://%s (version %s, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, ruhezeit=%q modus %s, cors=%s, mcp=/mcp)",
|
||||
addr, versionString(), sink.Name(), mute.Muted(),
|
||||
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.QuietHours, cfg.QuietMode, corsLog)
|
||||
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
q.Close()
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
case s := <-sig:
|
||||
log.Printf("Signal %v empfangen — fahre herunter (laufende Ansage wird abgebrochen)", s)
|
||||
}
|
||||
|
||||
// Reihenfolge: erst HTTP dicht (keine neuen Anfragen), dann die Queue
|
||||
// schließen (laufende Ansage abbrechen, Worker beenden und darauf
|
||||
// warten). So läuft nie eine Ansage nach dem Daemon-Ende weiter.
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("http shutdown: %v", err)
|
||||
}
|
||||
q.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// versionString rendert die Daemon-Version für /status.
|
||||
func versionString() string {
|
||||
if commit == "" {
|
||||
return version
|
||||
}
|
||||
return fmt.Sprintf("%s (%s)", version, commit)
|
||||
}
|
||||
|
||||
// chainConfigFor überführt Config + Item-Pins in die ChainConfig — dieselbe
|
||||
// Zusammensetzung wie im CLI-Pfad (cmd/talk/say.go), nur dass der Daemon
|
||||
// sie pro Ansage baut (Pin/Voice sind Anfrage-Properties). player ist nur
|
||||
// für die Abspiel-Kette gesetzt (die Basiskette braucht ihn nicht).
|
||||
func chainConfigFor(cfg config.Config, pin, voice string, getenv func(string) string, player provider.Player) provider.ChainConfig {
|
||||
return provider.ChainConfig{
|
||||
Pin: pickProvider(pin, cfg.Provider),
|
||||
Voice: voice,
|
||||
Voices: cfg.Voice,
|
||||
Endpoints: endpointsOf(cfg),
|
||||
Model: cfg.Providers["openai"].Model,
|
||||
Getenv: getenv,
|
||||
Player: player,
|
||||
Log: lineLogger{},
|
||||
}
|
||||
}
|
||||
|
||||
// pickProvider löst den Provider-Pin auf: Anfrage-/Flag-Pin schlägt
|
||||
// `provider = "…"` aus der config.toml; leer = komplette Default-Kette.
|
||||
func pickProvider(pin, cfgValue string) string {
|
||||
if pin != "" {
|
||||
return pin
|
||||
}
|
||||
return cfgValue
|
||||
}
|
||||
|
||||
// endpointsOf überführt die Provider-Einstellungen der Config in die
|
||||
// Endpunkt-Map der Kette.
|
||||
func endpointsOf(cfg config.Config) map[string]string {
|
||||
endpoints := make(map[string]string, len(cfg.Providers))
|
||||
for name, settings := range cfg.Providers {
|
||||
if settings.Endpoint != "" {
|
||||
endpoints[name] = settings.Endpoint
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
// resolveProviderName löst den „aktiven Provider" für /status auf: Pin des
|
||||
// Items, sonst erste verfügbare Stufe der Basiskette. Available() ist
|
||||
// bewusst billig (Key-/Binary-Prüfung ohne Netz-Call), deshalb darf das
|
||||
// /status on demand aufrufen.
|
||||
func resolveProviderName(chain *provider.Chain, item queue.Item) string {
|
||||
if item.Provider != "" {
|
||||
return item.Provider
|
||||
}
|
||||
for _, p := range chain.Tiers() {
|
||||
if p.Available(context.Background()) {
|
||||
return p.Name()
|
||||
}
|
||||
}
|
||||
return "keine"
|
||||
}
|
||||
|
||||
// providerState merkt sich den zuletzt aktiven Provider (für /status im
|
||||
// Leerlauf). Geschrieben wird ausschließlich vom Queue-Worker, gelesen von
|
||||
// HTTP-Handlern — daher der eigene kleine Mutex.
|
||||
type providerState struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *providerState) set(name string) {
|
||||
s.mu.Lock()
|
||||
s.name = name
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *providerState) get() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.name
|
||||
}
|
||||
|
||||
// lineLogger reicht Zeilen an den Standard-Logger weiter (Zeitstempel +
|
||||
// talkd-Präfix), statt sie roh zu schreiben: Provider-Kette, Queue und
|
||||
// null-Sink schreiben zeilenweise, so entsteht ein einheitliches Log.
|
||||
type lineLogger struct{}
|
||||
|
||||
func (lineLogger) Write(p []byte) (int, error) {
|
||||
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
|
||||
if line != "" {
|
||||
log.Print(line)
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
Reference in new issue
Block a user