Files
replypier/backend/app/services/lead_capture.py
T

323 lines
14 KiB
Python
Raw Normal View History

"""
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 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