Files
talk/cmd/talk/control.go
T
m3ta-chironandClaude Fable 5.1 97650ad0ec feat(queue): Gesetze max_text und max_age — gekürzte Langtexte, verfallene Stau-Ansagen
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>
2026-10-24 09:54:40 +02:00

182 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}