// Package apiclient is the daemon's ONLY channel back into the Python app. // The daemon owns long-lived I/O (gateway socket, timers) and nothing else: // grading depends on polars + the parquet cache, and the slash-command path // deliberately shares one grade builder with the API so the two never drift. // Reimplementing any of that here would reintroduce exactly that drift, so // everything data-shaped is a POST to the backend's /internal/* routes. package apiclient import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // defaultTimeout bounds every callback. Grading does synchronous parquet/DB // reads on the Python side (a cold city can take a while), so be generous — // but never infinite: a hung backend must not wedge the gateway's event loop. const defaultTimeout = 60 * time.Second // errBodyLimit caps how much of an error response we echo into logs; enough // to see a traceback's headline without dumping megabytes. const errBodyLimit = 512 // Client calls the backend's internal-only routes. The token is defence in // depth — Caddy never routes /internal/* to the backend in the first place — // but it is still required on every request so a misrouted or LAN-local call // cannot hit these endpoints unauthenticated. type Client struct { base string token string http *http.Client } // New returns a Client for the backend at base (e.g. http://web:8137), // authenticating with token. func New(base, token string) *Client { return &Client{ base: strings.TrimRight(base, "/"), token: token, http: &http.Client{Timeout: defaultTimeout}, } } // post sends body to path and returns the raw response body on 2xx. func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessage, error) { payload, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("apiclient: encode %s body: %w", path, err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+path, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("apiclient: build %s request: %w", path, err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Thermograph-Internal-Token", c.token) resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("apiclient: POST %s: %w", path, err) } defer resp.Body.Close() // Read one byte past the limit so the truncation marker only appears when // something was actually cut. raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, fmt.Errorf("apiclient: POST %s: read body: %w", path, err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { snippet := raw truncated := "" if len(snippet) > errBodyLimit { snippet = snippet[:errBodyLimit] truncated = "... (truncated)" } return nil, fmt.Errorf("apiclient: POST %s: status %d: %s%s", path, resp.StatusCode, snippet, truncated) } return json.RawMessage(raw), nil } // Grade asks Python to grade a city query and returns the gateway-ready // Discord message JSON VERBATIM. The raw bytes are relayed to Discord without // parsing or reshaping — that verbatim relay is what keeps Go out of the // grading/embed contract: any change to the message shape lives entirely on // the Python side. func (c *Client) Grade(ctx context.Context, query string) (json.RawMessage, error) { return c.post(ctx, "/internal/discord/grade", map[string]string{"query": query}) } // WarmCities triggers the warm-cities job (the Python side owns what "warm" // means and which cities are curated). func (c *Client) WarmCities(ctx context.Context) error { _, err := c.post(ctx, "/internal/jobs/warm-cities", map[string]string{}) return err } // IndexNow triggers the IndexNow check-and-submit job; a no-op on the Python // side when the indexable URL set has not changed. func (c *Client) IndexNow(ctx context.Context) error { _, err := c.post(ctx, "/internal/jobs/indexnow", map[string]string{}) return err }