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

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

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
"""
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.
Letting an installed extension answer a channel message before the AI does
(see app.channels.app_hooks), and announcing every inbound message to it.
"""
import asyncio
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy.orm import Session
from app.channels.app_hooks import (
ChannelEvent, EventKind, HandledBy, InboundContext, emit_channel_event,
inbound_event_id, inbound_handler,
)
from app.channels.base import InboundMessage
from app.core.logger import get_logger
from app.models.channels import ChannelAccount
from app.models.session_to_agent import SessionStatus
logger = get_logger(__name__)
# How long the customer's message waits on the extension before the AI (or
# inbox) takes it. The extension answers synchronously, so this is added to
# every reply the AI then gives — kept short.
APP_HANDLER_TIMEOUT_SECONDS = 4.0
async def app_claims(db: Session, account: ChannelAccount, session_record, customer_id: str,
inbound: InboundMessage, received_at: datetime) -> bool:
"""Offer the message to the extension; True when it has dealt with it.
Only a conversation nobody holds is offered — once a human has it, or a
transfer is pending, the human answers. A timeout or a failing extension
counts as "not handled", so the customer is never left waiting on it.
"""
handler = inbound_handler()
if handler is None or not _open_and_unheld(session_record):
return False
# Nothing may sit in an open transaction while an outside service thinks
db.commit()
context = InboundContext(
event_id=inbound_event_id(account.id, inbound.external_message_id),
organization_id=account.organization_id,
channel_account_id=account.id,
channel_type=account.channel_type,
session_id=str(session_record.session_id),
customer_id=customer_id,
inbound=inbound,
received_at=received_at,
)
try:
# Exactly True: anything else (a stray truthy string, a forgotten
# await) must not silence the AI
return await asyncio.wait_for(handler(context), APP_HANDLER_TIMEOUT_SECONDS) is True
except asyncio.TimeoutError:
logger.warning(f"Extension did not answer {inbound.external_message_id} within "
f"{APP_HANDLER_TIMEOUT_SECONDS}s; continuing without it")
except Exception as e:
logger.error(f"Extension failed on {inbound.external_message_id}; continuing without it: {e}")
return False
async def announce_inbound(account: ChannelAccount, session_record, customer_id: str,
inbound: InboundMessage, handled_by: HandledBy,
received_at: Optional[datetime] = None) -> None:
"""Tell listeners about an inbound message and who is answering it. Sent
once per message on every path, before any AI reply, under an id derived
from the message so a redelivered webhook can't announce it twice."""
await emit_channel_event(ChannelEvent(
kind=EventKind.INBOUND,
event_id=inbound_event_id(account.id, inbound.external_message_id),
organization_id=account.organization_id,
channel_account_id=account.id,
occurred_at=received_at or datetime.now(timezone.utc),
session_id=str(session_record.session_id),
customer_id=customer_id,
inbound=inbound,
handled_by=handled_by,
))
def _open_and_unheld(session_record) -> bool:
return session_record.user_id is None and session_record.status == SessionStatus.OPEN
+88
View File
@@ -0,0 +1,88 @@
"""
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 typing import Optional, Dict, Any
from app.repositories.customer import CustomerRepository
# Where a handoff email goes when the customer row can't take it — another
# customer in the org already owns that address (the (email, organization_id)
# unique constraint), or it is a channel identity key. meta_data renders as a
# label/value list in the inbox customer panel, so an agent still sees the
# address and can follow up. Dropping it is how a visitor asks to be contacted
# and then never is.
CONTACT_EMAIL_META_KEY = "contact_email_provided"
def retain_unstored_email(customer_repo, customer_id, submitted_email: str, update_result: dict) -> bool:
"""Keep a submitted handoff email that ``update_contact`` could not apply.
Returns True when it was retained on meta_data. No-op when the address did
land on the customer, when it is already their stored address, or when
nothing was submitted — so a normal capture writes no metadata.
"""
if not submitted_email or update_result.get('email_updated'):
return False
stored = (update_result.get('email') or '').lower()
if submitted_email.lower() == stored:
return False
return customer_repo.update_meta_data(
customer_id, {CONTACT_EMAIL_META_KEY: submitted_email}
) is not None
def build_handoff_contact_form(
customer,
collect_email: bool,
collect_name: bool,
) -> Optional[Dict[str, Any]]:
"""Return ``form_data`` (``form_type='contact'``) for the handoff contact prompt, or
``None`` when there is nothing to collect (toggles off, or the customer already has the
info). The widget renders this with its existing inline form UI.
"""
if customer is None:
return None
needs_email = bool(collect_email) and CustomerRepository.is_placeholder_email(getattr(customer, 'email', None))
full_name = (getattr(customer, 'full_name', None) or '').strip()
needs_name = bool(collect_name) and not full_name
if not needs_email and not needs_name:
return None
fields = []
if needs_email:
fields.append({
'name': 'email',
'type': 'email',
'label': 'Email',
'placeholder': 'you@example.com',
'required': True,
})
if needs_name:
fields.append({
'name': 'name',
'type': 'text',
'label': 'Name',
'placeholder': 'Your name',
'required': False,
})
return {
'form_type': 'contact',
'title': 'Before we connect you to a teammate',
'description': 'Share your details so we can follow up.',
'fields': fields,
}
+241
View File
@@ -0,0 +1,241 @@
"""
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 typing import Optional
from sqlalchemy.orm import Session
from app.core.logger import get_logger
from app.crm.base import CrmAuthError, CrmPushResult, CrmTransientError
from app.crm.mapping import build_customer_payload, build_lead_payload
from app.crm.registry import get_adapter
from app.models.crm import CrmConnectionStatus, CrmSyncJob
from app.models.customer import Customer
from app.models.lead_capture import CrmSyncTarget, LeadCaptureConfig, LeadCaptureResponse
from app.repositories.crm import (
CrmConnectionRepository, CrmSyncJobRepository, CrmCustomerSyncRepository,
)
from app.services.crm_tokens import get_valid_tokens
from app.services.feature_gate import feature_allowed
logger = get_logger(__name__)
def enqueue_crm_sync(db: Session, config: LeadCaptureConfig,
response: LeadCaptureResponse) -> Optional[CrmSyncJob]:
"""Queue a captured lead for CRM push, or quietly decide not to.
All the "should this sync at all" logic lives here so the capture path
stays dumb: no target configured, plan lacks the feature (fails closed),
or no active connection → no job. Idempotent per (lead, provider)."""
provider = str(config.crm_sync_target or CrmSyncTarget.NONE.value)
if provider == CrmSyncTarget.NONE.value:
return None
if get_adapter(provider) is None:
logger.warning(f"Lead {response.id}: unknown CRM target '{provider}', not syncing")
return None
if not feature_allowed(db, response.organization_id, "crm_sync"):
logger.info(f"Lead {response.id}: plan lacks crm_sync, not syncing")
return None
connection = CrmConnectionRepository(db).get_active(response.organization_id, provider)
if connection is None:
logger.info(f"Lead {response.id}: no active {provider} connection, not syncing")
return None
job = CrmSyncJobRepository(db).enqueue(
organization_id=response.organization_id,
lead_response_id=response.id,
provider=provider,
agent_id=response.agent_id,
)
if job is not None:
logger.info(f"Queued {provider} sync for lead {response.id}")
return job
async def process_job(db: Session, job: CrmSyncJob) -> CrmSyncJob:
"""Run one claimed sync job to a terminal or retry state.
Safe to re-run: the adapters' push sequences converge (HubSpot upsert,
Pipedrive search-first + open-lead check)."""
jobs = CrmSyncJobRepository(db)
# Re-checked at run time: the org may have downgraded since enqueue.
if not feature_allowed(db, job.organization_id, "crm_sync"):
return jobs.skip(job, "plan no longer includes crm_sync")
connections = CrmConnectionRepository(db)
connection = connections.get_active(job.organization_id, job.provider)
if connection is None:
# Terminal: retrying cannot help until a human reconnects, and the
# reconnect path enqueues nothing retroactively — the UI surfaces it.
return jobs.fail(job, "no_connection")
response = db.get(LeadCaptureResponse, job.lead_response_id)
if response is None:
return jobs.skip(job, "lead response deleted")
payload = build_lead_payload(
response,
_config_for_agent(db, response.agent_id),
db.get(Customer, response.customer_id) if response.customer_id else None,
)
if not payload.email:
return jobs.skip(job, "lead has no email")
adapter = get_adapter(job.provider)
try:
tokens = await get_valid_tokens(db, connection)
except CrmAuthError as e:
return jobs.fail(job, f"auth: {e}", connection_id=connection.id)
except CrmTransientError as e:
return jobs.schedule_retry(job, f"token refresh failed: {e}")
result = await adapter.push_lead(tokens, payload)
if result.auth_failed:
# The stored expiry looked fine but the provider said no — force one
# refresh and retry before declaring the connection dead.
try:
tokens = await get_valid_tokens(db, connection, force_refresh=True)
except (CrmAuthError, CrmTransientError) as e:
return jobs.fail(job, f"auth: {e}", connection_id=connection.id)
result = await adapter.push_lead(tokens, payload)
if result.auth_failed:
connections.set_status(connection, CrmConnectionStatus.EXPIRED.value,
last_error=result.error)
return jobs.fail(job, f"auth: {result.error}", connection_id=connection.id)
if result.ok:
logger.info(f"Pushed lead {response.id} to {job.provider} "
f"({result.action} {result.contact_id})")
# Record the per-person link so the People drawer can show it.
if response.customer_id:
_record_customer_sync(db, job.organization_id, response.customer_id,
job.provider, result)
return jobs.complete(job, {
"action": result.action,
"contact_id": result.contact_id,
"secondary_id": result.secondary_id,
"record_url": result.record_url,
}, connection_id=connection.id)
if result.retryable:
return jobs.schedule_retry(job, result.error or "retryable failure",
retry_after_seconds=result.retry_after_seconds)
return jobs.fail(job, result.error or "push failed", connection_id=connection.id)
class CrmManualSyncError(Exception):
"""A manual "Sync now" could not be completed (surfaced to the user)."""
async def sync_customer_to_crm(db: Session, customer: Customer) -> list:
"""Manual "Sync now" from the People drawer: push this person to every
active CRM connection in their org and record each link. Synchronous so
the UI gets an immediate result. Returns the list of recorded links.
Raises CrmManualSyncError for user-facing failures (no email, no
connection, plan gate, or a definitive provider rejection)."""
from app.repositories.customer import CustomerRepository
# Anonymous visitors carry a placeholder stand-in (…@noemail.com / .channel)
# that can't receive mail — never push it to a CRM.
if CustomerRepository.is_placeholder_email(customer.email):
raise CrmManualSyncError("Add an email to this person before syncing to a CRM.")
if not feature_allowed(db, customer.organization_id, "crm_sync"):
raise CrmManualSyncError("CRM sync is not available in your current plan.")
connections = CrmConnectionRepository(db).list_by_org(customer.organization_id)
active = [c for c in connections if c.status == CrmConnectionStatus.ACTIVE.value]
if not active:
raise CrmManualSyncError("Connect a CRM in Settings → Integrations first.")
attributes, summary, config = _lead_capture_context(db, customer)
payload = build_customer_payload(customer, attributes=attributes, summary=summary, config=config)
records, errors = [], []
for connection in active:
adapter = get_adapter(connection.provider)
if adapter is None:
continue
try:
tokens = await get_valid_tokens(db, connection)
result = await adapter.push_lead(tokens, payload)
if result.auth_failed:
tokens = await get_valid_tokens(db, connection, force_refresh=True)
result = await adapter.push_lead(tokens, payload)
except CrmAuthError:
errors.append(f"{connection.provider}: reconnect required")
continue
except CrmTransientError:
errors.append(f"{connection.provider}: temporary error, try again")
continue
if result.ok:
records.append(_record_customer_sync(
db, customer.organization_id, customer.id, connection.provider, result))
elif result.auth_failed:
connections_repo = CrmConnectionRepository(db)
connections_repo.set_status(connection, CrmConnectionStatus.EXPIRED.value,
last_error=result.error)
errors.append(f"{connection.provider}: reconnect required")
else:
errors.append(f"{connection.provider}: {result.error or 'push failed'}")
if not records and errors:
raise CrmManualSyncError("; ".join(errors))
return records
def _record_customer_sync(db: Session, organization_id, customer_id, provider: str,
result: CrmPushResult):
return CrmCustomerSyncRepository(db).record(
organization_id=organization_id,
customer_id=customer_id,
provider=provider,
contact_id=result.contact_id,
secondary_id=result.secondary_id,
record_url=result.record_url,
)
def _lead_capture_context(db: Session, customer: Customer):
"""Merge everything the person's lead captures produced — attributes
(latest value wins per key), the most recent AI summary, and the latest
response's agent config for custom-field labels — mirroring what the People
drawer shows, so a manual push sends the full picture, not just email.
Returns (attributes, summary, config)."""
responses = (
db.query(LeadCaptureResponse)
.filter(LeadCaptureResponse.customer_id == customer.id)
.order_by(LeadCaptureResponse.created_at)
.all()
)
attributes: dict = {}
summary = None
latest_agent_id = None
for r in responses:
for key, value in (r.field_values or {}).items():
if value not in (None, ""):
attributes[key] = value
if r.summary:
summary = r.summary
if r.agent_id:
latest_agent_id = r.agent_id
config = _config_for_agent(db, latest_agent_id) if latest_agent_id else None
return attributes, summary, config
def _config_for_agent(db: Session, agent_id) -> Optional[LeadCaptureConfig]:
if agent_id is None:
return None
return db.query(LeadCaptureConfig).filter(
LeadCaptureConfig.agent_id == agent_id).first()
+122
View File
@@ -0,0 +1,122 @@
"""
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 sqlalchemy.orm import Session
from app.core.logger import get_logger
from app.crm.base import CrmAuthError, OAuthTokens
from app.crm.registry import get_adapter
from app.models.crm import CrmConnection, CrmConnectionStatus
from app.repositories.crm import CrmConnectionRepository
logger = get_logger(__name__)
# Refresh when the access token has less runway than this — covers clock skew
# and the request's own latency.
EXPIRY_MARGIN_SECONDS = 120
def tokens_from_connection(repo: CrmConnectionRepository,
connection: CrmConnection) -> OAuthTokens:
"""Decrypt a connection into the adapter-facing token dataclass."""
credentials = repo.get_credentials(connection)
return OAuthTokens(
access_token=credentials.get("access_token", ""),
refresh_token=credentials.get("refresh_token", ""),
expires_at=_aware(connection.access_token_expires_at),
api_domain=credentials.get("api_domain"),
refresh_token_expires_at=_aware(connection.refresh_token_expires_at),
external_account_id=connection.external_account_id or "",
display_name=connection.display_name,
)
def persist_tokens(repo: CrmConnectionRepository, connection: CrmConnection,
tokens: OAuthTokens) -> None:
"""Re-encrypt the blob and mirror expiries onto queryable columns."""
repo.save_credentials(
connection,
{
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"api_domain": tokens.api_domain,
},
access_token_expires_at=tokens.expires_at,
refresh_token_expires_at=tokens.refresh_token_expires_at,
)
async def get_valid_tokens(db: Session, connection: CrmConnection,
force_refresh: bool = False) -> OAuthTokens:
"""Decrypted tokens with a live access token, refreshing when needed.
Raises CrmAuthError after marking the connection expired when the provider
definitively rejects our grant (the UI then shows "Reconnect");
CrmTransientError propagates untouched so callers can retry.
"""
repo = CrmConnectionRepository(db)
tokens = tokens_from_connection(repo, connection)
if not force_refresh and _has_runway(tokens.expires_at):
return tokens
# A refresh is needed. Serialize per connection: Pipedrive rotates the
# refresh token on every use, so two concurrent refreshers would send the
# same (now-consumed) token — the loser gets invalid_grant and would
# falsely mark the connection expired. Lock the row and re-read the current
# tokens under the lock (populate_existing bypasses the identity-map cache),
# so we refresh at most once and always with the latest token. FOR UPDATE is
# a no-op on SQLite (tests), which is fine.
locked = (
db.query(CrmConnection)
.filter(CrmConnection.id == connection.id)
.populate_existing()
.with_for_update()
.first()
) or connection
tokens = tokens_from_connection(repo, locked)
if not force_refresh and _has_runway(tokens.expires_at):
db.commit() # another worker refreshed while we waited — release the lock
return tokens
adapter = get_adapter(locked.provider)
if adapter is None:
raise CrmAuthError(f"No adapter for provider '{locked.provider}'")
try:
refreshed = await adapter.refresh_tokens(tokens)
except CrmAuthError as e:
logger.warning(f"CRM refresh rejected for {locked.provider} "
f"connection {locked.id}: {e}")
repo.set_status(locked, CrmConnectionStatus.EXPIRED.value, last_error=str(e))
raise
persist_tokens(repo, locked, refreshed)
return refreshed
def _has_runway(expires_at: Optional[datetime]) -> bool:
if expires_at is None:
return False
margin = datetime.now(timezone.utc) + timedelta(seconds=EXPIRY_MARGIN_SECONDS)
return _aware(expires_at) > margin
def _aware(dt: Optional[datetime]) -> Optional[datetime]:
"""Normalize DB datetimes: PG returns tz-aware, SQLite (tests) naive-UTC."""
if dt is None or dt.tzinfo is not None:
return dt
return dt.replace(tzinfo=timezone.utc)
+323
View File
@@ -0,0 +1,323 @@
"""
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 re
from typing import Optional, Dict, Any
from uuid import UUID
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.models.lead_capture import LeadCaptureConfig, LeadCaptureResponse
from app.repositories.customer import CustomerRepository
from app.services.widget_identity import can_act_as
from app.core.logger import get_logger
logger = get_logger(__name__)
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def has_captured_lead(db: Session, customer_id, agent_id) -> bool:
"""True once a lead has been recorded for this customer + agent (capture once)."""
return db.query(LeadCaptureResponse.id).filter(
LeadCaptureResponse.customer_id == customer_id,
LeadCaptureResponse.agent_id == agent_id,
).first() is not None
def _valid_email(email: Optional[str]) -> bool:
return bool(email and _EMAIL_RE.match(email.strip()))
def record_lead_capture(
db: Session,
config: LeadCaptureConfig,
organization_id: UUID,
agent_id,
customer_id,
session_id,
lead_data: Optional[Dict[str, Any]],
summary: Optional[str],
consent: bool,
page_url: Optional[str] = None,
channel: str = "widget",
*,
scope: Optional[str],
) -> Optional[LeadCaptureResponse]:
"""Persist an AI-captured lead (fields the agent extracted conversationally +
an AI qualification summary). Returns None — recording nothing — when there is
no valid email, or consent is required but not given (GDPR).
Never records when lead capture is disabled for the agent, or when the customer
is integration-authenticated (identified via generate-token) — those are the
business's existing customers, not leads.
If the captured email already belongs to ANOTHER customer in the org, the
anonymous visitor is MERGED into that existing customer (history/sessions
reassigned, visitor row marked merged) so People shows one entry. The returned
response's customer_id is the final (possibly merged-into) customer.
`scope` is the conversation-token scope of the visitor (VERIFIED_SCOPE for
channels, whose identity the platform vouches for). A widget visitor may be
bound to a customer by an email they only typed, so a lead is only recorded
on a customer that visitor created (widget_identity.can_act_as), and only
merged into one it may also act as. Otherwise nothing is recorded.
A recorded lead is queued for CRM push (post-commit, best-effort) when the
agent has a crm_sync_target configured. Slack/assignment routing remains
stored-only.
"""
from app.models.customer import Customer
# Never capture when lead capture is off for this agent (authoritative guard,
# independent of any caller-side check).
if not (config and config.enabled):
return None
# Never capture integration-authenticated people (identified via generate-token) —
# they're already the business's customers, not leads, regardless of the agent's
# toggle. Uses the explicit flag, not meta_data (see Customer.is_authenticated).
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if customer is not None and customer.is_authenticated:
return None
lead_data = lead_data or {}
# Keep only configured field keys (defensive: the model shouldn't inject others).
field_keys = {f.get("key") for f in (config.fields or []) if f.get("key")}
if field_keys:
field_values = {k: v for k, v in lead_data.items() if k in field_keys and v}
else:
field_values = {k: v for k, v in lead_data.items() if v}
email = (str(field_values.get("email") or "")).strip()
if not _valid_email(email):
return None # need at least a valid email to have a usable lead
if config.require_consent and not consent:
return None # GDPR: never record without explicit consent
name = (str(field_values.get("name") or "")).strip() or None
# If this email already belongs to a different customer in the org, merge the
# current (anonymous) visitor into that customer and record the lead there.
existing = (
db.query(Customer)
.filter(
Customer.organization_id == organization_id,
Customer.email == email,
Customer.id != customer_id,
Customer.merged_into_customer_id.is_(None),
)
.first()
)
# A visitor bound to this customer by an email they only typed records
# nothing against them: no lead, no contact details, no CRM push - and the
# real customer is still asked for their details next time.
if not can_act_as(db, customer, scope):
logger.info(f"Lead capture: not recording for customer {customer_id}; the visitor does not own it")
return None
# Merging moves everything of one customer onto another, so the visitor must
# be able to act as both. For a widget visitor that rules out merging into
# anyone who already existed - an email they typed proves nothing - and the
# lead stays on their own row.
if existing is not None and not can_act_as(db, existing, scope):
logger.info(f"Lead capture: not merging customer {customer_id}; the visitor does not own both")
existing = None
# Merge (reassigning history across rows), the new response row, the contact
# update, and the promotion must all commit together — roll back on any failure
# so a mid-merge error can never leave a half-merged customer.
try:
target_customer_id = customer_id
if existing is not None:
_merge_customer(db, source_id=customer_id, target=existing)
target_customer_id = existing.id
response = LeadCaptureResponse(
organization_id=organization_id,
agent_id=agent_id,
customer_id=target_customer_id,
session_id=session_id,
field_values=field_values or None,
summary=(summary or None),
consent=bool(consent),
qualified=True,
)
db.add(response)
# Update the customer's contact details so the People page / inbox show a real
# email/name (reuses the handoff-capture logic + unique-constraint handling).
# Phone included: an AI-captured number makes a widget lead WhatsApp-addressable.
# Deliberately strict: a captured number without a '+' country code is a
# national number we cannot resolve to one person worldwide, so it is
# dropped (logged) rather than guessed at — a wrong-country identity key
# is worse than an unset one.
CustomerRepository(db).update_contact(
target_customer_id, email=email or None, full_name=name,
phone=(field_values or {}).get('phone'))
# Promote Visitor -> Lead; never downgrade an existing Customer.
_promote_to_lead(db, target_customer_id, session_id, page_url=page_url, channel=channel)
db.commit()
except Exception:
db.rollback()
raise
db.refresh(response)
# Post-commit so a job can never reference a rolled-back lead; wrapped so
# CRM problems can never break the conversation flow.
try:
from app.services.crm_sync import enqueue_crm_sync
enqueue_crm_sync(db, config, response)
except Exception as e:
logger.error(f"CRM sync enqueue failed (lead still recorded): {e}")
return response
def _merge_customer(db: Session, source_id, target) -> None:
"""Merge the (anonymous) source customer into the identified target: reassign
conversation history / sessions / lead responses, then mark the source row as
merged so it disappears from People and stale device tokens resolve to target."""
from app.models.customer import Customer
from app.models.chat_history import ChatHistory
from app.models.session_to_agent import SessionToAgent
from app.models.rating import Rating
source = db.query(Customer).filter(Customer.id == source_id).first()
if not source or source.id == target.id:
return
db.query(ChatHistory).filter(ChatHistory.customer_id == source_id).update(
{ChatHistory.customer_id: target.id}, synchronize_session=False)
db.query(SessionToAgent).filter(SessionToAgent.customer_id == source_id).update(
{SessionToAgent.customer_id: target.id}, synchronize_session=False)
db.query(LeadCaptureResponse).filter(LeadCaptureResponse.customer_id == source_id).update(
{LeadCaptureResponse.customer_id: target.id}, synchronize_session=False)
db.query(Rating).filter(Rating.customer_id == source_id).update(
{Rating.customer_id: target.id}, synchronize_session=False)
# Carry over a name the target lacks; keep the row for token resolution.
if not (target.full_name or "").strip() and (source.full_name or "").strip():
target.full_name = source.full_name
# Identifiers union on merge: the phone follows the surviving person. The
# source is cleared and FLUSHED before the target is set — the unique index
# on (organization_id, phone) is checked per-UPDATE, and SQLAlchemy orders
# same-mapper UPDATEs by primary key (random UUIDs), so without the flush
# the target could gain the phone while the source still holds it in the
# database, failing the whole merge intermittently.
#
# The clear is UNCONDITIONAL, even when the target already has a number and
# there is nowhere to move it to. A merged row is invisible in People and
# resolves to the target everywhere a human looks — but phone lookup is the
# FIRST thing get_or_create_customer tries, so a tombstone left holding a
# live number would silently capture that person's next WhatsApp message
# onto a row nobody can see or edit.
if source.phone:
moved_phone = source.phone
source.phone = None
db.flush()
if not target.phone:
target.phone = moved_phone
elif target.phone != moved_phone:
# Two real numbers, one column: the survivor keeps theirs. Logged
# because it is the one thing this merge cannot preserve — a
# customer_identifiers table is what would (see the plan's
# evolution path), not a second column.
logger.info(
f"Merge {source.id} -> {target.id}: dropped second phone "
f"{moved_phone} (target keeps {target.phone})"
)
source.merged_into_customer_id = target.id
source.is_active = False
def _promote_to_lead(db: Session, customer_id, session_id, page_url: Optional[str] = None,
channel: str = "widget") -> None:
from app.models.customer import Customer, LeadStage
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
return
if customer.lead_stage == LeadStage.VISITOR:
customer.lead_stage = LeadStage.LEAD
customer.lead_qualified_at = datetime.now(timezone.utc)
# Always keep the capture context fresh (also for repeat captures on a Lead).
source = dict(customer.lead_source or {})
source.setdefault("channel", channel)
source["captured_at"] = datetime.now(timezone.utc).isoformat()
source["session_id"] = str(session_id) if session_id else None
if page_url:
source["page_url"] = page_url[:500]
customer.lead_source = source
def record_lead_from_response(
db: Session,
response,
organization_id,
agent_id,
customer_id,
session_id,
page_url: Optional[str] = None,
channel: str = "widget",
*,
scope: Optional[str],
) -> Optional[LeadCaptureResponse]:
"""Persist a lead from a ChatResponse whose request_lead_capture is set.
Shared by the widget socket handler and external channel processors so
every channel records leads identically: loads the agent's lead-capture
config, assembles lead_data from the explicit scalar fields (reliable
under strict structured outputs) plus any free-form lead_data dict, and
delegates validation/merge/promotion to record_lead_capture. Returns the
LeadCaptureResponse (whose customer_id may differ from the input when the
visitor was merged into an existing customer), or None if nothing was
recorded. Never raises.
"""
try:
if not getattr(response, 'request_lead_capture', False):
return None
from app.repositories.agent import AgentRepository
agent = AgentRepository(db).get_agent(agent_id)
config = getattr(agent, 'lead_capture_config', None) if agent else None
if not (config and config.enabled) or has_captured_lead(db, customer_id, agent_id):
return None
lead_data = dict(getattr(response, 'lead_data', None) or {})
for key, attr in (('email', 'lead_email'), ('name', 'lead_name'),
('company', 'lead_company'), ('phone', 'lead_phone')):
val = getattr(response, attr, None)
if val and not lead_data.get(key):
lead_data[key] = val
return record_lead_capture(
db, config,
organization_id=organization_id,
agent_id=agent_id,
customer_id=customer_id,
session_id=session_id,
lead_data=lead_data,
summary=getattr(response, 'lead_summary', None),
consent=getattr(response, 'lead_consent', False),
page_url=page_url,
channel=channel,
scope=scope,
)
except Exception as e:
# Lead capture must never break the conversation flow.
logger.error(f"Lead-capture record error: {e}")
return None