// Package aw is a thin ActivityWatch REST client: it creates buckets and reads // and writes events. It holds no Keel concepts, so it stays a leaf package. package aw import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "strings" "time" ) // Event is one ActivityWatch event. Duration is seconds; for Keel's discrete // derived events it is 0. type Event struct { Timestamp time.Time Duration float64 Data map[string]any } // Client talks to an aw-server over its /api/0 REST surface. type Client struct { baseURL string hc *http.Client } // New builds a client for the given base URL (e.g. http://localhost:5600). func New(baseURL string) *Client { return &Client{ baseURL: strings.TrimRight(baseURL, "/"), hc: &http.Client{Timeout: 5 * time.Second}, } } // EnsureBucket creates the bucket if absent. It is idempotent: an already-exists // response (304) is success. func (c *Client) EnsureBucket(ctx context.Context, id, eventType, client string) error { body, _ := json.Marshal(map[string]string{ "client": client, "type": eventType, "hostname": hostname(), }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/0/buckets/"+id, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK, http.StatusCreated, http.StatusNoContent, http.StatusNotModified: return nil default: return fmt.Errorf("aw: create bucket %s: status %d", id, resp.StatusCode) } } func hostname() string { if h, err := os.Hostname(); err == nil && h != "" { return h } return "keel" } type eventJSON struct { Timestamp time.Time `json:"timestamp"` Duration float64 `json:"duration"` Data map[string]any `json:"data"` } // Insert posts a single event to the bucket. The AW events endpoint takes a // JSON array, so the event is wrapped in a one-element list. func (c *Client) Insert(ctx context.Context, bucketID string, e Event) error { body, _ := json.Marshal([]eventJSON{{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data}}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/0/buckets/"+bucketID+"/events", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.hc.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("aw: insert into %s: status %d", bucketID, resp.StatusCode) } return nil } // Recent returns up to limit events from the bucket, newest first (the AW // default ordering for the events endpoint). func (c *Client) Recent(ctx context.Context, bucketID string, limit int) ([]Event, error) { endpoint := fmt.Sprintf("%s/api/0/buckets/%s/events?limit=%d", c.baseURL, bucketID, limit) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := c.hc.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("aw: read %s: status %d", bucketID, resp.StatusCode) } var raw []eventJSON if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return nil, err } out := make([]Event, len(raw)) for i, e := range raw { out[i] = Event{Timestamp: e.Timestamp, Duration: e.Duration, Data: e.Data} } return out, nil }