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