Aimpress_site/chatbot-api/twenty_crm.py
Vadym Samoilenko bc80f7667c Use bodyV2 markdown for CRM notes and transcripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:45:31 +00:00

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