Files
warmbly/internal/app/pipedrive/webhook.go
T

223 lines
6.5 KiB
Go

package pipedrive
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"github.com/warmbly/warmbly/internal/models"
)
// Pipedrive signs nothing: a delivery is authenticated by HTTP Basic auth,
// with a password derived per connection from the app's client secret, and a
// delivery only queues a re-read, so at most it makes a pull come sooner.
const hookUser = "warmbly"
// hookObjects are the records whose changes Warmbly mirrors.
var hookObjects = []string{"deal", "person", "activity", "note", "stage", "pipeline"}
// WebhookPath is where Pipedrive delivers a connection's webhooks.
const WebhookPath = "/api/v1/integrations/pipedrive/webhooks/"
func (s *Service) hookURL(connID uuid.UUID) string {
base := strings.TrimRight(s.d.PublicURL, "/")
if !strings.HasPrefix(base, "https://") || s.d.ClientSecret == "" {
return ""
}
return base + WebhookPath + connID.String()
}
func (s *Service) hookPassword(connID uuid.UUID) string {
mac := hmac.New(sha256.New, []byte(s.d.ClientSecret))
mac.Write([]byte("pipedrive-webhook:" + connID.String()))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyWebhook checks a delivery's Basic auth against the connection's password.
func (s *Service) VerifyWebhook(connID uuid.UUID, user, password string) bool {
if s.d.ClientSecret == "" || user != hookUser || password == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(password), []byte(s.hookPassword(connID))) == 1
}
func hooksKey(orgID uuid.UUID) string { return "pipedrive:hooks:" + orgID.String() }
// hooksLive reports a workspace whose webhooks were registered recently.
func (s *Service) hooksLive(ctx context.Context, orgID uuid.UUID) bool {
return s.marked(ctx, hooksKey(orgID))
}
// ensureWebhooks registers the webhooks Warmbly needs that are missing.
func (s *Service) ensureWebhooks(ctx context.Context, o *org) {
url := s.hookURL(o.ConnID)
if url == "" {
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, "webhooks", nil,
"Instant updates need this instance reachable over HTTPS. Warmbly checks Pipedrive every few minutes instead.")
return
}
existing, err := o.Client.Webhooks(ctx)
if err != nil {
s.hooksFailed(ctx, o, err)
return
}
have := map[string]bool{}
for _, w := range existing {
if w.SubscriptionURL != url {
continue
}
if !webhookActive(w.IsActive) {
// Pipedrive switches a hook off after repeated failed deliveries
// (a rotated client secret changes its password): start it over.
_ = o.Client.DeleteWebhook(ctx, w.ID)
continue
}
have[w.EventObject] = true
}
for _, obj := range hookObjects {
if have[obj] {
continue
}
if err := o.Client.CreateWebhook(ctx, map[string]any{
"subscription_url": url,
"event_action": "*",
"event_object": obj,
"name": "Warmbly " + obj + " sync",
"http_auth_user": hookUser,
"http_auth_password": s.hookPassword(o.ConnID),
"version": "2.0",
}); err != nil {
s.hooksFailed(ctx, o, err)
return
}
}
s.mark(ctx, hooksKey(o.ID), metaRefreshEvery+time.Hour)
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, "webhooks", ptrNow(), "")
}
func (s *Service) hooksFailed(ctx context.Context, o *org, err error) {
msg := s.userError(ctx, o, err).Message
if ae, ok := AsAPIError(err); ok && ae.Status == 403 {
msg = "Pipedrive did not let Warmbly add webhooks for the connected user. Warmbly checks Pipedrive every few minutes instead."
}
if s.d.Cache != nil {
s.d.Cache.Del(ctx, hooksKey(o.ID))
}
_ = s.d.Repo.SetCursor(ctx, o.ID, provider, "webhooks", nil, msg)
}
// webhookActive reads is_active, which Pipedrive sends as a bool or 0/1.
func webhookActive(v any) bool {
switch x := v.(type) {
case bool:
return x
case float64:
return x != 0
case nil:
return true
}
return true
}
// Left removes Warmbly's webhooks from the company the workspace stopped using.
func (s *Service) Left(ctx context.Context, orgID, connID uuid.UUID) {
if s.d.Cache != nil {
s.d.Cache.Del(ctx, hooksKey(orgID))
}
url := s.hookURL(connID)
if url == "" {
return
}
conn, err := s.d.Tokens.GetConnection(ctx, orgID, connID)
if err != nil || conn == nil {
return
}
o := s.orgFor(ctx, orgID, conn, nil)
hooks, err := o.Client.Webhooks(ctx)
if err != nil {
return
}
for _, w := range hooks {
if w.SubscriptionURL == url {
if err := o.Client.DeleteWebhook(ctx, w.ID); err != nil {
log.Info().Err(err).Str("org_id", orgID.String()).Msg("pipedrive: could not remove a webhook")
}
}
}
}
// webhookEvent reads both payload versions: 2.0 ({meta: {action, entity,
// entity_id}}) and 1.0 ({meta: {action, object, id}}).
type webhookEvent struct {
Meta struct {
Action string `json:"action"`
Entity string `json:"entity"`
EntityID json.RawMessage `json:"entity_id"`
Object string `json:"object"`
ID json.RawMessage `json:"id"`
} `json:"meta"`
}
func rawID(raw json.RawMessage) string {
v := strings.Trim(strings.TrimSpace(string(raw)), `"`)
if extID(v) == 0 {
return ""
}
return v
}
// HandleWebhook turns a verified delivery into a refresh job. ok is false when
// no workspace runs its CRM on the connection any more, so Pipedrive can
// retire the webhook.
func (s *Service) HandleWebhook(ctx context.Context, connID uuid.UUID, body []byte) (bool, error) {
orgID, err := s.d.Repo.OrgForConnection(ctx, provider, connID)
if err != nil {
return true, err
}
if orgID == uuid.Nil {
return false, nil
}
var ev webhookEvent
if err := json.Unmarshal(body, &ev); err != nil {
return true, fmt.Errorf("decode: %w", err)
}
entity := firstNonEmpty(ev.Meta.Entity, ev.Meta.Object)
ext := rawID(ev.Meta.EntityID)
if ext == "" {
ext = rawID(ev.Meta.ID)
}
objectType := entity
switch entity {
case "deal", "person", "activity", "note":
if ext == "" {
return true, nil
}
case "stage", "pipeline":
objectType, ext = "pipelines", ""
default:
return true, nil
}
job := &models.CRMSyncJob{
OrganizationID: orgID, Provider: provider, Kind: models.CRMJobRefreshObject,
DedupeKey: "refresh:" + objectType + ":" + ext,
Subject: strings.TrimSpace("Pipedrive " + objectType + " " + ext),
Payload: map[string]any{"object_type": objectType, "external_id": ext},
// A short delay folds a burst of edits to one record into one read.
NextAttemptAt: time.Now().Add(5 * time.Second),
}
if err := s.d.Repo.EnqueueJob(ctx, job); err != nil {
return true, err
}
return true, nil
}