Files
replypier/backend/app/crm/base.py
T

163 lines
6.0 KiB
Python
Raw Normal View History

"""
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.
"""
import base64
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import ClassVar, Optional
from uuid import UUID
import httpx
from app.core.logger import get_logger
logger = get_logger(__name__)
REQUEST_TIMEOUT_SECONDS = 20
def basic_auth_header(client_id: str, client_secret: str) -> str:
"""base64(client_id:client_secret) for HTTP Basic — Pipedrive OAuth + webhook."""
return base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
def summarize_provider_error(text: Optional[str]) -> str:
"""A short, single-line, tag-stripped snippet of a provider error body for a
user-facing message. The full body is logged separately — never echo an
untrusted third-party string verbatim into an admin surface."""
stripped = re.sub(r"<[^>]+>", " ", text or "")
return " ".join(stripped.split())[:200]
_http_client: Optional[httpx.AsyncClient] = None
def get_http_client() -> httpx.AsyncClient:
"""Shared AsyncClient for all CRM adapters (tests swap in a MockTransport)."""
global _http_client
if _http_client is None or _http_client.is_closed:
_http_client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
return _http_client
class CrmAuthError(Exception):
"""The provider definitively rejected our grant (invalid_grant/revoked) —
the connection needs a human reconnect, retrying cannot help."""
class CrmTransientError(Exception):
"""Temporary provider failure (5xx/network/429) — safe to retry later."""
@dataclass
class OAuthTokens:
"""Decrypted per-tenant OAuth state, passed into every adapter call.
`api_domain` is the per-tenant API host where the provider has one
(Pipedrive api_domain; later Salesforce instance_url / Zoho DC host).
"""
access_token: str
refresh_token: str
expires_at: Optional[datetime] = None
api_domain: Optional[str] = None
refresh_token_expires_at: Optional[datetime] = None
external_account_id: str = ""
display_name: Optional[str] = None
raw: dict = field(default_factory=dict)
@dataclass
class LeadPayload:
"""Provider-agnostic view of one captured lead (built by crm.mapping)."""
lead_response_id: UUID
email: str
name: Optional[str] = None
company: Optional[str] = None
phone: Optional[str] = None
summary: Optional[str] = None
# Human label -> value, from the agent's configured custom fields.
custom_fields: dict = field(default_factory=dict)
source_url: Optional[str] = None
@dataclass
class CrmPushResult:
ok: bool
action: Optional[str] = None # 'created' | 'updated'
contact_id: Optional[str] = None # HubSpot contact / Pipedrive person
secondary_id: Optional[str] = None # Pipedrive lead id
record_url: Optional[str] = None # deep link for the UI
error: Optional[str] = None
retryable: bool = False
retry_after_seconds: Optional[int] = None
auth_failed: bool = False
def classify_error_response(response: httpx.Response) -> CrmPushResult:
"""Map a failed provider response onto retry semantics.
401 → dead token (refresh-and-retry, else reconnect); 429 → back off,
honoring Retry-After; 5xx → transient; other 4xx → permanent (payload/
scope problems a retry can't fix).
"""
logger.warning(f"CRM provider HTTP {response.status_code}: {response.text[:1000]}")
error = f"HTTP {response.status_code}: {summarize_provider_error(response.text)}"
if response.status_code == 401:
return CrmPushResult(ok=False, error=error, auth_failed=True)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
return CrmPushResult(ok=False, error=error, retryable=True,
retry_after_seconds=seconds)
if response.status_code >= 500:
return CrmPushResult(ok=False, error=error, retryable=True)
return CrmPushResult(ok=False, error=error)
class CrmAdapter(ABC):
"""One stateless singleton per CRM. Tokens are passed into every call —
the adapter holds no per-tenant state (mirrors ChannelAdapter)."""
provider: ClassVar[str]
@abstractmethod
def get_authorize_url(self, state: str, redirect_uri: str) -> str:
"""Provider consent-screen URL for the OAuth install redirect."""
@abstractmethod
async def exchange_code(self, code: str, redirect_uri: str) -> OAuthTokens:
"""Swap the callback code for tokens + account identity.
Raises CrmAuthError on a rejected code, CrmTransientError on 5xx."""
@abstractmethod
async def refresh_tokens(self, tokens: OAuthTokens) -> OAuthTokens:
"""Mint a fresh access token. Raises CrmAuthError on invalid_grant
(connection dead), CrmTransientError on temporary failure."""
@abstractmethod
async def push_lead(self, tokens: OAuthTokens, payload: LeadPayload) -> CrmPushResult:
"""Create-or-update the lead in the CRM, deduping by email.
Never raises — failures come back classified in CrmPushResult."""
@abstractmethod
async def revoke(self, tokens: OAuthTokens) -> None:
"""Best-effort token revocation on disconnect. Never raises."""
@abstractmethod
async def test_connection(self, tokens: OAuthTokens) -> dict:
"""Cheap read-only authenticated call.
Returns {"ok": bool, "account_name": str | None, "error": str | None}."""