Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Complete MVP implementation of the Cast GHL Conversation Provider bridge: - Go module setup with chi router and mongo-driver dependencies - Config loading with env var validation and defaults - MongoDB token store with upsert, get, update, delete operations - Cast.ph SMS client with 429 retry logic and typed errors - Phone number normalization (E.164 ↔ Philippine local format) - GHL OAuth 2.0 install/callback/refresh flow - GHL webhook handler with ECDSA signature verification (async dispatch) - GHL API client for message status updates and inbound message stubs - Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline - Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers Co-Authored-By: SideKx <sidekx.ai@sds.dev>
84 lines
2.3 KiB
Markdown
84 lines
2.3 KiB
Markdown
# Task 03: Cast API Client
|
|
|
|
## Objective
|
|
Build `internal/cast/client.go` — a typed HTTP client for the Cast.ph SMS API.
|
|
|
|
## Reference
|
|
Read `CAST_API_REFERENCE.md` for exact request/response shapes.
|
|
|
|
## Types (`internal/cast/types.go`)
|
|
|
|
```go
|
|
type SendRequest struct {
|
|
To string `json:"to"`
|
|
Message string `json:"message"`
|
|
SenderID string `json:"sender_id,omitempty"`
|
|
}
|
|
|
|
type SendResponse struct {
|
|
Success bool `json:"success"`
|
|
MessageID string `json:"message_id,omitempty"`
|
|
Parts int `json:"parts,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
```
|
|
|
|
## Client (`internal/cast/client.go`)
|
|
|
|
### Client struct
|
|
```go
|
|
type Client struct {
|
|
baseURL string
|
|
apiKey string
|
|
senderID string // default sender ID, can be empty
|
|
httpClient *http.Client
|
|
}
|
|
|
|
func NewClient(baseURL, apiKey, senderID string) *Client
|
|
```
|
|
|
|
### Methods
|
|
|
|
```go
|
|
func (c *Client) SendSMS(ctx context.Context, to, message string) (*SendResponse, error)
|
|
```
|
|
|
|
- POST to `/api/sms/send`
|
|
- Set headers: `X-API-Key`, `Content-Type: application/json`
|
|
- Body: `{ "to": to, "message": message, "sender_id": c.senderID }` (omit sender_id if empty)
|
|
- On non-200: return `CastAPIError` with status code and error message from body
|
|
- On 200 with `success: false`: return `CastAPIError` with the error field
|
|
- On 200 with `success: true`: return the response
|
|
- Retry on 429: max 3 retries, read `Retry-After` header, backoff 1s/2s/4s
|
|
|
|
### Error type
|
|
|
|
```go
|
|
type CastAPIError struct {
|
|
StatusCode int
|
|
APIError string
|
|
}
|
|
|
|
func (e *CastAPIError) Error() string {
|
|
return fmt.Sprintf("cast api error (HTTP %d): %s", e.StatusCode, e.APIError)
|
|
}
|
|
```
|
|
|
|
### Key behaviors
|
|
- HTTP client timeout: 30 seconds
|
|
- `sender_id` omitted from JSON when Client.senderID is empty
|
|
- Response body always read and closed, even on errors
|
|
- Retry on 429 only (not on 5xx — Cast should handle that)
|
|
- Log retries with `slog.Warn`
|
|
|
|
## Acceptance Criteria
|
|
- [ ] `go build ./cmd/server/` succeeds
|
|
- [ ] `SendSMS` calls correct URL with correct headers
|
|
- [ ] `X-API-Key` header set on every request
|
|
- [ ] `sender_id` omitted from JSON when empty
|
|
- [ ] `CastAPIError` returned on non-200 with statusCode + apiError
|
|
- [ ] `CastAPIError` returned on 200 with `success: false`
|
|
- [ ] Retry on 429 with backoff (max 3)
|
|
- [ ] Context passed to HTTP request
|
|
- [ ] HTTP client has 30s timeout
|