All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The endpoint requires appId (GHL client ID) as a query parameter. Without it the API returns 422 "appId must be a string". Co-Authored-By: Paperclip <noreply@paperclip.ing>
355 lines
12 KiB
Go
355 lines
12 KiB
Go
package ghl
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
|
|
)
|
|
|
|
const (
|
|
ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
|
|
ghlLocationTokenURL = "https://services.leadconnectorhq.com/oauth/locationToken"
|
|
ghlInstalledLocationsURL = "https://services.leadconnectorhq.com/oauth/installedLocations"
|
|
ghlLocationAPIVersion = "2021-07-28"
|
|
)
|
|
|
|
// TokenStore is the interface OAuthHandler uses for token persistence.
|
|
type TokenStore interface {
|
|
SaveToken(ctx context.Context, record *store.TokenRecord) error
|
|
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
|
|
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
|
|
DeleteToken(ctx context.Context, locationID string) error
|
|
}
|
|
|
|
type OAuthHandler struct {
|
|
clientID string
|
|
clientSecret string
|
|
baseURL string
|
|
providerID string
|
|
store TokenStore
|
|
httpClient *http.Client
|
|
tokenURL string
|
|
}
|
|
|
|
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s TokenStore) *OAuthHandler {
|
|
return &OAuthHandler{
|
|
clientID: clientID,
|
|
clientSecret: clientSecret,
|
|
baseURL: baseURL,
|
|
providerID: providerID,
|
|
store: s,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
tokenURL: ghlTokenURL,
|
|
}
|
|
}
|
|
|
|
func (h *OAuthHandler) HandleInstall(w http.ResponseWriter, r *http.Request) {
|
|
redirectURI := h.baseURL + "/oauth-callback"
|
|
scopes := strings.Join([]string{
|
|
"conversations/message.write",
|
|
"conversations/message.readonly",
|
|
"conversations.write",
|
|
"conversations.readonly",
|
|
"contacts.readonly",
|
|
"contacts.write",
|
|
"locations.readonly",
|
|
}, " ")
|
|
|
|
authURL := fmt.Sprintf(
|
|
"https://marketplace.gohighlevel.com/oauth/chooselocation?response_type=code&redirect_uri=%s&client_id=%s&scope=%s",
|
|
url.QueryEscape(redirectURI),
|
|
url.QueryEscape(h.clientID),
|
|
url.QueryEscape(scopes),
|
|
)
|
|
|
|
slog.Info("ghl oauth install initiated", "redirect_uri", redirectURI)
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
|
slog.Warn("ghl oauth denied by user", "error", errParam)
|
|
http.Error(w, "authorization denied: "+errParam, http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
code := r.URL.Query().Get("code")
|
|
if code == "" {
|
|
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
|
return
|
|
}
|
|
slog.Info("ghl oauth callback query params", "params", r.URL.RawQuery)
|
|
|
|
ctx := r.Context()
|
|
tokenResp, err := h.exchangeCode(ctx, code)
|
|
if err != nil {
|
|
slog.Error("ghl oauth code exchange failed", "err", err)
|
|
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Company-level (bulk) install: exchange company token for per-location tokens.
|
|
if tokenResp.UserType == "Company" && tokenResp.LocationID == "" {
|
|
slog.Info("ghl oauth company install — exchanging for location tokens", "company_id", tokenResp.CompanyID)
|
|
installed, err := h.installAllLocations(ctx, tokenResp)
|
|
if err != nil {
|
|
slog.Error("ghl oauth location token exchange failed", "company_id", tokenResp.CompanyID, "err", err)
|
|
http.Error(w, "failed to exchange company token for location tokens: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
slog.Info("ghl oauth bulk install complete", "company_id", tokenResp.CompanyID, "locations_installed", installed)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
tmpl := template.Must(template.New("ok").Parse(`<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>Connected {{.}} location(s). You can close this tab.</p></body></html>`))
|
|
_ = tmpl.Execute(w, installed)
|
|
return
|
|
}
|
|
|
|
if tokenResp.LocationID == "" {
|
|
slog.Error("ghl oauth token missing locationId", "company_id", tokenResp.CompanyID, "user_type", tokenResp.UserType)
|
|
http.Error(w, "GHL token response did not include a locationId", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
record := &store.TokenRecord{
|
|
LocationID: tokenResp.LocationID,
|
|
CompanyID: tokenResp.CompanyID,
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
ExpiresAt: expiresAt,
|
|
InstalledAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := h.store.SaveToken(ctx, record); err != nil {
|
|
slog.Error("ghl oauth token save failed", "location_id", tokenResp.LocationID, "err", err)
|
|
http.Error(w, "failed to save token", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
slog.Info("ghl oauth install complete", "location_id", tokenResp.LocationID)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>You can close this tab.</p></body></html>`)
|
|
}
|
|
|
|
func (h *OAuthHandler) RefreshToken(ctx context.Context, locationID string) (*store.TokenRecord, error) {
|
|
record, err := h.store.GetToken(ctx, locationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if record == nil {
|
|
return nil, errors.New("no token for location: " + locationID)
|
|
}
|
|
|
|
data := url.Values{}
|
|
data.Set("client_id", h.clientID)
|
|
data.Set("client_secret", h.clientSecret)
|
|
data.Set("grant_type", "refresh_token")
|
|
data.Set("refresh_token", record.RefreshToken)
|
|
|
|
tokenResp, err := h.postToken(ctx, data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh token failed: %w", err)
|
|
}
|
|
|
|
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
if err := h.store.UpdateToken(ctx, locationID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {
|
|
return nil, fmt.Errorf("failed to update token in store: %w", err)
|
|
}
|
|
|
|
record.AccessToken = tokenResp.AccessToken
|
|
record.RefreshToken = tokenResp.RefreshToken
|
|
record.ExpiresAt = expiresAt
|
|
slog.Info("ghl token refreshed", "location_id", locationID)
|
|
return record, nil
|
|
}
|
|
|
|
func (h *OAuthHandler) GetValidToken(ctx context.Context, locationID string) (string, error) {
|
|
record, err := h.store.GetToken(ctx, locationID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if record == nil {
|
|
return "", errors.New("no token for location: " + locationID)
|
|
}
|
|
|
|
if time.Until(record.ExpiresAt) < 5*time.Minute {
|
|
record, err = h.RefreshToken(ctx, locationID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
return record.AccessToken, nil
|
|
}
|
|
|
|
func (h *OAuthHandler) exchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
|
|
data := url.Values{}
|
|
data.Set("client_id", h.clientID)
|
|
data.Set("client_secret", h.clientSecret)
|
|
data.Set("grant_type", "authorization_code")
|
|
data.Set("code", code)
|
|
data.Set("redirect_uri", h.baseURL+"/oauth-callback")
|
|
return h.postToken(ctx, data)
|
|
}
|
|
|
|
func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenResponse, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.tokenURL, strings.NewReader(data.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp TokenResponse
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
|
}
|
|
slog.Info("ghl token response fields",
|
|
"location_id", tokenResp.LocationID,
|
|
"company_id", tokenResp.CompanyID,
|
|
"user_type", tokenResp.UserType,
|
|
"installed_locations", tokenResp.InstalledLocations,
|
|
"raw_body", string(body),
|
|
)
|
|
return &tokenResp, nil
|
|
}
|
|
|
|
// installAllLocations exchanges the company token for per-location tokens and stores them.
|
|
// It first tries to use the InstalledLocations list from the token response (provided by GHL
|
|
// for bulk installs). Falls back to GET /locations/search if that list is empty.
|
|
func (h *OAuthHandler) installAllLocations(ctx context.Context, companyToken *TokenResponse) (int, error) {
|
|
// Build location ID list from token response when GHL provides it (bulk install path).
|
|
locationIDs := companyToken.InstalledLocations
|
|
if len(locationIDs) == 0 {
|
|
slog.Info("ghl bulk install: no installedLocations in token, falling back to locations search", "company_id", companyToken.CompanyID)
|
|
locations, err := h.getCompanyLocations(ctx, companyToken.AccessToken, companyToken.CompanyID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list locations: %w", err)
|
|
}
|
|
if len(locations) == 0 {
|
|
return 0, fmt.Errorf("no locations found for company %s", companyToken.CompanyID)
|
|
}
|
|
for _, loc := range locations {
|
|
locationIDs = append(locationIDs, loc.ID)
|
|
}
|
|
} else {
|
|
slog.Info("ghl bulk install: using installedLocations from token", "company_id", companyToken.CompanyID, "count", len(locationIDs))
|
|
}
|
|
|
|
installed := 0
|
|
for _, locID := range locationIDs {
|
|
locToken, err := h.exchangeForLocationToken(ctx, companyToken.AccessToken, companyToken.CompanyID, locID)
|
|
if err != nil {
|
|
slog.Warn("ghl location token exchange failed", "location_id", locID, "err", err)
|
|
continue
|
|
}
|
|
expiresAt := time.Now().Add(time.Duration(locToken.ExpiresIn) * time.Second)
|
|
record := &store.TokenRecord{
|
|
LocationID: locID,
|
|
CompanyID: companyToken.CompanyID,
|
|
AccessToken: locToken.AccessToken,
|
|
RefreshToken: locToken.RefreshToken,
|
|
ExpiresAt: expiresAt,
|
|
InstalledAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
if err := h.store.SaveToken(ctx, record); err != nil {
|
|
slog.Warn("ghl location token save failed", "location_id", locID, "err", err)
|
|
continue
|
|
}
|
|
slog.Info("ghl location installed", "location_id", locID)
|
|
installed++
|
|
}
|
|
return installed, nil
|
|
}
|
|
|
|
// getCompanyLocations lists installed locations for a company using /oauth/installedLocations.
|
|
// This endpoint requires the oauth.readonly scope, which is present on all Company-level tokens.
|
|
func (h *OAuthHandler) getCompanyLocations(ctx context.Context, companyAccessToken, companyID string) ([]LocationInfo, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ghlInstalledLocationsURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
q := req.URL.Query()
|
|
q.Set("companyId", companyID)
|
|
q.Set("appId", h.clientID)
|
|
q.Set("isInstalled", "true")
|
|
req.URL.RawQuery = q.Encode()
|
|
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
|
|
req.Header.Set("Version", ghlLocationAPIVersion)
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("installedLocations endpoint returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var locResp InstalledLocationsResponse
|
|
if err := json.Unmarshal(body, &locResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse installedLocations response: %w", err)
|
|
}
|
|
slog.Info("ghl installed locations fetched", "company_id", companyID, "count", len(locResp.Locations))
|
|
return locResp.Locations, nil
|
|
}
|
|
|
|
// exchangeForLocationToken converts a company-scoped token into a location-scoped token.
|
|
func (h *OAuthHandler) exchangeForLocationToken(ctx context.Context, companyAccessToken, companyID, locationID string) (*TokenResponse, error) {
|
|
payload := fmt.Sprintf(`{"companyId":%q,"locationId":%q}`, companyID, locationID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ghlLocationTokenURL, strings.NewReader(payload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+companyAccessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Version", ghlLocationAPIVersion)
|
|
|
|
resp, err := h.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("locationToken endpoint returned %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var tokenResp TokenResponse
|
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse location token response: %w", err)
|
|
}
|
|
return &tokenResp, nil
|
|
}
|