Head of Product & Engineering a40a4aa626
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: initial implementation of Cast GHL Provider
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>
2026-04-04 17:27:05 +02:00

97 lines
3.3 KiB
Markdown

# Task 07: GHL API Client (Status Updates)
## Objective
Build `internal/ghl/api.go` — client for calling GHL APIs (update message status, post inbound messages).
## Reference
- Update Message Status: https://marketplace.gohighlevel.com/docs/ghl/conversations/update-message-status
- Add Inbound Message: https://marketplace.gohighlevel.com/docs/ghl/conversations/add-an-inbound-message
## Types (add to `internal/ghl/types.go`)
```go
type MessageStatusUpdate struct {
Status string `json:"status"` // "delivered", "failed", "pending"
ErrorCode string `json:"error_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
}
type InboundMessage struct {
Type string `json:"type"` // "SMS"
Message string `json:"message"`
Phone string `json:"phone"` // E.164
ConversationProviderID string `json:"conversationProviderId,omitempty"`
}
type InboundMessageResponse struct {
ConversationID string `json:"conversationId"`
MessageID string `json:"messageId"`
}
```
## APIClient struct
```go
type APIClient struct {
baseURL string
httpClient *http.Client
}
func NewAPIClient() *APIClient {
return &APIClient{
baseURL: "https://services.leadconnectorhq.com",
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
```
## Methods
### UpdateMessageStatus
```go
func (c *APIClient) UpdateMessageStatus(ctx context.Context, accessToken, messageID, status string) error
```
1. Build URL: `{baseURL}/conversations/messages/{messageID}/status`
2. Body: `{ "status": status }`
3. Headers:
- `Authorization: Bearer {accessToken}`
- `Content-Type: application/json`
- `Version: 2021-04-15` (GHL API version header)
4. PUT request
5. On non-2xx: return error with status code + body
6. On success: return nil
### PostInboundMessage (Phase 2 — stub for now)
```go
func (c *APIClient) PostInboundMessage(ctx context.Context, accessToken string, msg *InboundMessage) (*InboundMessageResponse, error)
```
1. Build URL: `{baseURL}/conversations/messages/inbound`
2. Body: JSON of InboundMessage
3. Headers: same as above
4. POST request
5. Parse and return response
**Note:** Implement as a working stub — the full inbound flow is Phase 2, but the API client method should be ready.
## Key behaviors
- **GHL API version header:** `Version: 2021-04-15` — required on all GHL API calls
- **Bearer auth:** Use the OAuth access token for the specific locationId
- **Status values:** `"delivered"`, `"failed"`, `"pending"` — GHL expects these exact strings
- **Error on status update is non-fatal** — log it but don't cascade. The SMS was already sent (or failed to send). The status update is best-effort.
- **Retry on 401** — if status update returns 401, the token may have expired mid-request. The caller (webhook handler) should refresh and retry once.
## Acceptance Criteria
- [ ] `go build ./cmd/server/` succeeds
- [ ] `UpdateMessageStatus` sends PUT to correct URL
- [ ] `Authorization: Bearer` header set
- [ ] `Version: 2021-04-15` header set
- [ ] `Content-Type: application/json` header set
- [ ] Non-2xx returns descriptive error
- [ ] `PostInboundMessage` stub implemented with correct URL and method
- [ ] HTTP client has 30s timeout
- [ ] Context passed to all HTTP requests