feat(cli): say über den Daemon, Steuer-Befehle als Daemon-Aufrufe, Degraded Mode

- say/Default erkennt den Daemon via GET /status: enqueue + sofort zurück
  (stdout = Item-ID), --wait pollt bis zur Fertigstellung, --now springt vor
- mute/unmute/status/cancel mit plain Output; mute/unmute schreiben ohne
  Daemon die Mute-Datei direkt (gleiche Quelle, wirkt sofort im Daemon)
- Degraded Mode: Lock-Datei talk.lock im Config-Dir (Kollision = warten,
  verwaist nach 60 s), sequenzielles lokales Abspielen, mute respektiert
- Handler-Signatur der Befehlstabelle erhält stdout (say/Steuer-Befehle
  geben Ergebnisse aus); --priority wird validiert
This commit is contained in:
m3ta-chiron committed 2026-10-18 17:10:36 +02:00
1 parent d09aff6874
commit e1a3981d17
9 files changed
+524 -81

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
package main
import (
"context"
"fmt"
"io"
"path/filepath"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
)
// Die Steuer-Befehle (Task 04) sind Daemon-Aufrufe mit plain Output:
// stdout führt eine maschinenlesbare Zeile (muted/unmuted, cancelled: N
// bzw. den Status-Block), stderr menschliche Hinweise.
// 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.
func runMute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(true, stdout, stderr)
}
// runUnmute implementiert `talk unmute` (analog zu runMute).
func runUnmute(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
return setMute(false, stdout, stderr)
}
// setMute ist der gemeinsame Kern von mute/unmute.
func setMute(muted bool, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
client := api.NewClient(cfg.Server.Port)
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
if err := client.SetMute(ctx, muted); err == nil {
printMuteState(muted, stdout)
return exitOK
}
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar (%v) — Mute-Datei wird direkt geschrieben\n", err)
dir, err := config.Dir(getenv)
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
if err := (queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}).Set(muted); err != nil {
fmt.Fprintf(stderr, "talk: Mute-Datei schreiben: %v\n", err)
return exitFailure
}
printMuteState(muted, stdout)
return exitOK
}
// printMuteState schreibt den Zustand als plain Token auf stdout.
func printMuteState(muted bool, stdout io.Writer) {
if muted {
fmt.Fprintln(stdout, "muted")
} else {
fmt.Fprintln(stdout, "unmuted")
}
}
// runStatus implementiert `talk status`: plain Block aus GET /status —
// Daemon-Adresse + Version, Queue-Länge, aktuelles Item, Mute-State,
// aktiver Provider. Ohne Daemon: klarer Fehler (status ist eine Anfrage an
// den Daemon; der Degraded Mode hat keinen Status).
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 {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
client := api.NewClient(cfg.Server.Port)
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
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)
return exitFailure
}
fmt.Fprintf(stdout, "daemon: %s (version %s)\n", client.BaseURL, st.Version)
fmt.Fprintf(stdout, "queue: %d\n", st.Queue)
if st.Current != nil {
fmt.Fprintf(stdout, "current: %s %q (Priorität %s)\n", st.Current.ID, st.Current.Text, st.Current.Priority)
} else {
fmt.Fprintln(stdout, "current: –")
}
muteState := "aus"
if st.Muted {
muteState = "an"
}
fmt.Fprintf(stdout, "mute: %s\n", muteState)
fmt.Fprintf(stdout, "provider: %s\n", st.Provider)
return exitOK
}
// runCancel implementiert `talk cancel`: POST /cancel — bricht die laufende
// Ansage ab und leert die Queue; stdout meldet die Zahl der abgebrochenen
// Ansagen. Ohne Daemon gibt es nichts, was talk abbrechen könnte.
func runCancel(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
cfg, err := config.Load(config.Options{Getenv: getenv})
if err != nil {
fmt.Fprintf(stderr, "talk: %v\n", err)
return exitFailure
}
client := api.NewClient(cfg.Server.Port)
ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout)
defer cancel()
n, err := client.Cancel(ctx)
if err != nil {
fmt.Fprintf(stderr, "talk: Daemon nicht erreichbar unter %s — nichts zu abbrechen\n", client.BaseURL)
return exitFailure
}
fmt.Fprintf(stdout, "cancelled: %d\n", n)
return exitOK
}