Head of Product & Engineering 5312eb0ca2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: per-location sender ID with admin API
Allows each GHL sub-account to use a different Cast sender ID instead of
the global CAST_SENDER_ID default.

- store.TokenRecord gains a sender_id field (MongoDB)
- store.UpdateSenderID method to set it per location
- cast.Client.SendSMS accepts a senderID override param (empty = use
  client-level default)
- webhook.processOutbound reads the location's sender_id from the token
  record and passes it to Cast
- new admin handler: PUT /api/admin/locations/{locationId}/sender-id
  protected by Authorization: Bearer <INBOUND_API_KEY>

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-06 12:47:00 +02:00

114 lines
2.7 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. senderID overrides the client-level default when non-empty.
func (c *Client) SendSMS(ctx context.Context, to, message, 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}
for attempt := 0; attempt <= maxRetries; attempt++ {
resp, err := c.doRequest(ctx, body)
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) (*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", c.apiKey)
req.Header.Set("Content-Type", "application/json")
return c.httpClient.Do(req)
}