Add voxtelesys_integration/api.py

This commit is contained in:
Zac Gaetano 2026-05-11 09:14:32 -04:00
parent f77b855a15
commit ac64d86e99

View file

@ -0,0 +1,127 @@
"""
voxtelesys_integration.api
==========================
Single whitelisted endpoint called by 3CX Call Flow Designer (CF_URLFetch)
when an inbound call arrives.
3CX sends a POST with these variables set in the Call Flow:
caller_id - the inbound caller's number (e.g. +12025551234)
called_did - the DID that was dialled (optional, for routing info)
call_id - 3CX internal call ID (optional, for deduplication)
The endpoint creates an HD Ticket in Frappe Helpdesk and returns JSON.
Endpoint URL to put in 3CX:
https://erp.broadcastmgmt.cloud/api/method/voxtelesys_integration.api.inbound_call
No authentication required (allow_guest=True) protect via 3CX secret token
in the request if desired (see WEBHOOK_SECRET below).
"""
import frappe
from frappe import _
# Optional: set this in Site Config as voxtelesys_webhook_secret = "yourtoken"
# 3CX should pass it as ?secret=yourtoken in the URL.
# Leave blank to accept all requests (fine if ERPNext is behind a firewall).
@frappe.whitelist(allow_guest=True)
def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", **kwargs):
"""
Called by 3CX CF_URLFetch on every inbound call.
Creates an HD Ticket and returns the ticket name.
"""
_validate_secret()
caller_id = (caller_id or "").strip()
if not caller_id:
frappe.local.response["http_status_code"] = 400
return {"ok": False, "error": "caller_id is required"}
# Deduplication: if a ticket was already created for this call_id, return it
if call_id:
existing = frappe.db.get_value(
"HD Ticket",
{"custom_3cx_call_id": call_id},
"name",
)
if existing:
return {"ok": True, "ticket": existing, "duplicate": True}
# Look up a Contact matching the caller number so we can pre-fill the ticket
contact_name, contact_email = _find_contact(caller_id)
try:
ticket = frappe.new_doc("HD Ticket")
ticket.subject = f"Inbound Call from {caller_id}"
ticket.description = (
f"An inbound call was received from <b>{frappe.utils.escape_html(caller_id)}</b>."
+ (f"<br>DID dialled: {frappe.utils.escape_html(called_did)}" if called_did else "")
+ (f"<br>3CX Call ID: {frappe.utils.escape_html(call_id)}" if call_id else "")
)
# Pre-fill contact/email if we found a match
if contact_email:
ticket.raised_by = contact_email
if contact_name:
ticket.contact = contact_name
# Store the 3CX call ID for deduplication (requires custom field — see README)
if call_id and frappe.db.has_column("HD Ticket", "custom_3cx_call_id"):
ticket.custom_3cx_call_id = call_id
ticket.insert(ignore_permissions=True)
frappe.db.commit()
frappe.logger().info(
f"[Voxtelesys] Created HD Ticket {ticket.name} for inbound call from {caller_id}"
)
return {"ok": True, "ticket": ticket.name}
except Exception:
frappe.log_error(frappe.get_traceback(), "Voxtelesys: Failed to create HD Ticket")
frappe.local.response["http_status_code"] = 500
return {"ok": False, "error": "Failed to create ticket — check Error Log"}
def _validate_secret():
"""
If voxtelesys_webhook_secret is set in site_config.json, require it
to be passed as ?secret= in the request URL.
"""
expected = frappe.conf.get("voxtelesys_webhook_secret")
if not expected:
return # no secret configured — allow all
provided = frappe.request.args.get("secret") or frappe.form_dict.get("secret") or ""
if provided != expected:
frappe.throw(_("Unauthorized"), frappe.PermissionError)
def _find_contact(phone_number: str):
"""
Look up a Contact by phone number. Returns (contact_name, email) or (None, None).
Strips +, spaces, and dashes for a loose suffix match.
"""
if not phone_number:
return None, None
normalised = phone_number.replace("+", "").replace(" ", "").replace("-", "")
result = frappe.db.sql(
"""
SELECT cp.parent, c.email_id
FROM `tabContact Phone` cp
JOIN `tabContact` c ON c.name = cp.parent
WHERE REPLACE(REPLACE(REPLACE(cp.phone, '+', ''), ' ', ''), '-', '') LIKE %(num)s
LIMIT 1
""",
{"num": f"%{normalised}"},
as_dict=True,
)
if result:
return result[0].parent, result[0].email_id
return None, None