2026-10-18 17:10:26 +02:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Client is the CLI's HTTP client for the daemon on 127.0.0.1; the same
|
|
|
|
|
// contract that server.go serves.
|
2026-10-18 17:10:26 +02:00
|
|
|
type Client struct {
|
2026-10-24 11:15:35 +02:00
|
|
|
// BaseURL is the daemon address (http://127.0.0.1:<port>).
|
2026-10-18 17:10:26 +02:00
|
|
|
BaseURL string
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// HTTP is the client in use; NewClient sets it when nil.
|
2026-10-18 17:10:26 +02:00
|
|
|
HTTP *http.Client
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// NewClient builds the client for a daemon port. The caller controls time
|
|
|
|
|
// budgets per call via the context (daemon detection short, /speak more
|
|
|
|
|
// generous); the client timeout is only the hard ceiling.
|
2026-10-18 17:10:26 +02:00
|
|
|
func NewClient(port int) *Client {
|
|
|
|
|
return &Client{
|
|
|
|
|
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
|
|
|
|
HTTP: &http.Client{Timeout: 10 * time.Second},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// HTTPError is a daemon response with an unexpected status code (e.g. 400
|
|
|
|
|
// validation). Network errors (daemon dead) are regular *url.Error values
|
|
|
|
|
// instead; the CLI distinguishes by that: show HTTP errors, fall into
|
|
|
|
|
// degraded mode on network errors.
|
2026-10-18 17:10:26 +02:00
|
|
|
type HTTPError struct {
|
|
|
|
|
Status int
|
|
|
|
|
Body string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (e *HTTPError) Error() string {
|
|
|
|
|
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Status calls GET /status (daemon detection, --wait polling).
|
2026-10-18 17:10:26 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Speak calls POST /speak (enqueue).
|
2026-10-18 17:10:26 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Cancel calls POST /cancel; returns the number of cancelled announcements.
|
2026-10-18 17:10:26 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:14:38 +02:00
|
|
|
// CancelItem calls POST /cancel-item; it reports whether the id was the
|
|
|
|
|
// playing announcement.
|
|
|
|
|
func (c *Client) CancelItem(ctx context.Context, id string) (bool, error) {
|
|
|
|
|
var resp CancelItemResponse
|
|
|
|
|
if err := c.do(ctx, http.MethodPost, "/cancel-item", CancelItemRequest{ID: id}, http.StatusOK, &resp); err != nil {
|
|
|
|
|
return false, err
|
|
|
|
|
}
|
|
|
|
|
return resp.Cancelled, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// SetMute calls POST /mute (muted=true) or POST /unmute (muted=false).
|
2026-10-18 17:10:26 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Shutdown calls POST /shutdown (`talk daemon stop`).
|
2026-10-24 09:49:16 +02:00
|
|
|
func (c *Client) Shutdown(ctx context.Context) error {
|
|
|
|
|
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// do is the shared core: build the request, send it, check the status,
|
|
|
|
|
// decode. Network errors are returned unfiltered (the caller distinguishes
|
|
|
|
|
// via errors.As(&HTTPError)).
|
2026-10-18 17:10:26 +02:00
|
|
|
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)
|
|
|
|
|
}
|