Update all active config and documentation files to use the correct production domain hl.cast.ph (not ghl.cast.ph). Co-Authored-By: Paperclip <noreply@paperclip.ing>
159 lines
7.1 KiB
Markdown
159 lines
7.1 KiB
Markdown
# cast-ghl-provider — Claude Code Project Instructions
|
|
|
|
## What This Is
|
|
|
|
`cast-ghl-provider` is a Go HTTP service that acts as a GHL (GoHighLevel) Marketplace SMS Conversation Provider. It bridges GHL's outbound SMS webhooks to Cast.ph's SMS API, replacing Twilio/LC-Phone as the default SMS provider for GHL sub-accounts.
|
|
|
|
## Stack
|
|
|
|
- **Language:** Go 1.22+
|
|
- **HTTP:** `net/http` (stdlib) + `chi` router (lightweight)
|
|
- **Database:** MongoDB (OAuth token storage)
|
|
- **Mongo driver:** `go.mongodb.org/mongo-driver/v2`
|
|
- **HTTP client:** `net/http` (stdlib, no external HTTP client)
|
|
- **JSON:** `encoding/json` (stdlib)
|
|
- **Crypto:** `crypto/rsa` + `crypto/sha256` (webhook sig: RSA-PKCS1v15+SHA-256); `crypto/ed25519` (X-GHL-Signature fallback, active July 2026+)
|
|
- **Config:** Environment variables only (no config files)
|
|
- **Deploy:** Docker + Docker Compose on Vultr
|
|
|
|
## External APIs
|
|
|
|
### Cast.ph SMS API
|
|
|
|
**Base URL:** `https://api.cast.ph`
|
|
**Auth:** `X-API-Key: cast_<64-hex-chars>`
|
|
**Full docs:** `CAST_API_REFERENCE.md` in repo root
|
|
|
|
| Endpoint | Method | Purpose |
|
|
|----------|--------|---------|
|
|
| `/api/sms/send` | POST | Send outbound SMS |
|
|
|
|
Request: `{ "to": "09171234567", "message": "text", "sender_id": "CAST" }`
|
|
Response: `{ "success": true, "message_id": "abc123", "parts": 1 }`
|
|
|
|
Key behaviors:
|
|
- Phone numbers: 11-digit Philippine format (`09XXXXXXXXX`)
|
|
- Message max: 450 characters (3 SMS parts)
|
|
- Rate limit: 30 req/s, burst 50, 429 with Retry-After
|
|
- Errors: `{ "success": false, "error": "..." }`
|
|
|
|
### GHL API
|
|
|
|
**Base URL:** `https://services.leadconnectorhq.com`
|
|
**Auth:** `Authorization: Bearer <access_token>` (OAuth 2.0)
|
|
**Provider docs:** `GHL_API_REFERENCE.md` in repo root
|
|
|
|
| Endpoint | Method | Purpose |
|
|
|----------|--------|---------|
|
|
| `/oauth/token` | POST | Exchange auth code / refresh token |
|
|
| `/conversations/messages/{messageId}/status` | PUT | Update outbound message status |
|
|
| `/conversations/messages/inbound` | POST | Post inbound SMS to GHL (Phase 2) |
|
|
|
|
### GHL Webhook (inbound TO our service)
|
|
|
|
GHL sends `ProviderOutboundMessage` to our delivery URL:
|
|
```json
|
|
{
|
|
"contactId": "...",
|
|
"locationId": "...",
|
|
"messageId": "...",
|
|
"type": "SMS",
|
|
"phone": "+639171234567",
|
|
"message": "text to send",
|
|
"attachments": [],
|
|
"userId": "..."
|
|
}
|
|
```
|
|
Verified via `x-wh-signature` header using RSA-PKCS1v15 + SHA-256.
|
|
Future: `X-GHL-Signature` header using Ed25519 (GHL migration July 2026).
|
|
|
|
## Project Structure
|
|
|
|
```
|
|
cast-ghl-provider/
|
|
├── cmd/
|
|
│ └── server/
|
|
│ └── main.go # Entry: config, routes, graceful shutdown
|
|
├── internal/
|
|
│ ├── config/
|
|
│ │ └── config.go # Env var loading + validation
|
|
│ ├── ghl/
|
|
│ │ ├── oauth.go # OAuth install, callback, token refresh
|
|
│ │ ├── webhook.go # Outbound webhook handler + sig verify
|
|
│ │ ├── api.go # GHL API client (status update, inbound)
|
|
│ │ └── types.go # GHL types
|
|
│ ├── cast/
|
|
│ │ ├── client.go # Cast API HTTP client
|
|
│ │ └── types.go # Cast types
|
|
│ ├── phone/
|
|
│ │ └── normalize.go # E.164 ↔ PH local conversion
|
|
│ └── store/
|
|
│ └── mongo.go # MongoDB token/session storage
|
|
├── Dockerfile
|
|
├── docker-compose.yaml
|
|
├── .env.example
|
|
├── go.mod
|
|
├── CAST_API_REFERENCE.md
|
|
├── GHL_API_REFERENCE.md
|
|
├── CLAUDE.md # THIS FILE
|
|
├── .claude/tasks/ # Sequential dev tasks
|
|
└── README.md
|
|
```
|
|
|
|
## Conventions
|
|
|
|
- **One package per concern** — `ghl/`, `cast/`, `phone/`, `store/`, `config/`
|
|
- **No global state** — pass dependencies via struct fields (dependency injection without a framework)
|
|
- **Error handling:** Return `error`, never panic. Wrap with `fmt.Errorf("context: %w", err)`
|
|
- **Logging:** `log/slog` (structured logging, stdlib). JSON format in production.
|
|
- **HTTP handlers:** `func(w http.ResponseWriter, r *http.Request)` — standard `net/http`
|
|
- **Context:** Pass `context.Context` through all external calls (HTTP, MongoDB)
|
|
- **Tests:** `_test.go` files alongside source. Use `httptest` for HTTP mocks.
|
|
- **Naming:** Follow Go conventions — exported types are PascalCase, packages are lowercase single-word
|
|
|
|
## Config (env vars)
|
|
|
|
| Variable | Required | Default | Description |
|
|
|----------|----------|---------|-------------|
|
|
| `PORT` | No | `3002` | Server listen port |
|
|
| `BASE_URL` | Yes | — | Public URL (e.g. `https://hl.cast.ph`) |
|
|
| `GHL_CLIENT_ID` | Yes | — | GHL Marketplace app client ID |
|
|
| `GHL_CLIENT_SECRET` | Yes | — | GHL Marketplace app client secret |
|
|
| `GHL_WEBHOOK_PUBLIC_KEY` | Yes | — | PEM-encoded ECDSA public key for webhook sig |
|
|
| `GHL_CONVERSATION_PROVIDER_ID` | Yes | — | Conversation provider ID from GHL app |
|
|
| `CAST_API_KEY` | Yes | — | Cast.ph API key |
|
|
| `CAST_API_URL` | No | `https://api.cast.ph` | Cast API base URL |
|
|
| `CAST_SENDER_ID` | No | — | Default sender ID (uses account default if empty) |
|
|
| `MONGO_URI` | Yes | — | MongoDB connection string |
|
|
| `INBOUND_API_KEY` | No | — | Shared secret for future inbound webhook auth |
|
|
|
|
Validated at startup. Missing required vars → log error + os.Exit(1).
|
|
|
|
## Routes
|
|
|
|
| Method | Path | Handler | Purpose |
|
|
|--------|------|---------|---------|
|
|
| `GET` | `/health` | healthCheck | Health check |
|
|
| `GET` | `/install` | ghl.HandleInstall | Start OAuth flow |
|
|
| `GET` | `/oauth-callback` | ghl.HandleCallback | OAuth redirect handler |
|
|
| `POST` | `/api/ghl/v1/webhook/messages` | ghl.HandleWebhook | Outbound SMS webhook |
|
|
| `POST` | `/api/ghl/v1/inbound-sms` | ghl.HandleInbound | Inbound SMS (Phase 2) |
|
|
|
|
## Development Workflow
|
|
|
|
1. Work through `.claude/tasks/` in order (01 → 10)
|
|
2. `go build ./cmd/server/` after each task
|
|
3. `go test ./...` for tests
|
|
4. `go vet ./...` for static analysis
|
|
5. Docker: `docker compose up --build` for full stack
|
|
|
|
## Key Implementation Notes
|
|
|
|
1. **Webhook signature verification is mandatory** — GHL sends `x-wh-signature` on every webhook. Verify with RSA-PKCS1v15 + SHA-256 using the static RSA public key from GHL docs (set in `GHL_WEBHOOK_PUBLIC_KEY` env var). From July 2026, also support `X-GHL-Signature` (Ed25519). The handler checks `X-GHL-Signature` first and falls back to `x-wh-signature`.
|
|
2. **OAuth tokens are per-location** — store `locationId` → `{ access_token, refresh_token, expires_at }` in MongoDB. Refresh before expiry.
|
|
3. **Phone normalization is critical** — GHL sends E.164 (`+639XXXXXXXXX`), Cast expects `09XXXXXXXXX`. Get this wrong = messages fail.
|
|
4. **Status updates must use the provider's token** — only the conversation provider marketplace app tokens can update message status.
|
|
5. **Respond 200 to webhook immediately** — process the SMS send asynchronously (goroutine) so GHL doesn't timeout waiting.
|
|
6. **Cast API has no inbound webhook yet** — inbound SMS is Phase 2, after Cast SIM gateway adds webhook support.
|
|
7. **GHL API base is `services.leadconnectorhq.com`** — not `rest.gohighlevel.com` (that's v1, deprecated).
|