The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
325 lines
15 KiB
Python
325 lines
15 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
from urllib.parse import urlencode, urlparse
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
from app.core.external_urls import external_base
|
|
from app.core.logger import get_logger
|
|
from app.crm.base import (
|
|
CrmAdapter, CrmAuthError, CrmPushResult, CrmTransientError,
|
|
LeadPayload, OAuthTokens, basic_auth_header, classify_error_response,
|
|
get_http_client, summarize_provider_error,
|
|
)
|
|
from app.crm.mapping import build_note_body
|
|
from app.crm.registry import register_adapter
|
|
from app.models.crm import CrmProvider
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
OAUTH_BASE = external_base("PIPEDRIVE_OAUTH_BASE", "https://oauth.pipedrive.com")
|
|
AUTHORIZE_URL = f"{OAUTH_BASE}/oauth/authorize"
|
|
TOKEN_URL = f"{OAUTH_BASE}/oauth/token"
|
|
REVOKE_URL = f"{OAUTH_BASE}/oauth/revoke"
|
|
# The API host is per company (the token response's api_domain), so there is no
|
|
# fixed base to swap. When set, this is the one extra api_domain we accept.
|
|
API_BASE = external_base("PIPEDRIVE_API_BASE", "")
|
|
# Refresh tokens die 60 days after their LAST use (sliding window) — every
|
|
# refresh restamps this; the worker's proactive sweep keeps idle tenants alive.
|
|
REFRESH_TOKEN_LIFETIME_DAYS = 60
|
|
|
|
|
|
def _validated_api_domain(api_domain) -> Optional[str]:
|
|
"""The per-tenant API host comes from Pipedrive's token response (over TLS),
|
|
but every subsequent call uses it as the base URL — so defensively require
|
|
an https pipedrive.com host and reject anything else, rather than letting a
|
|
malformed value point our requests at an arbitrary host."""
|
|
if not api_domain:
|
|
return None
|
|
if API_BASE and str(api_domain).rstrip("/") == API_BASE:
|
|
return API_BASE
|
|
try:
|
|
host = urlparse(api_domain).hostname or ""
|
|
except Exception:
|
|
return None
|
|
if api_domain.startswith("https://") and (host == "pipedrive.com" or host.endswith(".pipedrive.com")):
|
|
return api_domain
|
|
logger.warning(f"Pipedrive returned an unexpected api_domain, ignoring: {api_domain!r}")
|
|
return None
|
|
|
|
|
|
class PipedriveAdapter(CrmAdapter):
|
|
"""Pushes a lead as Person (v2, email-deduped) + Lead (v1) + context Note.
|
|
|
|
All API calls go to the tenant's `api_domain` from the token response.
|
|
A push is ~4 sequential calls, well under the 10 req/2s search cap; the
|
|
worker additionally serializes pushes per organization.
|
|
"""
|
|
|
|
provider = CrmProvider.PIPEDRIVE.value
|
|
|
|
def get_authorize_url(self, state: str, redirect_uri: str) -> str:
|
|
# Scopes are declared on the app in Developer Hub, not in the URL.
|
|
return f"{AUTHORIZE_URL}?" + urlencode({
|
|
"client_id": settings.PIPEDRIVE_CLIENT_ID,
|
|
"redirect_uri": redirect_uri,
|
|
"state": state,
|
|
})
|
|
|
|
async def exchange_code(self, code: str, redirect_uri: str) -> OAuthTokens:
|
|
tokens = await self._token_request({
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
})
|
|
# Company identity comes from users/me on the tenant's api_domain.
|
|
# Skip when the domain was missing/rejected — there's no valid base to call.
|
|
if not tokens.api_domain:
|
|
return tokens
|
|
try:
|
|
response = await get_http_client().get(
|
|
f"{tokens.api_domain}/api/v1/users/me",
|
|
headers=self._auth_header(tokens))
|
|
if response.status_code == 200:
|
|
data = response.json().get("data") or {}
|
|
tokens.external_account_id = str(data.get("company_id", ""))
|
|
tokens.display_name = data.get("company_name")
|
|
except httpx.HTTPError as e:
|
|
logger.warning(f"Pipedrive users/me lookup failed (ignored): {e}")
|
|
return tokens
|
|
|
|
async def refresh_tokens(self, tokens: OAuthTokens) -> OAuthTokens:
|
|
refreshed = await self._token_request({
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": tokens.refresh_token,
|
|
}, fallback_refresh_token=tokens.refresh_token)
|
|
refreshed.api_domain = refreshed.api_domain or tokens.api_domain
|
|
refreshed.external_account_id = tokens.external_account_id
|
|
refreshed.display_name = tokens.display_name
|
|
return refreshed
|
|
|
|
async def push_lead(self, tokens: OAuthTokens, payload: LeadPayload) -> CrmPushResult:
|
|
try:
|
|
org_id = await self._upsert_organization(tokens, payload.company)
|
|
person_id, action, person_error = await self._upsert_person(tokens, payload, org_id)
|
|
if person_error is not None:
|
|
return person_error
|
|
lead_id, lead_error = await self._ensure_open_lead(tokens, person_id, payload, org_id)
|
|
if lead_error is not None:
|
|
return lead_error
|
|
if lead_id:
|
|
await self._attach_note(tokens, lead_id, payload)
|
|
except httpx.HTTPError as e:
|
|
return CrmPushResult(ok=False, error=f"network error: {e}", retryable=True)
|
|
|
|
return CrmPushResult(
|
|
ok=True, action=action,
|
|
contact_id=str(person_id), secondary_id=str(lead_id) if lead_id else None,
|
|
record_url=f"{tokens.api_domain}/person/{person_id}" if tokens.api_domain else None,
|
|
)
|
|
|
|
async def revoke(self, tokens: OAuthTokens) -> None:
|
|
try:
|
|
await get_http_client().post(
|
|
REVOKE_URL,
|
|
headers={"Authorization": f"Basic {self._basic_secret()}"},
|
|
data={"token": tokens.refresh_token,
|
|
"token_type_hint": "refresh_token"},
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"Pipedrive token revocation failed (ignored): {e}")
|
|
|
|
async def test_connection(self, tokens: OAuthTokens) -> dict:
|
|
try:
|
|
response = await get_http_client().get(
|
|
f"{tokens.api_domain}/api/v1/users/me",
|
|
headers=self._auth_header(tokens))
|
|
except httpx.HTTPError as e:
|
|
return {"ok": False, "account_name": None, "error": f"network error: {e}"}
|
|
if response.status_code != 200:
|
|
return {"ok": False, "account_name": None,
|
|
"error": f"HTTP {response.status_code}"}
|
|
data = response.json().get("data") or {}
|
|
return {"ok": True, "account_name": data.get("company_name"), "error": None}
|
|
|
|
# ---- push steps -------------------------------------------------------
|
|
|
|
async def _upsert_organization(self, tokens: OAuthTokens, company: str | None):
|
|
"""Find-or-create the organization by name so the person is linked to a
|
|
company. Best-effort: a failure returns None and the person still syncs,
|
|
just unlinked. Uses the contacts scope (persons + orgs), so no extra
|
|
permission is needed."""
|
|
if not company or not company.strip():
|
|
return None
|
|
company = company.strip()
|
|
try:
|
|
search = await get_http_client().get(
|
|
f"{tokens.api_domain}/api/v2/organizations/search?" + urlencode({
|
|
"term": company, "fields": "name", "exact_match": "true"}),
|
|
headers=self._auth_header(tokens))
|
|
if search.status_code == 200:
|
|
items = ((search.json().get("data") or {}).get("items")) or []
|
|
if items:
|
|
return (items[0].get("item") or {}).get("id")
|
|
create = await get_http_client().post(
|
|
f"{tokens.api_domain}/api/v2/organizations",
|
|
headers=self._auth_header(tokens), json={"name": company})
|
|
if create.status_code in (200, 201):
|
|
return (create.json().get("data") or {}).get("id")
|
|
logger.warning(f"Pipedrive organization upsert failed: HTTP {create.status_code}")
|
|
except httpx.HTTPError as e:
|
|
logger.warning(f"Pipedrive organization upsert failed: {e}")
|
|
return None
|
|
|
|
async def _upsert_person(self, tokens: OAuthTokens, payload: LeadPayload, org_id=None):
|
|
"""Search by email; update blanks on a hit, create on a miss.
|
|
Returns (person_id, action, error_result)."""
|
|
response = await get_http_client().get(
|
|
f"{tokens.api_domain}/api/v2/persons/search?" + urlencode({
|
|
"term": payload.email, "fields": "email", "exact_match": "true"}),
|
|
headers=self._auth_header(tokens))
|
|
if response.status_code != 200:
|
|
return None, None, classify_error_response(response)
|
|
|
|
items = ((response.json().get("data") or {}).get("items")) or []
|
|
if items:
|
|
item = items[0].get("item") or {}
|
|
person_id = item.get("id")
|
|
update = self._blank_filling_update(item, payload, org_id)
|
|
if update:
|
|
patch = await get_http_client().patch(
|
|
f"{tokens.api_domain}/api/v2/persons/{person_id}",
|
|
headers=self._auth_header(tokens), json=update)
|
|
if patch.status_code not in (200, 201):
|
|
logger.warning(
|
|
f"Pipedrive person update failed: HTTP {patch.status_code}")
|
|
return person_id, "updated", None
|
|
|
|
create = await get_http_client().post(
|
|
f"{tokens.api_domain}/api/v2/persons",
|
|
headers=self._auth_header(tokens),
|
|
json=self._person_body(payload, org_id))
|
|
if create.status_code not in (200, 201):
|
|
return None, None, classify_error_response(create)
|
|
person_id = (create.json().get("data") or {}).get("id")
|
|
return person_id, "created", None
|
|
|
|
async def _ensure_open_lead(self, tokens: OAuthTokens, person_id,
|
|
payload: LeadPayload, org_id=None):
|
|
"""Create a Lead unless the person already has an open one — repeat
|
|
captures must not spam the leads inbox. Returns (lead_id, error_result).
|
|
|
|
A Lead carries its own organization_id (separate from the person's
|
|
org), so set it too or the lead shows no company."""
|
|
listing = await get_http_client().get(
|
|
f"{tokens.api_domain}/api/v1/leads?" + urlencode({
|
|
"person_id": person_id, "archived_status": "not_archived"}),
|
|
headers=self._auth_header(tokens))
|
|
if listing.status_code != 200:
|
|
# Guessing here risks a duplicate lead — classify and let the
|
|
# (idempotent) job retry instead.
|
|
return None, classify_error_response(listing)
|
|
if listing.json().get("data") or []:
|
|
return None, None # open lead exists — nothing to create
|
|
|
|
body = {"title": f"{payload.name or payload.email} — ReplyPier lead",
|
|
"person_id": person_id}
|
|
if org_id:
|
|
body["organization_id"] = org_id
|
|
create = await get_http_client().post(
|
|
f"{tokens.api_domain}/api/v1/leads",
|
|
headers=self._auth_header(tokens), json=body)
|
|
if create.status_code not in (200, 201):
|
|
return None, classify_error_response(create)
|
|
return (create.json().get("data") or {}).get("id"), None
|
|
|
|
async def _attach_note(self, tokens: OAuthTokens, lead_id,
|
|
payload: LeadPayload) -> None:
|
|
"""Context note on the lead. Best-effort — never fails the job."""
|
|
try:
|
|
response = await get_http_client().post(
|
|
f"{tokens.api_domain}/api/v1/notes",
|
|
headers=self._auth_header(tokens),
|
|
json={"lead_id": lead_id,
|
|
"content": build_note_body(payload)})
|
|
if response.status_code not in (200, 201):
|
|
logger.warning(f"Pipedrive note attach failed: HTTP {response.status_code}")
|
|
except httpx.HTTPError as e:
|
|
logger.warning(f"Pipedrive note attach failed: {e}")
|
|
|
|
# ---- helpers ----------------------------------------------------------
|
|
|
|
async def _token_request(self, data: dict,
|
|
fallback_refresh_token: str = "") -> OAuthTokens:
|
|
try:
|
|
response = await get_http_client().post(
|
|
TOKEN_URL, data=data,
|
|
headers={"Authorization": f"Basic {self._basic_secret()}",
|
|
"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise CrmTransientError(f"Pipedrive token endpoint unreachable: {e}")
|
|
if response.status_code >= 500:
|
|
raise CrmTransientError(f"Pipedrive token endpoint HTTP {response.status_code}")
|
|
if response.status_code != 200:
|
|
logger.warning(f"Pipedrive token request rejected: {response.text[:500]}")
|
|
raise CrmAuthError(f"Pipedrive rejected the token request: {summarize_provider_error(response.text)}")
|
|
body = response.json()
|
|
now = datetime.now(timezone.utc)
|
|
return OAuthTokens(
|
|
access_token=body["access_token"],
|
|
refresh_token=body.get("refresh_token") or fallback_refresh_token,
|
|
expires_at=now + timedelta(seconds=body.get("expires_in", 3600)),
|
|
api_domain=_validated_api_domain(body.get("api_domain")),
|
|
# Sliding window restarts on every successful token use.
|
|
refresh_token_expires_at=now + timedelta(days=REFRESH_TOKEN_LIFETIME_DAYS),
|
|
raw=body,
|
|
)
|
|
|
|
def _person_body(self, payload: LeadPayload, org_id=None) -> dict:
|
|
body = {"name": payload.name or payload.email,
|
|
"emails": [{"value": payload.email, "primary": True}]}
|
|
if payload.phone:
|
|
body["phones"] = [{"value": payload.phone, "primary": True}]
|
|
if org_id:
|
|
body["org_id"] = org_id
|
|
return body
|
|
|
|
def _blank_filling_update(self, item: dict, payload: LeadPayload, org_id=None) -> dict:
|
|
"""Only fill fields the existing person is missing — never clobber."""
|
|
update = {}
|
|
if payload.name and not item.get("name"):
|
|
update["name"] = payload.name
|
|
if payload.phone and not item.get("phones"):
|
|
update["phones"] = [{"value": payload.phone, "primary": True}]
|
|
if org_id and not item.get("org_id"):
|
|
update["org_id"] = org_id
|
|
return update
|
|
|
|
def _auth_header(self, tokens: OAuthTokens) -> dict:
|
|
return {"Authorization": f"Bearer {tokens.access_token}"}
|
|
|
|
@staticmethod
|
|
def _basic_secret() -> str:
|
|
return basic_auth_header(settings.PIPEDRIVE_CLIENT_ID, settings.PIPEDRIVE_CLIENT_SECRET)
|
|
|
|
|
|
register_adapter(PipedriveAdapter())
|