Files
talk/internal/api/client.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

129 lines
3.5 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Client ist der HTTP-Client der CLI für den Daemon auf 127.0.0.1 —
// dieselbe Vertragsschnittstelle, die server.go bedient.
type Client struct {
// BaseURL ist die Daemon-Adresse (http://127.0.0.1:<port>).
BaseURL string
// HTTP ist der verwendete Client; nil wird von NewClient gesetzt.
HTTP *http.Client
}
// NewClient baut den Client für einen Daemon-Port. Zeit-Budgets steuert der
// Aufrufer pro Aufruf über den Context (Daemon-Erkennung kurz, /speak
// großzügiger); das Client-Timeout ist nur die harte Decke.
func NewClient(port int) *Client {
return &Client{
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
HTTP: &http.Client{Timeout: 10 * time.Second},
}
}
// HTTPError ist eine Daemon-Antwort mit unerwartetem Status-Code (z. B.
// 400 Validierung). Netz-Fehler (Daemon tot) sind dagegen reguläre
// *url.Error-Werte — die CLI unterscheidet danach: HTTP-Fehler anzeigen,
// Netz-Fehler → Degraded Mode.
type HTTPError struct {
Status int
Body string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
}
// Status ruft GET /status (Daemon-Erkennung, --wait-Polling).
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
var st StatusResponse
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
return nil, err
}
return &st, nil
}
// Speak ruft POST /speak (enqueue).
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
var resp SpeakResponse
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// Cancel ruft POST /cancel; Rückgabe ist die Zahl der abgebrochenen
// Ansagen.
func (c *Client) Cancel(ctx context.Context) (int, error) {
var resp CancelResponse
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
return 0, err
}
return resp.Cancelled, nil
}
// SetMute ruft POST /mute (muted=true) bzw. POST /unmute (muted=false).
func (c *Client) SetMute(ctx context.Context, muted bool) error {
path := "/mute"
if !muted {
path = "/unmute"
}
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
}
// Shutdown ruft POST /shutdown (`talk daemon stop`).
func (c *Client) Shutdown(ctx context.Context) error {
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
}
// do ist der gemeinsame Kern: Request bauen, senden, Status prüfen,
// dekodieren. Netz-Fehler kommen ungefiltert zurück (Aufrufer unterscheidet
// über errors.As(&HTTPError)).
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
var rd io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rd = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return err
}
if resp.StatusCode != wantStatus {
return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))}
}
if out == nil {
return nil
}
return json.Unmarshal(data, out)
}