Head of Product & Engineering 9995027093
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: per-location Cast API key and unified admin config API
Each GHL location can now have its own Cast API key and sender ID stored
in MongoDB. Falls back to global CAST_API_KEY / CAST_SENDER_ID env vars
when not set per-location.

Admin endpoints (all require Authorization: Bearer <INBOUND_API_KEY>):
  GET  /api/admin/locations                        — list all locations
  GET  /api/admin/locations/{locationId}/config    — get location config
  PUT  /api/admin/locations/{locationId}/config    — set sender_id + cast_api_key

Cast API key is masked in GET responses (first 12 chars + "...").
Replaces the /sender-id endpoint deployed in the previous commit.

Also adds FUTURE_DEV.md documenting the migration path to Infisical
for secret management, plus MongoDB security hardening checklist.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-06 14:08:29 +02:00

121 lines
2.9 KiB
Go

package cast
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strconv"
"time"
)
type Client struct {
baseURL string
apiKey string
senderID string
httpClient *http.Client
}
func NewClient(baseURL, apiKey, senderID string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
senderID: senderID,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// SendSMS sends an SMS via Cast API.
// apiKey and senderID override client-level defaults when non-empty.
func (c *Client) SendSMS(ctx context.Context, to, message, apiKey, senderID string) (*SendResponse, error) {
req := SendRequest{To: to, Message: message}
switch {
case senderID != "":
req.SenderID = senderID
case c.senderID != "":
req.SenderID = c.senderID
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
const maxRetries = 3
backoff := []time.Duration{1 * time.Second, 2 * time.Second, 4 * time.Second}
// Resolve effective API key: per-call override takes precedence over client default.
effectiveAPIKey := c.apiKey
if apiKey != "" {
effectiveAPIKey = apiKey
}
for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err := c.doRequest(ctx, body, effectiveAPIKey)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
if attempt == maxRetries {
return nil, &CastAPIError{StatusCode: resp.StatusCode, APIError: "rate limited, max retries exceeded"}
}
idx := attempt
if idx >= len(backoff) {
idx = len(backoff) - 1
}
wait := backoff[idx]
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.ParseFloat(ra, 64); err == nil {
wait = time.Duration(secs * float64(time.Second))
}
}
slog.Warn("cast api rate limited, retrying", "attempt", attempt+1, "wait", wait)
_ = resp.Body.Close()
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
continue
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var errResp SendResponse
_ = json.Unmarshal(data, &errResp)
return nil, &CastAPIError{StatusCode: resp.StatusCode, APIError: errResp.Error}
}
var result SendResponse
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
if !result.Success {
return nil, &CastAPIError{StatusCode: resp.StatusCode, APIError: result.Error}
}
return &result, nil
}
return nil, &CastAPIError{StatusCode: http.StatusTooManyRequests, APIError: "max retries exceeded"}
}
func (c *Client) doRequest(ctx context.Context, body []byte, apiKey string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/sms/send", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("X-API-Key", apiKey)
req.Header.Set("Content-Type", "application/json")
return c.httpClient.Do(req)
}