v2.1: docs + boot_session sms_enabled + legacy api header: api.py

This commit is contained in:
Zac Gaetano 2026-05-12 00:14:30 -04:00
parent f68cae0c4a
commit b977800868

View file

@ -1,37 +1,35 @@
""" """
voxtelesys_integration.api voxtelesys_integration.api (LEGACY 3CX bridge path)
========================== ====================================================
Single whitelisted endpoint called by 3CX Call Flow Designer (CF_URLFetch) This module implements the v1.x "3CX webhook" architecture where 3CX is the
when an inbound call arrives. PBX call broker, the Voxtelesys SIP trunk is just the carrier, and ERPNext
is a passive ticket creator notified by 3CX's CF_URLFetch block.
3CX sends a POST with these variables set in the Call Flow: For the v2.x direct-API architecture (ERPNext talks to Voxtelesys APIs and
caller_id - the inbound caller's number (e.g. +12025551234) brokers calls itself), see api/voxtelesys.py and api/sms.py.
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. Both paths can coexist. Use this one if your existing 3CX deployment is
already routing calls and you only want auto-ticketing in ERPNext; use the
Endpoint URL to put in 3CX: direct path if you want click-to-call, agent routing, SMS, and call
https://erp.broadcastmgmt.cloud/api/method/voxtelesys_integration.api.inbound_call recording driven from ERPNext.
No authentication required (allow_guest=True) protect via 3CX secret token
in the request if desired (see WEBHOOK_SECRET below).
""" """
import frappe import frappe
from frappe import _ 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) @frappe.whitelist(allow_guest=True)
def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", **kwargs): def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", **kwargs):
""" """3CX CF_URLFetch entrypoint — creates an HD Ticket on every inbound call.
Called by 3CX CF_URLFetch on every inbound call.
Creates an HD Ticket and returns the ticket name. Endpoint URL:
https://<site>/api/method/voxtelesys_integration.api.inbound_call
3CX passes:
caller_id inbound caller's number (e.g. +12025551234)
called_did DID that was dialled (optional, for routing info)
call_id 3CX internal call ID (optional, for deduplication)
""" """
_validate_secret() _validate_secret()
@ -40,7 +38,6 @@ def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", *
frappe.local.response["http_status_code"] = 400 frappe.local.response["http_status_code"] = 400
return {"ok": False, "error": "caller_id is required"} return {"ok": False, "error": "caller_id is required"}
# Deduplication: if a ticket was already created for this call_id, return it
if call_id: if call_id:
existing = frappe.db.get_value( existing = frappe.db.get_value(
"HD Ticket", "HD Ticket",
@ -50,7 +47,6 @@ def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", *
if existing: if existing:
return {"ok": True, "ticket": existing, "duplicate": True} 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) contact_name, contact_email = _find_contact(caller_id)
try: try:
@ -62,13 +58,11 @@ def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", *
+ (f"<br>3CX Call ID: {frappe.utils.escape_html(call_id)}" if call_id 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: if contact_email:
ticket.raised_by = contact_email ticket.raised_by = contact_email
if contact_name: if contact_name:
ticket.contact = 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"): if call_id and frappe.db.has_column("HD Ticket", "custom_3cx_call_id"):
ticket.custom_3cx_call_id = call_id ticket.custom_3cx_call_id = call_id
@ -76,34 +70,29 @@ def inbound_call(caller_id: str = "", called_did: str = "", call_id: str = "", *
frappe.db.commit() frappe.db.commit()
frappe.logger().info( frappe.logger().info(
f"[Voxtelesys] Created HD Ticket {ticket.name} for inbound call from {caller_id}" f"[Voxtelesys/3CX] Created HD Ticket {ticket.name} for inbound call from {caller_id}"
) )
return {"ok": True, "ticket": ticket.name} return {"ok": True, "ticket": ticket.name}
except Exception: except Exception:
frappe.log_error(frappe.get_traceback(), "Voxtelesys: Failed to create HD Ticket") frappe.log_error(frappe.get_traceback(), "Voxtelesys/3CX: Failed to create HD Ticket")
frappe.local.response["http_status_code"] = 500 frappe.local.response["http_status_code"] = 500
return {"ok": False, "error": "Failed to create ticket — check Error Log"} return {"ok": False, "error": "Failed to create ticket — check Error Log"}
def _validate_secret(): def _validate_secret():
""" """If voxtelesys_webhook_secret is set in site_config.json, require it
If voxtelesys_webhook_secret is set in site_config.json, require it to be passed as ?secret= in the request URL."""
to be passed as ?secret= in the request URL.
"""
expected = frappe.conf.get("voxtelesys_webhook_secret") expected = frappe.conf.get("voxtelesys_webhook_secret")
if not expected: if not expected:
return # no secret configured — allow all return
provided = frappe.request.args.get("secret") or frappe.form_dict.get("secret") or "" provided = frappe.request.args.get("secret") or frappe.form_dict.get("secret") or ""
if provided != expected: if provided != expected:
frappe.throw(_("Unauthorized"), frappe.PermissionError) frappe.throw(_("Unauthorized"), frappe.PermissionError)
def _find_contact(phone_number: str): def _find_contact(phone_number: str):
""" """Loose suffix match on Contact Phone — strips +, spaces, dashes."""
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: if not phone_number:
return None, None return None, None