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

91 lines
2.3 KiB
Go

package ghl
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
const ghlAPIBase = "https://services.leadconnectorhq.com"
const ghlAPIVersion = "2021-04-15"
type APIClient struct {
baseURL string
httpClient *http.Client
}
func NewAPIClient() *APIClient {
return &APIClient{
baseURL: ghlAPIBase,
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *APIClient) UpdateMessageStatus(ctx context.Context, accessToken, messageID, status string) error {
body, err := json.Marshal(MessageStatusUpdate{Status: status})
if err != nil {
return err
}
u := fmt.Sprintf("%s/conversations/messages/%s/status", c.baseURL, messageID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, u, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Version", ghlAPIVersion)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ghl update status returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *APIClient) PostInboundMessage(ctx context.Context, accessToken string, msg *InboundMessage) (*InboundMessageResponse, error) {
body, err := json.Marshal(msg)
if err != nil {
return nil, err
}
u := fmt.Sprintf("%s/conversations/messages/inbound", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Version", ghlAPIVersion)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("ghl inbound message returned %d: %s", resp.StatusCode, string(respBody))
}
var result InboundMessageResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse inbound message response: %w", err)
}
return &result, nil
}