Initial commit: avc-phone-ai codebase + CLAUDE.md
This commit is contained in:
106
odoo_client.py
Normal file
106
odoo_client.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""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
|
||||
|
||||
|
||||
class OdooError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _connect():
|
||||
if not (ODOO_USER and ODOO_API_KEY):
|
||||
raise OdooError("ODOO_USER / ODOO_API_KEY not set")
|
||||
common = xmlrpc.client.ServerProxy(f"{ODOO_URL}/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 = xmlrpc.client.ServerProxy(f"{ODOO_URL}/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, call_sid=None):
|
||||
"""Create the request in Odoo. Returns (model, record_id) or raises OdooError."""
|
||||
uid, models = _connect()
|
||||
summary = f"📞 Phone appt request — {patient_name or 'caller'}"
|
||||
# description is an Odoo HTML field — build with <br/> so it renders in the UI.
|
||||
rows = [
|
||||
("Name", patient_name),
|
||||
("Callback", callback_number),
|
||||
("Reason", reason),
|
||||
("Preferred time (patient's words)", preferred_time),
|
||||
("Twilio call SID", call_sid),
|
||||
]
|
||||
note = "<p><b>Captured by the AVC phone agent</b> (UNCONFIRMED — call patient to finalize).</p><p>" + \
|
||||
"<br/>".join(f"{escape(k)}: {escape(str(v)) if v else '—'}" for k, v in rows) + "</p>"
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user