feat(customer): 网站客户身份签发与长期负责人绑定

可信客户身份由站点后端签发,长期负责人与本次接待人分开保存。
This commit is contained in:
WangShen007 committed 2026-10-10 21:15:00 +08:00
1 parent 2554eb7bc7
commit ad90b830c4
18 files changed
+3123

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
"""
Copyright 2024-2026 ChatterMate
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 app.crm.base import CrmAdapter, OAuthTokens, LeadPayload, CrmPushResult
from app.crm.registry import get_adapter, register_adapter
__all__ = [
"CrmAdapter",
"OAuthTokens",
"LeadPayload",
"CrmPushResult",
"get_adapter",
"register_adapter",
]
+163
View File
@@ -0,0 +1,163 @@
"""
Copyright 2024-2026 ChatterMate
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}."""
+210
View File
@@ -0,0 +1,210 @@
"""
Copyright 2024-2026 ChatterMate
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 urllib.parse import urlencode
import httpx
from app.core.config import settings
from app.core.logger import get_logger
from app.crm.base import (
CrmAdapter, CrmAuthError, CrmPushResult, CrmTransientError,
LeadPayload, OAuthTokens, classify_error_response, get_http_client,
summarize_provider_error,
)
from app.crm.mapping import build_note_body, split_name
from app.crm.registry import register_adapter
from app.models.crm import CrmProvider
from app.core.external_urls import external_base
logger = get_logger(__name__)
AUTHORIZE_URL = "https://app.hubspot.com/oauth/authorize"
API_BASE = external_base("HUBSPOT_API_BASE", "https://api.hubapi.com")
TOKEN_URL = f"{API_BASE}/oauth/v1/token"
# Request only what the push needs — the marketplace review checks scope usage.
SCOPES = "crm.objects.contacts.read crm.objects.contacts.write"
# HubSpot-defined association type: note -> contact.
NOTE_TO_CONTACT_ASSOCIATION_TYPE_ID = 202
class HubSpotAdapter(CrmAdapter):
"""Pushes a lead as a Contact (email upsert) plus a context Note.
HubSpot's separate "Leads" object is deliberately not used: it requires a
paid-seat owner and an existing contact, which doesn't fit lead capture.
"""
provider = CrmProvider.HUBSPOT.value
def get_authorize_url(self, state: str, redirect_uri: str) -> str:
return f"{AUTHORIZE_URL}?" + urlencode({
"client_id": settings.HUBSPOT_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": SCOPES,
"state": state,
})
async def exchange_code(self, code: str, redirect_uri: str) -> OAuthTokens:
tokens = await self._token_request({
"grant_type": "authorization_code",
"client_id": settings.HUBSPOT_CLIENT_ID,
"client_secret": settings.HUBSPOT_CLIENT_SECRET,
"redirect_uri": redirect_uri,
"code": code,
})
# Token introspection carries the portal identity (hub_id/domain).
response = await get_http_client().get(
f"{API_BASE}/oauth/v1/access-tokens/{tokens.access_token}")
if response.status_code == 200:
info = response.json()
tokens.external_account_id = str(info.get("hub_id", ""))
tokens.display_name = info.get("hub_domain")
return tokens
async def refresh_tokens(self, tokens: OAuthTokens) -> OAuthTokens:
refreshed = await self._token_request({
"grant_type": "refresh_token",
"client_id": settings.HUBSPOT_CLIENT_ID,
"client_secret": settings.HUBSPOT_CLIENT_SECRET,
"refresh_token": tokens.refresh_token,
})
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:
properties = self._contact_properties(payload)
try:
response = await get_http_client().post(
f"{API_BASE}/crm/v3/objects/contacts/batch/upsert",
headers=self._auth_header(tokens),
json={"inputs": [{
"id": payload.email,
"idProperty": "email",
"properties": properties,
}]},
)
except httpx.HTTPError as e:
return CrmPushResult(ok=False, error=f"network error: {e}", retryable=True)
if response.status_code not in (200, 201):
return classify_error_response(response)
results = response.json().get("results") or [{}]
contact_id = str(results[0].get("id", ""))
action = "created" if results[0].get("new") else "updated"
await self._attach_note(tokens, contact_id, payload)
record_url = None
if tokens.external_account_id and contact_id:
record_url = (f"https://app.hubspot.com/contacts/"
f"{tokens.external_account_id}/record/0-1/{contact_id}")
return CrmPushResult(ok=True, action=action, contact_id=contact_id,
record_url=record_url)
async def revoke(self, tokens: OAuthTokens) -> None:
try:
await get_http_client().delete(
f"{API_BASE}/oauth/v1/refresh-tokens/{tokens.refresh_token}")
except Exception as e:
logger.warning(f"HubSpot token revocation failed (ignored): {e}")
async def test_connection(self, tokens: OAuthTokens) -> dict:
try:
response = await get_http_client().get(
f"{API_BASE}/oauth/v1/access-tokens/{tokens.access_token}")
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}"}
return {"ok": True, "account_name": response.json().get("hub_domain"),
"error": None}
async def _token_request(self, data: dict) -> OAuthTokens:
try:
response = await get_http_client().post(
TOKEN_URL, data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
except httpx.HTTPError as e:
raise CrmTransientError(f"HubSpot token endpoint unreachable: {e}")
if response.status_code >= 500:
raise CrmTransientError(f"HubSpot token endpoint HTTP {response.status_code}")
if response.status_code != 200:
# 400 invalid_grant / bad client config — retrying cannot help.
logger.warning(f"HubSpot token request rejected: {response.text[:500]}")
raise CrmAuthError(f"HubSpot rejected the token request: {summarize_provider_error(response.text)}")
body = response.json()
return OAuthTokens(
access_token=body["access_token"],
refresh_token=body.get("refresh_token", data.get("refresh_token", "")),
expires_at=datetime.now(timezone.utc) + timedelta(seconds=body.get("expires_in", 1800)),
raw=body,
)
def _contact_properties(self, payload: LeadPayload) -> dict:
first, last = split_name(payload.name)
properties = {
"email": payload.email,
"firstname": first,
"lastname": last,
"phone": payload.phone,
"company": payload.company,
# Backward lifecycle moves are ignored by HubSpot, so this is
# safe on updates too.
"lifecyclestage": "lead",
}
return {k: v for k, v in properties.items() if v}
async def _attach_note(self, tokens: OAuthTokens, contact_id: str,
payload: LeadPayload) -> None:
"""Context note (summary/custom answers/source). Best-effort: the
contact is the push — a failed note never fails the job."""
if not contact_id:
return
try:
response = await get_http_client().post(
f"{API_BASE}/crm/v3/objects/notes",
headers=self._auth_header(tokens),
json={
"properties": {
"hs_timestamp": int(datetime.now(timezone.utc).timestamp() * 1000),
"hs_note_body": build_note_body(payload),
},
"associations": [{
"to": {"id": contact_id},
"types": [{
"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": NOTE_TO_CONTACT_ASSOCIATION_TYPE_ID,
}],
}],
},
)
if response.status_code not in (200, 201):
logger.warning(f"HubSpot note attach failed: HTTP {response.status_code}")
except httpx.HTTPError as e:
logger.warning(f"HubSpot note attach failed: {e}")
@staticmethod
def _auth_header(tokens: OAuthTokens) -> dict:
return {"Authorization": f"Bearer {tokens.access_token}"}
register_adapter(HubSpotAdapter())
+143
View File
@@ -0,0 +1,143 @@
"""
Copyright 2024-2026 ChatterMate
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 html
from typing import Optional, Tuple
from app.crm.base import LeadPayload
from app.models.customer import Customer
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
# Standard field keys the agent collects; everything else is a custom field.
_STANDARD_KEYS = {"email", "name", "company", "phone"}
def _payload_from_values(
values: dict,
customer: Optional[Customer],
config: Optional[LeadCaptureConfig],
summary: Optional[str],
ref_id,
) -> LeadPayload:
"""Fold captured field values into the provider-agnostic push payload.
`values` holds what the agent extracted conversationally (email/name/
company/phone + custom_N); the Customer row backfills anything blank. Custom
answers are labeled from the agent's field config. Company has no Customer
column, so it comes from the captured values only.
"""
values = values or {}
email = (values.get("email") or (customer.email if customer else "") or "").strip().lower()
labels = _custom_field_labels(config)
custom_fields = {
labels.get(key, key): str(value)
for key, value in values.items()
if key not in _STANDARD_KEYS and value not in (None, "")
}
lead_source = (customer.lead_source if customer else None) or {}
return LeadPayload(
lead_response_id=ref_id,
email=email,
name=_first_truthy(values.get("name"), customer.full_name if customer else None),
company=values.get("company") or None,
phone=_first_truthy(values.get("phone"), customer.phone if customer else None),
summary=summary,
custom_fields=custom_fields,
source_url=lead_source.get("page_url"),
)
def build_lead_payload(
response: LeadCaptureResponse,
config: Optional[LeadCaptureConfig],
customer: Optional[Customer],
) -> LeadPayload:
"""Auto path: fold one captured lead response into the push payload."""
return _payload_from_values(
response.field_values, customer, config, response.summary, response.id)
def build_customer_payload(
customer: Customer,
attributes: Optional[dict] = None,
summary: Optional[str] = None,
config: Optional[LeadCaptureConfig] = None,
) -> LeadPayload:
"""Manual "Sync now": build from the person's captured attributes (the same
merged field_values the People drawer shows) so name/company/phone/custom
answers all sync — not just the sparse Customer columns. Integrator-supplied
meta_data is folded in as extra custom fields."""
payload = _payload_from_values(attributes, customer, config, summary, customer.id)
meta = customer.meta_data if isinstance(customer.meta_data, dict) else {}
for key, value in meta.items():
if value not in (None, "") and str(key) not in payload.custom_fields:
payload.custom_fields[str(key)] = str(value)
return payload
def split_name(name: Optional[str]) -> Tuple[str, str]:
"""Best-effort (first, last) split for CRMs with separate name fields."""
if not name or not name.strip():
return "", ""
parts = name.strip().split()
if len(parts) == 1:
return parts[0], ""
return " ".join(parts[:-1]), parts[-1]
def build_note_body(payload: LeadPayload) -> str:
"""The context note attached to the CRM record: AI qualification summary,
custom answers, and where the lead came from. Shared across providers.
Returns HTML — both HubSpot (hs_note_body) and Pipedrive (note content)
render notes as HTML, so plain newlines would collapse and run the summary
into the header. Labelled lines keep the AI summary unmistakable; dynamic
values are escaped."""
lines = ["<b>Lead captured by ChatterMate</b>"]
if payload.summary:
lines.append(f"<b>AI summary:</b> {html.escape(payload.summary)}")
for label, value in (payload.custom_fields or {}).items():
lines.append(f"<b>{html.escape(str(label))}:</b> {html.escape(str(value))}")
if payload.source_url:
url = html.escape(payload.source_url)
# Only render an anchor for http(s); a javascript:/data: page_url would
# otherwise become a clickable link in the CRM note.
if payload.source_url.lower().startswith(("http://", "https://")):
lines.append(f'Captured on: <a href="{url}">{url}</a>')
else:
lines.append(f"Captured on: {url}")
return "<br>".join(lines)
def _custom_field_labels(config: Optional[LeadCaptureConfig]) -> dict:
"""key -> human label for the agent's configured custom fields."""
if config is None or not config.fields:
return {}
return {
f["key"]: f.get("label") or f["key"]
for f in config.fields
if isinstance(f, dict) and f.get("key")
}
def _first_truthy(*values: Optional[str]) -> Optional[str]:
for value in values:
if value and str(value).strip():
return str(value).strip()
return None
+324
View File
@@ -0,0 +1,324 @@
"""
Copyright 2024-2026 ChatterMate
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} — ChatterMate 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())
+47
View File
@@ -0,0 +1,47 @@
"""
Copyright 2024-2026 ChatterMate
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 typing import Dict, Optional
from app.crm.base import CrmAdapter
_adapters: Dict[str, CrmAdapter] = {}
def register_adapter(adapter: CrmAdapter) -> CrmAdapter:
"""Register a stateless adapter singleton for its provider."""
_adapters[adapter.provider] = adapter
return adapter
def get_adapter(provider: str) -> Optional[CrmAdapter]:
"""Adapter for a CRM provider, or None for unknown providers."""
_ensure_loaded()
return _adapters.get(provider)
_loaded = False
def _ensure_loaded() -> None:
"""Import adapter modules on first use (each registers itself).
Lazy so importing app.crm never pulls every adapter's deps."""
global _loaded
if _loaded:
return
_loaded = True
from app.crm import hubspot # noqa: F401
from app.crm import pipedrive # noqa: F401