"""Minimal Odoo XML-RPC client for the phone agent. Creates an appointment *request* in Odoo from a captured call. A request is NOT a confirmed booking — staff call the patient back to finalize — so by default we write a CRM lead (a clean "to-do" that doesn't occupy a real calendar slot). Set ODOO_TARGET=calendar to instead drop a tentative event on the calendar. Auth + target are all env-driven (see .env.example). Connection is lazy and every failure is swallowed by the caller's fallback, so a flaky Odoo never drops a request. """ import os import xmlrpc.client from datetime import datetime, timedelta from html import escape ODOO_URL = os.environ.get("ODOO_URL", "http://localhost:8069") ODOO_DB = os.environ.get("ODOO_DB", "db1") ODOO_USER = os.environ.get("ODOO_USER", "") ODOO_API_KEY = os.environ.get("ODOO_API_KEY", "") ODOO_TARGET = os.environ.get("ODOO_TARGET", "crm").lower() # "crm" | "calendar" # Pipeline placement for crm target. If ODOO_STAGE_ID is set, the request is created as a # staged opportunity (shows up in the CRM pipeline as a worklist) instead of a bare lead. ODOO_STAGE_ID = int(os.environ["ODOO_STAGE_ID"]) if os.environ.get("ODOO_STAGE_ID") else None ODOO_TEAM_ID = int(os.environ["ODOO_TEAM_ID"]) if os.environ.get("ODOO_TEAM_ID") else None ODOO_USER_ID = int(os.environ["ODOO_USER_ID"]) if os.environ.get("ODOO_USER_ID") else None # Hard cap on any single XML-RPC round-trip. Without it, xmlrpc.client uses the OS # default (no timeout): a HUNG Odoo (as opposed to a refused connection) would block # post-call persistence forever — and since the call slot is released only after # run_agent returns, two hung persists would leave the line permanently "busy". ODOO_TIMEOUT_SECS = float(os.environ.get("ODOO_TIMEOUT_SECS", "15")) class OdooError(RuntimeError): pass class _TimeoutTransport(xmlrpc.client.Transport): def make_connection(self, host): conn = super().make_connection(host) conn.timeout = ODOO_TIMEOUT_SECS return conn class _TimeoutSafeTransport(xmlrpc.client.SafeTransport): def make_connection(self, host): conn = super().make_connection(host) conn.timeout = ODOO_TIMEOUT_SECS return conn def _proxy(path): transport = (_TimeoutSafeTransport() if ODOO_URL.lower().startswith("https") else _TimeoutTransport()) return xmlrpc.client.ServerProxy(f"{ODOO_URL}{path}", transport=transport) def _connect(): if not (ODOO_USER and ODOO_API_KEY): raise OdooError("ODOO_USER / ODOO_API_KEY not set") common = _proxy("/xmlrpc/2/common") uid = common.authenticate(ODOO_DB, ODOO_USER, ODOO_API_KEY, {}) if not uid: raise OdooError("Odoo authentication failed (check db/user/key)") models = _proxy("/xmlrpc/2/object") return uid, models def _exec(uid, models, model, method, *args, **kw): return models.execute_kw(ODOO_DB, uid, ODOO_API_KEY, model, method, list(args), kw) def _find_or_create_partner(uid, models, name, phone): """Return a res.partner id, matching on phone first, else creating one.""" domain = [] if phone: domain = ["|", ["phone", "=", phone], ["mobile", "=", phone]] if domain: hit = _exec(uid, models, "res.partner", "search", domain, limit=1) if hit: return hit[0] vals = {"name": name or "Phone caller", "phone": phone or False, "company_type": "person"} return _exec(uid, models, "res.partner", "create", vals) def create_appointment_request(patient_name, callback_number, reason, preferred_time, insurance=None, call_sid=None, kind="appointment"): """Create the request in Odoo. `kind` is "appointment" or "callback" (a message for staff to call the caller back about something we can't do on the phone). Returns (model, id).""" uid, models = _connect() if kind == "callback": summary = f"📞 Callback request — {patient_name or 'caller'}" + (f": {reason}" if reason else "") header = "Captured by the AVC phone agent — CALL THE PATIENT BACK about this request." rows = [ ("Name", patient_name), ("Callback", callback_number), ("What they need", reason), ("Twilio call SID", call_sid), ] else: summary = f"📞 Phone appt — {patient_name or 'caller'}" + (f": {reason}" if reason else "") header = "Captured by the AVC phone agent (UNCONFIRMED — call patient to finalize)." rows = [ ("Name", patient_name), ("Callback", callback_number), ("Reason", reason), ("Insurance (log only — staff to verify coverage)", insurance), ("Preferred time (patient's words)", preferred_time), ("Twilio call SID", call_sid), ] # description is an Odoo HTML field — build with
so it renders in the UI. note = f"

{header}

" + \ "
".join(f"{escape(k)}: {escape(str(v)) if v else '—'}" for k, v in rows) + "

" if ODOO_TARGET == "calendar": partner_id = _find_or_create_partner(uid, models, patient_name, callback_number) # Tentative 30-min slot tomorrow 9:00 as a visible placeholder; real time set on callback. start = (datetime.utcnow() + timedelta(days=1)).replace(hour=9, minute=0, second=0, microsecond=0) vals = { "name": summary, "start": start.strftime("%Y-%m-%d %H:%M:%S"), "stop": (start + timedelta(minutes=30)).strftime("%Y-%m-%d %H:%M:%S"), "description": note, "partner_ids": [(4, partner_id)], } rec = _exec(uid, models, "calendar.event", "create", vals) return ("calendar.event", rec) # CRM target. With a stage configured, create a staged opportunity (lands in the # pipeline as a worklist staff act on); otherwise a plain lead. vals = { "name": summary, "contact_name": patient_name or False, "phone": callback_number or False, "description": note, "type": "opportunity" if ODOO_STAGE_ID else "lead", } if ODOO_STAGE_ID: vals["stage_id"] = ODOO_STAGE_ID if ODOO_TEAM_ID: vals["team_id"] = ODOO_TEAM_ID if ODOO_USER_ID: vals["user_id"] = ODOO_USER_ID rec = _exec(uid, models, "crm.lead", "create", vals) return ("crm.lead", rec)