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

139 lines
3.9 KiB
Go

package ghl
import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"time"
castclient "git.sds.dev/CAST/cast-ghl-plugin/internal/cast"
"git.sds.dev/CAST/cast-ghl-plugin/internal/phone"
)
type WebhookHandler struct {
webhookPubKey *ecdsa.PublicKey
castClient *castclient.Client
ghlAPI *APIClient
oauthHandler *OAuthHandler
}
func NewWebhookHandler(pubKeyPEM string, castClient *castclient.Client, ghlAPI *APIClient, oauth *OAuthHandler) (*WebhookHandler, error) {
key, err := parseECDSAPublicKey(pubKeyPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse webhook public key: %w", err)
}
return &WebhookHandler{
webhookPubKey: key,
castClient: castClient,
ghlAPI: ghlAPI,
oauthHandler: oauth,
}, nil
}
func (h *WebhookHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) {
sigHeader := r.Header.Get("x-wh-signature")
body, err := io.ReadAll(r.Body)
if err != nil {
slog.Error("webhook: failed to read body", "err", err)
http.Error(w, "failed to read request body", http.StatusInternalServerError)
return
}
if !h.verifySignature(body, sigHeader) {
slog.Warn("webhook: invalid signature")
http.Error(w, "invalid webhook signature", http.StatusUnauthorized)
return
}
var webhook OutboundMessageWebhook
if err := json.Unmarshal(body, &webhook); err != nil {
slog.Error("webhook: failed to parse payload", "err", err)
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if webhook.Type != "SMS" {
slog.Debug("webhook: ignoring non-SMS webhook", "type", webhook.Type)
w.WriteHeader(http.StatusOK)
return
}
slog.Info("webhook: received outbound SMS", "message_id", webhook.MessageID, "location_id", webhook.LocationID)
w.WriteHeader(http.StatusOK)
go h.processOutbound(webhook)
}
func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
localPhone, err := phone.ToLocal(webhook.Phone)
if err != nil {
slog.Error("webhook: phone normalization failed", "phone", webhook.Phone, "err", err)
h.updateStatus(ctx, webhook, "failed")
return
}
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message)
if err != nil {
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "err", err)
h.updateStatus(ctx, webhook, "failed")
return
}
slog.Info("webhook: cast send success", "message_id", webhook.MessageID)
h.updateStatus(ctx, webhook, "delivered")
}
func (h *WebhookHandler) updateStatus(ctx context.Context, webhook OutboundMessageWebhook, status string) {
token, err := h.oauthHandler.GetValidToken(ctx, webhook.LocationID)
if err != nil {
slog.Error("webhook: failed to get valid token for status update", "location_id", webhook.LocationID, "err", err)
return
}
if err := h.ghlAPI.UpdateMessageStatus(ctx, token, webhook.MessageID, status); err != nil {
slog.Error("webhook: failed to update message status", "message_id", webhook.MessageID, "status", status, "err", err)
return
}
slog.Info("webhook: message status updated", "message_id", webhook.MessageID, "status", status)
}
func (h *WebhookHandler) verifySignature(body []byte, signatureB64 string) bool {
if signatureB64 == "" {
return false
}
sigBytes, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
return false
}
hash := sha256.Sum256(body)
return ecdsa.VerifyASN1(h.webhookPubKey, hash[:], sigBytes)
}
func parseECDSAPublicKey(pemStr string) (*ecdsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pemStr))
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ecdsaPub, ok := pub.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("key is not ECDSA")
}
return ecdsaPub, nil
}