Zwei Löcher im Anti-Spam-Gesetz, die ein Prompt trivial nutzt: Ein Agent
kann 64 KB Text einreichen (ElevenLabs stellt die Rechnung, die Ansage
dauert Minuten), und geparkte normal-Ansagen spielen unbegrenzt spät —
„Build fertig" zweieinhalb Minuten nach dem Build ist keine Nachricht.
- queue.max_text (Default 400 Zeichen, Env TALK_MAX_TEXT): längere Texte
werden beim Eintritt an einer Wortgrenze gekürzt und so gesprochen; die
/speak-Antwort meldet truncated, die CLI warnt auf stderr, das MCP-
Ergebnis sagt es im Text. Runen, nicht Bytes.
- queue.max_age (Default 2m, Env TALK_MAX_AGE): Items, die beim
Fälligwerden länger gewartet haben, werden verworfen (discarded
"expired", Protokoll nennt Wartezeit und Grenze). now:true ist
ausgenommen. Rang: now > veraltet > Ruhezeit > Rate-Limit.
- /status trägt "limits": {max_text, max_age}; `talk status` zeigt sie.
- Negative Werte sind Config-Fehler; 0 = unbegrenzt.
Tests: truncateText-Tabelle (Wortgrenze, Satzzeichen, Runen), Enqueue-
Kürzung, decide-Vorrang inkl. now-Ausnahme, geparktes Item verfällt;
Config-Schichten; Prozessgrenze: Provider bekommt die gekürzte Fassung,
dritte von drei Stau-Ansagen wird nie gesprochen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
182 lines
6.1 KiB
Go
182 lines
6.1 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"path/filepath"
|
||
"time"
|
||
|
||
"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. 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)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Daemon-Aufruf fehlgeschlagen (meist: nicht erreichbar): Datei direkt
|
||
// schreiben — der Daemon (und der Degraded Mode) liest sie bei jeder
|
||
// Ansage neu.
|
||
fmt.Fprintf(stderr, "talk: Daemon-Aufruf fehlgeschlagen (%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, 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 {
|
||
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(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
|
||
}
|
||
|
||
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)
|
||
printLawState(stdout, st)
|
||
return exitOK
|
||
}
|
||
|
||
// printLawState schreibt den Gesetz-State (Task 05) aus GET /status:
|
||
// Rate-Limit mit Burst-Recht und nächstem Slot sowie das Ruhezeit-Fenster.
|
||
func printLawState(stdout io.Writer, st *api.StatusResponse) {
|
||
if st.RateLimit != nil {
|
||
interval := st.RateLimit.Interval
|
||
if interval == "0s" {
|
||
interval = "aus (unbegrenzt)"
|
||
}
|
||
burst := "verboten"
|
||
if st.RateLimit.AllowHighBurst {
|
||
burst = "erlaubt"
|
||
}
|
||
line := fmt.Sprintf("rate-limit: %s (high-burst %s)", interval, burst)
|
||
if st.RateLimit.NextSlot != nil {
|
||
if ts, err := time.Parse(time.RFC3339, *st.RateLimit.NextSlot); err == nil {
|
||
line += fmt.Sprintf(", nächster slot %s", ts.Format("15:04:05"))
|
||
}
|
||
}
|
||
fmt.Fprintln(stdout, line)
|
||
}
|
||
if st.QuietHours != nil {
|
||
window := st.QuietHours.Window
|
||
if window == "" {
|
||
window = "aus"
|
||
}
|
||
state := "inaktiv"
|
||
if st.QuietHours.Active {
|
||
state = "aktiv"
|
||
if st.QuietHours.Until != nil {
|
||
if ts, err := time.Parse(time.RFC3339, *st.QuietHours.Until); err == nil {
|
||
state += " bis " + ts.Format("15:04")
|
||
}
|
||
}
|
||
}
|
||
fmt.Fprintf(stdout, "ruhezeit: %s (modus %s, %s)\n", window, st.QuietHours.Mode, state)
|
||
}
|
||
if st.Limits != nil {
|
||
text := fmt.Sprintf("max. %d Zeichen", st.Limits.MaxText)
|
||
if st.Limits.MaxText == 0 {
|
||
text = "Länge unbegrenzt"
|
||
}
|
||
age := "max. Wartezeit " + st.Limits.MaxAge
|
||
if st.Limits.MaxAge == "0s" {
|
||
age = "Wartezeit unbegrenzt"
|
||
}
|
||
fmt.Fprintf(stdout, "limits: %s, %s\n", text, age)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|