package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strconv" "time" "bytes" "golang.org/x/time/rate" ) const baseURL = "https://api.solidarity.tech/v1" type Client struct { key string dryRun bool http *http.Client limiter *rate.Limiter } func NewClient(key string, dryRun bool) *Client { return &Client{ key: key, dryRun: dryRun, http: &http.Client{Timeout: 30 * time.Second}, // 60 requests per 30 seconds: 2/sec sustained, burst of 60. limiter: rate.NewLimiter(2, 60), } } // do applies the rate limit, retries once on 429, and returns the raw body. func (c *Client) do(ctx context.Context, method, path string, body io.Reader) ([]byte, error) { if err := c.limiter.Wait(ctx); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, method, baseURL+path, body) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+c.key) req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.http.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { wait := 30 * time.Second if s := resp.Header.Get("Retry-After"); s != "" { if secs, err := strconv.Atoi(s); err == nil { wait = time.Duration(secs) * time.Second } } log.Printf("rate limited; waiting %s", wait) select { case <-time.After(wait): case <-ctx.Done(): return nil, ctx.Err() } return c.do(ctx, method, path, body) } raw, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode < 200 || resp.StatusCode > 299 { return nil, fmt.Errorf("%s %s: %d: %s", method, path, resp.StatusCode, truncate(raw)) } return raw, nil } func truncate(b []byte) string { if len(b) > 300 { return string(b[:300]) + "..." } return string(b) } // apiUser mirrors the ST user payload. Custom properties are heterogeneous, // so they stay raw until read individually. type apiUser struct { ID int64 `json:"id"` Email string `json:"email"` ChapterID int64 `json:"chapter_id"` CustomProps map[string]json.RawMessage `json:"custom_user_properties"` } func (a apiUser) toUser() *User { return &User{ ID: a.ID, Email: a.Email, ChapterID: a.ChapterID, Department: departmentOf(a.CustomProps), AppointmentTypes: optionLabels(a.CustomProps, "appointment-type"), IsRep: isRep(a.CustomProps), } } // departmentOf reads the free-text "department-1" property. func departmentOf(props map[string]json.RawMessage) string { raw, ok := props["department-1"] if !ok { return "" } var s string if err := json.Unmarshal(raw, &s); err != nil { return "" } return s } //optionLabels returne the labels of an option-valued custom-property // ST serialises select and checkbox fields as [{label, value}], and the // same label appears more than one time func optionLabels(props map[string]json.RawMessage, key string) []string { raw, ok := props[key] if !ok || len(raw) == 0 { return nil } var arr []struct { Label string `json:"label"` Value string `json:"value"` } if err := json.Unmarshal(raw, &arr); err != nil { return nil } out := make([]string, 0, len(arr)) for _, o := range arr { out = append(out, o.Label) } return out } // isRep tolerates all three shapes "department-rep" has been seen in: // array of options, bare string, bare bool. func isRep(props map[string]json.RawMessage) bool { return len(optionLabels(props, "department-rep")) > 0 } func (c *Client) GetUser(ctx context.Context, id int64) (*User, error) { raw, err := c.do(ctx, http.MethodGet, "/users/"+strconv.FormatInt(id, 10), nil) if err != nil { return nil, err } var u apiUser if err := json.Unmarshal(raw, &u); err != nil { return nil, err } return u.toUser(), nil } // ActiveAgent returns the user ID of the person's current agent, or 0 if // they have none. The is_active query param is ignored by the API, so // rows are filtered here. func (c *Client) ActiveAgent(ctx context.Context, userID int64) (int64, error) { q := url.Values{} q.Set("user_id", strconv.FormatInt(userID, 10)) q.Set("_limit", "100") raw, err := c.do(ctx, http.MethodGet, "/agent_assignments?"+q.Encode(), nil) if err != nil { return 0, err } var env struct { Data []struct { AgentUserID int64 `json:"agent_user_id"` IsActive bool `json:"is_active"` } `json:"data"` } if err := json.Unmarshal(raw, &env); err != nil { return 0, err } for _, a := range env.Data { if a.IsActive { return a.AgentUserID, nil } } return 0, nil } // AssignAgent creates an agent assignment. ST deactivates any prior active // assignment automatically. Not idempotent: a repeat call creates a new row. func (c *Client) AssignAgent(ctx context.Context, userID, agentID int64) error { if c.dryRun { log.Printf("DRY RUN: would assign user %d to agent %d", userID, agentID) return nil } body, err := json.Marshal(map[string]int64{ "user_id": userID, "agent_user_id": agentID, }) if err != nil { return err } _, err = c.do(ctx, http.MethodPost, "/agent_assignments", bytes.NewReader(body)) if err != nil { return err } log.Printf("assigned user %d to agent %d", userID, agentID) return nil } // ListUsers pages the full user list. If since is non-zero, only users // updated after that Unix timestamp are returned. func (c *Client) ListUsers(ctx context.Context, since int64) ([]User, error) { var out []User offset := 0 for { q := url.Values{} q.Set("_limit", "100") q.Set("_offset", strconv.Itoa(offset)) if since > 0 { q.Set("_since", strconv.FormatInt(since, 10)) } raw, err := c.do(ctx, http.MethodGet, "/users?"+q.Encode(), nil) if err != nil { return nil, fmt.Errorf("listing users at offset %d: %w", offset, err) } var env struct { Data []apiUser `json:"data"` } if err := json.Unmarshal(raw, &env); err != nil { return nil, fmt.Errorf("parsing users at offset %d: %w", offset, err) } for _, a := range env.Data { out = append(out, *a.toUser()) } if len(env.Data) < 100 { return out, nil } offset += 100 log.Printf("listed %d users so far", len(out)) } }