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:). 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) }