394 lines
13 KiB
Python
394 lines
13 KiB
Python
import httpx
|
|
import logging
|
|
from config import settings
|
|
|
|
logger = logging.getLogger("twenty_crm")
|
|
|
|
BASE_URL = f"{settings.twenty_crm_url}/rest"
|
|
HEADERS = {
|
|
"Authorization": f"Bearer {settings.twenty_crm_api_key}",
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
|
|
def _split_name(full_name: str) -> tuple[str, str]:
|
|
parts = full_name.strip().split(maxsplit=1)
|
|
return parts[0], parts[1] if len(parts) > 1 else ""
|
|
|
|
|
|
async def find_person_by_email(email: str) -> dict | None:
|
|
"""Find existing person by email."""
|
|
if not settings.twenty_crm_api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient() as http:
|
|
resp = await http.get(
|
|
f"{BASE_URL}/people",
|
|
headers=HEADERS,
|
|
params={"filter": f'emails.primaryEmail[eq]:{email}', "limit": 1},
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 200:
|
|
data = resp.json().get("data", {}).get("people", [])
|
|
return data[0] if data else None
|
|
except Exception as e:
|
|
logger.error(f"Twenty find_person error: {e}")
|
|
return None
|
|
|
|
|
|
async def create_or_update_company(name: str, domain: str = "") -> str | None:
|
|
"""Create company in Twenty CRM, return company ID."""
|
|
if not settings.twenty_crm_api_key or not name:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient() as http:
|
|
# Check if company exists
|
|
resp = await http.get(
|
|
f"{BASE_URL}/companies",
|
|
headers=HEADERS,
|
|
params={"filter": f'name[eq]:{name}', "limit": 1},
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 200:
|
|
companies = resp.json().get("data", {}).get("companies", [])
|
|
if companies:
|
|
return companies[0]["id"]
|
|
|
|
# Create new company
|
|
body = {"name": name}
|
|
if domain:
|
|
body["domainName"] = {
|
|
"primaryLinkLabel": "",
|
|
"primaryLinkUrl": domain,
|
|
"secondaryLinks": [],
|
|
}
|
|
resp = await http.post(
|
|
f"{BASE_URL}/companies",
|
|
headers=HEADERS,
|
|
json=body,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 201:
|
|
return resp.json()["data"]["createCompany"]["id"]
|
|
logger.error(f"Twenty create_company: {resp.status_code} {resp.text}")
|
|
except Exception as e:
|
|
logger.error(f"Twenty create_company error: {e}")
|
|
return None
|
|
|
|
|
|
async def create_person(
|
|
name: str,
|
|
email: str,
|
|
company_id: str | None = None,
|
|
job_title: str = "",
|
|
city: str = "",
|
|
phone: str = "",
|
|
) -> str | None:
|
|
"""Create person in Twenty CRM, return person ID."""
|
|
if not settings.twenty_crm_api_key:
|
|
return None
|
|
try:
|
|
first, last = _split_name(name)
|
|
body: dict = {
|
|
"name": {"firstName": first, "lastName": last},
|
|
"emails": {"primaryEmail": email, "additionalEmails": []},
|
|
}
|
|
if company_id:
|
|
body["companyId"] = company_id
|
|
if job_title:
|
|
body["jobTitle"] = job_title
|
|
if city:
|
|
body["city"] = city
|
|
if phone:
|
|
body["phones"] = {
|
|
"primaryPhoneNumber": phone,
|
|
"primaryPhoneCountryCode": "",
|
|
"primaryPhoneCallingCode": "",
|
|
"additionalPhones": [],
|
|
}
|
|
|
|
async with httpx.AsyncClient() as http:
|
|
resp = await http.post(
|
|
f"{BASE_URL}/people",
|
|
headers=HEADERS,
|
|
json=body,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 201:
|
|
person_id = resp.json()["data"]["createPerson"]["id"]
|
|
logger.info(f"Twenty person created: {person_id} ({name})")
|
|
return person_id
|
|
# Handle duplicate — find existing person by email
|
|
if resp.status_code == 400 and "duplicate" in resp.text.lower():
|
|
existing = await find_person_by_email(email)
|
|
if existing:
|
|
logger.info(f"Twenty person already exists: {existing['id']} ({name})")
|
|
return existing["id"]
|
|
logger.error(f"Twenty create_person: {resp.status_code} {resp.text}")
|
|
except Exception as e:
|
|
logger.error(f"Twenty create_person error: {e}")
|
|
return None
|
|
|
|
|
|
async def update_person(person_id: str, updates: dict) -> bool:
|
|
"""Update person fields in Twenty CRM."""
|
|
if not settings.twenty_crm_api_key or not person_id:
|
|
return False
|
|
try:
|
|
async with httpx.AsyncClient() as http:
|
|
resp = await http.patch(
|
|
f"{BASE_URL}/people/{person_id}",
|
|
headers=HEADERS,
|
|
json=updates,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 200:
|
|
logger.info(f"Twenty person updated: {person_id}")
|
|
return True
|
|
logger.error(f"Twenty update_person: {resp.status_code} {resp.text}")
|
|
except Exception as e:
|
|
logger.error(f"Twenty update_person error: {e}")
|
|
return False
|
|
|
|
|
|
async def create_note(title: str, person_id: str | None = None, body: str = "") -> str | None:
|
|
"""Create a note in Twenty CRM, optionally linked to a person."""
|
|
if not settings.twenty_crm_api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient() as http:
|
|
note_data: dict = {"title": title}
|
|
if body:
|
|
note_data["bodyV2"] = {"markdown": body, "blocknote": None}
|
|
resp = await http.post(
|
|
f"{BASE_URL}/notes",
|
|
headers=HEADERS,
|
|
json=note_data,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code != 201:
|
|
logger.error(f"Twenty create_note: {resp.status_code} {resp.text}")
|
|
return None
|
|
|
|
note_id = resp.json()["data"]["createNote"]["id"]
|
|
|
|
# Link note to person via noteTargets
|
|
if person_id:
|
|
await http.post(
|
|
f"{BASE_URL}/noteTargets",
|
|
headers=HEADERS,
|
|
json={"noteId": note_id, "targetPerson": person_id},
|
|
timeout=10,
|
|
)
|
|
|
|
return note_id
|
|
except Exception as e:
|
|
logger.error(f"Twenty create_note error: {e}")
|
|
return None
|
|
|
|
|
|
async def create_opportunity(name: str, company_id: str | None = None, point_of_contact_id: str | None = None) -> str | None:
|
|
"""Create an opportunity in NEW stage."""
|
|
if not settings.twenty_crm_api_key:
|
|
return None
|
|
try:
|
|
body: dict = {"name": name, "stage": "NEW"}
|
|
if company_id:
|
|
body["companyId"] = company_id
|
|
if point_of_contact_id:
|
|
body["pointOfContactId"] = point_of_contact_id
|
|
async with httpx.AsyncClient() as http:
|
|
resp = await http.post(
|
|
f"{BASE_URL}/opportunities",
|
|
headers=HEADERS,
|
|
json=body,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code == 201:
|
|
opp_id = resp.json()["data"]["createOpportunity"]["id"]
|
|
logger.info(f"Twenty opportunity created: {opp_id} ({name})")
|
|
return opp_id
|
|
logger.error(f"Twenty create_opportunity: {resp.status_code} {resp.text}")
|
|
except Exception as e:
|
|
logger.error(f"Twenty create_opportunity error: {e}")
|
|
return None
|
|
|
|
|
|
async def create_task(
|
|
title: str,
|
|
person_id: str | None = None,
|
|
company_id: str | None = None,
|
|
status: str = "TODO",
|
|
) -> str | None:
|
|
"""Create a task in Twenty CRM, optionally linked to a person/company."""
|
|
if not settings.twenty_crm_api_key:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient() as http:
|
|
resp = await http.post(
|
|
f"{BASE_URL}/tasks",
|
|
headers=HEADERS,
|
|
json={"title": title, "status": status},
|
|
timeout=10,
|
|
)
|
|
if resp.status_code != 201:
|
|
logger.error(f"Twenty create_task: {resp.status_code} {resp.text}")
|
|
return None
|
|
|
|
task_id = resp.json()["data"]["createTask"]["id"]
|
|
|
|
# Link task to person
|
|
if person_id:
|
|
await http.post(
|
|
f"{BASE_URL}/taskTargets",
|
|
headers=HEADERS,
|
|
json={"taskId": task_id, "targetPerson": person_id},
|
|
timeout=10,
|
|
)
|
|
# Link task to company
|
|
if company_id:
|
|
await http.post(
|
|
f"{BASE_URL}/taskTargets",
|
|
headers=HEADERS,
|
|
json={"taskId": task_id, "targetCompany": company_id},
|
|
timeout=10,
|
|
)
|
|
|
|
logger.info(f"Twenty task created: {task_id} ({title})")
|
|
return task_id
|
|
except Exception as e:
|
|
logger.error(f"Twenty create_task error: {e}")
|
|
return None
|
|
|
|
|
|
async def save_conversation_transcript(
|
|
person_id: str, messages: list[dict], visitor_name: str = "Visitor"
|
|
) -> str | None:
|
|
"""Save full conversation transcript as a note linked to person."""
|
|
if not messages:
|
|
return None
|
|
|
|
lines = [f"💬 Chat transcript — {visitor_name}"]
|
|
for msg in messages:
|
|
role = msg.get("role", "unknown")
|
|
content = msg.get("content", "")
|
|
if isinstance(content, list):
|
|
content = " ".join(
|
|
b.get("text", "") for b in content if isinstance(b, dict)
|
|
)
|
|
# Skip system context lines
|
|
if content.startswith("[System:"):
|
|
continue
|
|
if role == "user":
|
|
lines.append(f"👤 {visitor_name}: {content}")
|
|
elif role == "assistant":
|
|
lines.append(f"🤖 Bot: {content}")
|
|
|
|
transcript = "\n".join(lines)
|
|
# Truncate to 10000 chars for CRM note body
|
|
if len(transcript) > 10000:
|
|
transcript = transcript[:9950] + "\n\n... (truncated)"
|
|
|
|
return await create_note(
|
|
f"💬 Chat transcript — {visitor_name}",
|
|
person_id,
|
|
body=transcript,
|
|
)
|
|
|
|
|
|
async def create_lead_in_crm(
|
|
name: str,
|
|
email: str,
|
|
company: str = "",
|
|
need: str = "",
|
|
job_title: str = "",
|
|
city: str = "",
|
|
phone: str = "",
|
|
page_context: str = "",
|
|
) -> str | None:
|
|
"""Full lead creation flow: company → person → note. Returns person ID."""
|
|
# Check if person already exists
|
|
existing = await find_person_by_email(email)
|
|
if existing:
|
|
person_id = existing["id"]
|
|
# Update with any new info
|
|
updates = {}
|
|
if job_title:
|
|
updates["jobTitle"] = job_title
|
|
if city:
|
|
updates["city"] = city
|
|
if updates:
|
|
await update_person(person_id, updates)
|
|
if need:
|
|
await create_note(
|
|
f"Chatbot: {need} (page: {page_context})", person_id
|
|
)
|
|
return person_id
|
|
|
|
# Create company first
|
|
company_id = None
|
|
if company:
|
|
company_id = await create_or_update_company(company)
|
|
|
|
# Create person
|
|
person_id = await create_person(
|
|
name=name,
|
|
email=email,
|
|
company_id=company_id,
|
|
job_title=job_title,
|
|
city=city,
|
|
phone=phone,
|
|
)
|
|
|
|
# Create note with the lead's need
|
|
if person_id and need:
|
|
await create_note(
|
|
f"Chatbot lead: {need} (page: {page_context})", person_id
|
|
)
|
|
|
|
# Create opportunity in NEW stage
|
|
if person_id:
|
|
opp_name = f"{name} — {need[:50]}" if need else name
|
|
await create_opportunity(opp_name, company_id=company_id, point_of_contact_id=person_id)
|
|
|
|
# Create follow-up task
|
|
if person_id:
|
|
task_title = f"Follow up: {name}"
|
|
if need:
|
|
task_title += f" — {need[:60]}"
|
|
await create_task(task_title, person_id=person_id, company_id=company_id)
|
|
|
|
return person_id
|
|
|
|
|
|
async def enrich_person(person_id: str, data: dict) -> bool:
|
|
"""Enrich person with additional data gathered during conversation."""
|
|
updates = {}
|
|
|
|
if data.get("job_title"):
|
|
updates["jobTitle"] = data["job_title"]
|
|
if data.get("city"):
|
|
updates["city"] = data["city"]
|
|
if data.get("phone"):
|
|
updates["phones"] = {
|
|
"primaryPhoneNumber": data["phone"],
|
|
"primaryPhoneCountryCode": "",
|
|
"primaryPhoneCallingCode": "",
|
|
"additionalPhones": [],
|
|
}
|
|
|
|
success = True
|
|
if updates:
|
|
success = await update_person(person_id, updates)
|
|
|
|
# Add conversation notes
|
|
if data.get("note"):
|
|
await create_note(data["note"], person_id)
|
|
|
|
# Update company info if provided
|
|
if data.get("company") and not data.get("_company_linked"):
|
|
company_id = await create_or_update_company(data["company"])
|
|
if company_id:
|
|
await update_person(person_id, {"companyId": company_id})
|
|
|
|
return success
|