diff --git a/voxtelesys_integration/api.py b/voxtelesys_integration/api.py new file mode 100644 index 0000000..7368fb8 --- /dev/null +++ b/voxtelesys_integration/api.py @@ -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 {frappe.utils.escape_html(caller_id)}." + + (f"
DID dialled: {frappe.utils.escape_html(called_did)}" if called_did else "") + + (f"
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