mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 00:02:15 +00:00
223 lines
6.5 KiB
Go
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
|
|
}
|