diff --git a/voxtelesys_integration/api/voxtelesys.py b/voxtelesys_integration/api/voxtelesys.py
index bab1dae..e2415d1 100644
--- a/voxtelesys_integration/api/voxtelesys.py
+++ b/voxtelesys_integration/api/voxtelesys.py
@@ -1,52 +1,99 @@
"""
voxtelesys_integration.api.voxtelesys
-All public-facing API methods for the Voxtelesys integration.
+=====================================
+
+Voxtelesys Voice API client + webhook handlers for direct ERPNext-to-Voxtelesys
+integration (no 3CX in the middle).
+
+Surface:
+ handle_inbound_call() — webhook: returns VoXML to ring an agent or hang up.
+ handle_call_status() — webhook: receives status callbacks, updates Call Log.
+ make_outbound_call() — RPC: places a click-to-call from the desk UI.
+ test_connection() — RPC: pings the Voxtelesys account endpoint.
+ boot_session() — exposes config to the browser as frappe.boot.voxtelesys.
+ get_linked_call_logs() — RPC: history sidebar feed.
+ sync_pending_call_logs() — scheduler: reconciles stuck Ringing/In Progress rows.
+
+For SMS, see api/sms.py — the same Bearer token works on both APIs.
+
+For the legacy 3CX → ERPNext webhook path (3CX is the call broker, this app is
+just a ticket creator), see voxtelesys_integration/api.py.
"""
from __future__ import annotations
-import hashlib, hmac, json
+
+import hashlib
+import hmac
+import json
from typing import Any
-import frappe, requests
+
+import frappe
+import requests
from frappe import _
from frappe.utils import now_datetime
VOICE_API_BASE = "https://voiceapi.voxtelesys.com/v1"
+
def _headers():
- from voxtelesys_integration.doctype.voxtelesys_settings.voxtelesys_settings import VoxtelesysSettings
- return {"Authorization": f"Bearer {VoxtelesysSettings.get_api_token()}", "Content-Type": "application/json", "Accept": "application/json"}
+ from voxtelesys_integration.voxtelesys_integration.doctype.voxtelesys_settings.voxtelesys_settings import (
+ VoxtelesysSettings,
+ )
+ return {
+ "Authorization": f"Bearer {VoxtelesysSettings.get_api_token()}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+
def _post(path, payload):
resp = requests.post(f"{VOICE_API_BASE}{path}", headers=_headers(), json=payload, timeout=15)
- resp.raise_for_status(); return resp.json()
+ resp.raise_for_status()
+ return resp.json()
+
def _get(path, params=None):
resp = requests.get(f"{VOICE_API_BASE}{path}", headers=_headers(), params=params or {}, timeout=15)
- resp.raise_for_status(); return resp.json()
+ resp.raise_for_status()
+ return resp.json()
+
def _verify_signature(body, signature):
secret = frappe.db.get_single_value("Voxtelesys Settings", "webhook_secret")
- if not secret: return True
+ if not secret:
+ return True
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature or "")
+
def _voxml_response(*elements):
body = "\n ".join(elements)
return f'\n\n {body}\n'
+
def _voxml_dial(agent_number, record, status_callback_url):
record_attr = 'record="true"' if record else ""
return f'{agent_number}'
-def _voxml_say(text): return f"{text}"
-def _voxml_hangup(): return ""
+
+def _voxml_say(text):
+ return f"{text}"
+
+
+def _voxml_hangup():
+ return ""
+
def _pick_agent_number(mode):
- if mode == "Direct (no routing)": return None
- users = frappe.get_all("User",
- filters=[["custom_voxtelesys_number","!=",""],["enabled","=",1]],
- fields=["name","custom_voxtelesys_number","last_active"],
- order_by="last_active desc" if mode == "Availability-Based" else "name asc")
- if not users: return None
+ if mode == "Direct (no routing)":
+ return None
+ users = frappe.get_all(
+ "User",
+ filters=[["custom_voxtelesys_number", "!=", ""], ["enabled", "=", 1]],
+ fields=["name", "custom_voxtelesys_number", "last_active"],
+ order_by="last_active desc" if mode == "Availability-Based" else "name asc",
+ )
+ if not users:
+ return None
if mode == "Round Robin":
idx_key = "voxtelesys_rr_idx"
idx = int(frappe.cache().get_value(idx_key) or 0)
@@ -55,149 +102,278 @@ def _pick_agent_number(mode):
return user.custom_voxtelesys_number
return users[0].custom_voxtelesys_number
+
+# -----------------------------------------------------------------------------
+# Webhooks
+# -----------------------------------------------------------------------------
@frappe.whitelist(allow_guest=True)
def handle_inbound_call():
- if frappe.request.method != "POST": frappe.throw("Method not allowed", frappe.PermissionError)
+ if frappe.request.method != "POST":
+ frappe.throw("Method not allowed", frappe.PermissionError)
raw_body = frappe.request.data
sig = frappe.request.headers.get("X-Voxtelesys-Signature", "")
- if not _verify_signature(raw_body, sig): frappe.throw("Invalid webhook signature", frappe.PermissionError)
+ if not _verify_signature(raw_body, sig):
+ frappe.throw("Invalid webhook signature", frappe.PermissionError)
data = _parse_webhook_body(raw_body)
- call_sid = data.get("CallSid") or data.get("call_sid") or ""
- from_number = data.get("From") or data.get("from") or ""
- to_number = data.get("To") or data.get("to") or ""
- log = _upsert_call_log(call_sid=call_sid, direction="Inbound", status="Ringing",
- from_number=from_number, to_number=to_number, start_time=now_datetime())
+ call_sid = data.get("CallSid") or data.get("call_sid") or ""
+ from_number = data.get("From") or data.get("from") or ""
+ to_number = data.get("To") or data.get("to") or ""
+ log = _upsert_call_log(
+ call_sid=call_sid,
+ direction="Inbound",
+ status="Ringing",
+ from_number=from_number,
+ to_number=to_number,
+ start_time=now_datetime(),
+ )
_link_contact(log, from_number)
- settings = frappe.get_single("Voxtelesys Settings")
- base_url = settings.voxml_base_url or frappe.utils.get_url()
- status_cb = f"{base_url.rstrip('/')}/api/method/voxtelesys_integration.api.voxtelesys.handle_call_status"
- agent_num = _pick_agent_number(settings.agent_routing_mode or "Round Robin")
+ settings = frappe.get_single("Voxtelesys Settings")
+ base_url = settings.voxml_base_url or frappe.utils.get_url()
+ status_cb = (
+ f"{base_url.rstrip('/')}"
+ "/api/method/voxtelesys_integration.api.voxtelesys.handle_call_status"
+ )
+ agent_num = _pick_agent_number(settings.agent_routing_mode or "Round Robin")
if agent_num:
xml = _voxml_response(_voxml_dial(agent_num, settings.recording_enabled, status_cb))
else:
- xml = _voxml_response(_voxml_say("Thank you for calling. All agents are currently unavailable. Please try again later."), _voxml_hangup())
+ xml = _voxml_response(
+ _voxml_say(
+ "Thank you for calling. All agents are currently unavailable. "
+ "Please leave a message after the tone."
+ ),
+ _voxml_hangup(),
+ )
frappe.local.response["content_type"] = "application/xml"
frappe.local.response["http_status_code"] = 200
return xml
+
@frappe.whitelist(allow_guest=True)
def handle_call_status():
- if frappe.request.method != "POST": frappe.throw("Method not allowed", frappe.PermissionError)
+ if frappe.request.method != "POST":
+ frappe.throw("Method not allowed", frappe.PermissionError)
data = _parse_webhook_body(frappe.request.data)
call_sid = data.get("CallSid") or data.get("call_sid") or ""
- raw_status = (data.get("CallStatus") or data.get("DialCallStatus") or data.get("call_status") or "").lower()
- status_map = {"ringing":"Ringing","in-progress":"In Progress","completed":"Completed",
- "no-answer":"No Answer","busy":"Busy","failed":"Failed","canceled":"Cancelled","cancelled":"Cancelled"}
+ raw_status = (
+ data.get("CallStatus") or data.get("DialCallStatus") or data.get("call_status") or ""
+ ).lower()
+ status_map = {
+ "ringing": "Ringing",
+ "in-progress": "In Progress",
+ "completed": "Completed",
+ "no-answer": "No Answer",
+ "busy": "Busy",
+ "failed": "Failed",
+ "canceled": "Cancelled",
+ "cancelled": "Cancelled",
+ }
status = status_map.get(raw_status, "Completed")
recording_url = data.get("RecordingUrl") or data.get("recording_url") or ""
updates: dict[str, Any] = {"status": status}
- if status in ("Completed","No Answer","Busy","Failed","Cancelled"):
+ if status in ("Completed", "No Answer", "Busy", "Failed", "Cancelled"):
updates["end_time"] = now_datetime()
- try: updates["duration"] = int(data.get("CallDuration") or data.get("duration") or 0)
- except (TypeError, ValueError): pass
- if recording_url: updates["recording_url"] = recording_url
+ try:
+ updates["duration"] = int(data.get("CallDuration") or data.get("duration") or 0)
+ except (TypeError, ValueError):
+ pass
+ if recording_url:
+ updates["recording_url"] = recording_url
_upsert_call_log(call_sid=call_sid, **updates)
frappe.local.response["content_type"] = "application/xml"
frappe.local.response["http_status_code"] = 200
return _voxml_response()
+
+# -----------------------------------------------------------------------------
+# Outbound
+# -----------------------------------------------------------------------------
@frappe.whitelist()
def make_outbound_call(to, from_="", reference_doctype="", reference_name=""):
settings = frappe.get_single("Voxtelesys Settings")
- if not settings.enabled: frappe.throw(_("Voxtelesys integration is not enabled."))
+ if not settings.enabled:
+ frappe.throw(_("Voxtelesys integration is not enabled."))
caller_id = from_ or settings.caller_id or ""
- if not caller_id: frappe.throw(_("No Caller ID configured. Set it in Voxtelesys Settings."))
- base_url = settings.voxml_base_url or frappe.utils.get_url()
- status_cb = f"{base_url.rstrip('/')}/api/method/voxtelesys_integration.api.voxtelesys.handle_call_status"
- payload: dict[str, Any] = {"to": to, "from": caller_id, "status_callback": status_cb, "status_callback_method": "POST"}
- if settings.outbound_trunk_group: payload["trunk_group"] = settings.outbound_trunk_group
- if settings.recording_enabled: payload["record"] = True
+ if not caller_id:
+ frappe.throw(_("No Caller ID configured. Set it in Voxtelesys Settings."))
+ base_url = settings.voxml_base_url or frappe.utils.get_url()
+ status_cb = (
+ f"{base_url.rstrip('/')}"
+ "/api/method/voxtelesys_integration.api.voxtelesys.handle_call_status"
+ )
+ payload: dict[str, Any] = {
+ "to": to,
+ "from": caller_id,
+ "status_callback": status_cb,
+ "status_callback_method": "POST",
+ }
+ if settings.outbound_trunk_group:
+ payload["trunk_group"] = settings.outbound_trunk_group
+ if settings.recording_enabled:
+ payload["record"] = True
try:
result = _post("/calls", payload)
except requests.HTTPError as exc:
frappe.log_error(str(exc), "Voxtelesys: Outbound call failed")
frappe.throw(_("Voxtelesys API error: {0}").format(str(exc)), title=_("Call Failed"))
call_sid = result.get("sid") or result.get("call_sid") or result.get("CallSid") or ""
- log = _upsert_call_log(call_sid=call_sid, direction="Outbound", status="Ringing",
- from_number=caller_id, to_number=to, start_time=now_datetime())
- if reference_doctype and reference_name: _add_link(log, reference_doctype, reference_name)
+ log = _upsert_call_log(
+ call_sid=call_sid,
+ direction="Outbound",
+ status="Ringing",
+ from_number=caller_id,
+ to_number=to,
+ start_time=now_datetime(),
+ )
+ if reference_doctype and reference_name:
+ _add_link(log, reference_doctype, reference_name)
return {"ok": True, "call_log": log.name, "call_sid": call_sid}
+
@frappe.whitelist()
def test_connection():
frappe.only_for("System Manager")
- try: return {"ok": True, "data": _get("/account")}
- except Exception as exc: return {"ok": False, "error": str(exc)}
-
-def boot_session(bootinfo):
try:
+ return {"ok": True, "data": _get("/account")}
+ except Exception as exc:
+ return {"ok": False, "error": str(exc)}
+
+
+# -----------------------------------------------------------------------------
+# Frontend boot
+# -----------------------------------------------------------------------------
+def boot_session(bootinfo):
+ """Expose Voxtelesys config to the browser as frappe.boot.voxtelesys."""
+ try:
+ if not frappe.db.exists("DocType", "Voxtelesys Settings"):
+ bootinfo.voxtelesys = {"enabled": False}
+ return
settings = frappe.get_single("Voxtelesys Settings")
- bootinfo.voxtelesys = {"enabled": bool(settings.enabled), "caller_id": settings.caller_id or ""}
+ bootinfo.voxtelesys = {
+ "enabled": bool(settings.enabled),
+ "caller_id": settings.caller_id or "",
+ "sms_enabled": bool(getattr(settings, "sms_enabled", True)),
+ }
except Exception:
bootinfo.voxtelesys = {"enabled": False}
+
+# -----------------------------------------------------------------------------
+# History (form sidebar)
+# -----------------------------------------------------------------------------
@frappe.whitelist()
def get_linked_call_logs(doctype, name, **kwargs):
- logs = frappe.db.sql("""
+ logs = frappe.db.sql(
+ """
SELECT vcl.name, vcl.from_number, vcl.to_number, vcl.direction,
vcl.status, vcl.start_time, vcl.duration, vcl.recording_url, vcl.summary
FROM `tabVoxtelesys Call Log` vcl
INNER JOIN `tabDynamic Link` dl
ON dl.parent = vcl.name AND dl.parenttype = 'Voxtelesys Call Log'
AND dl.link_doctype = %(doctype)s AND dl.link_name = %(name)s
- ORDER BY vcl.start_time DESC LIMIT 50""",
- {"doctype": doctype, "name": name}, as_dict=True)
- for log in logs: log["doctype"] = "Voxtelesys Call Log"
+ ORDER BY vcl.start_time DESC LIMIT 50
+ """,
+ {"doctype": doctype, "name": name},
+ as_dict=True,
+ )
+ for log in logs:
+ log["doctype"] = "Voxtelesys Call Log"
return logs
+
+# -----------------------------------------------------------------------------
+# Scheduler — reconcile stuck call logs
+# -----------------------------------------------------------------------------
def sync_pending_call_logs():
settings = frappe.get_single("Voxtelesys Settings")
- if not settings.enabled: return
- stale = frappe.get_all("Voxtelesys Call Log", filters={"status": ["in",["Ringing","In Progress"]]}, fields=["name","call_sid"], limit=50)
+ if not settings.enabled:
+ return
+ stale = frappe.get_all(
+ "Voxtelesys Call Log",
+ filters={"status": ["in", ["Ringing", "In Progress"]]},
+ fields=["name", "call_sid"],
+ limit=50,
+ )
for row in stale:
- if not row.call_sid: continue
+ if not row.call_sid:
+ continue
try:
data = _get(f"/calls/{row.call_sid}")
raw_status = (data.get("status") or "").lower()
- status_map = {"completed":"Completed","no-answer":"No Answer","busy":"Busy","failed":"Failed","canceled":"Cancelled","cancelled":"Cancelled"}
+ status_map = {
+ "completed": "Completed",
+ "no-answer": "No Answer",
+ "busy": "Busy",
+ "failed": "Failed",
+ "canceled": "Cancelled",
+ "cancelled": "Cancelled",
+ }
if raw_status in status_map:
doc = frappe.get_doc("Voxtelesys Call Log", row.name)
- doc.status = status_map[raw_status]; doc.end_time = now_datetime()
+ doc.status = status_map[raw_status]
+ doc.end_time = now_datetime()
doc.duration = data.get("duration") or 0
doc.recording_url = data.get("recording_url") or doc.recording_url
doc.save(ignore_permissions=True)
except Exception:
- frappe.log_error(frappe.get_traceback(), f"Voxtelesys: Failed to sync call log {row.name}")
+ frappe.log_error(
+ frappe.get_traceback(),
+ f"Voxtelesys: Failed to sync call log {row.name}",
+ )
+
+# -----------------------------------------------------------------------------
+# Shared helpers
+# -----------------------------------------------------------------------------
def _parse_webhook_body(raw):
- try: return json.loads(raw)
- except (json.JSONDecodeError, ValueError): pass
+ try:
+ return json.loads(raw)
+ except (json.JSONDecodeError, ValueError):
+ pass
return dict(frappe.form_dict)
+
def _upsert_call_log(call_sid, **fields):
if call_sid and frappe.db.exists("Voxtelesys Call Log", {"call_sid": call_sid}):
doc_name = frappe.db.get_value("Voxtelesys Call Log", {"call_sid": call_sid})
doc = frappe.get_doc("Voxtelesys Call Log", doc_name)
for k, v in fields.items():
- if v is not None: doc.set(k, v)
+ if v is not None:
+ doc.set(k, v)
doc.save(ignore_permissions=True)
else:
- doc = frappe.new_doc("Voxtelesys Call Log"); doc.call_sid = call_sid
- for k, v in fields.items(): doc.set(k, v)
+ doc = frappe.new_doc("Voxtelesys Call Log")
+ doc.call_sid = call_sid
+ for k, v in fields.items():
+ doc.set(k, v)
doc.insert(ignore_permissions=True)
frappe.db.commit()
return doc
+
def _link_contact(doc, phone_number):
- if not phone_number: return
- normalised = phone_number.replace("+","").replace(" ","").replace("-","")
- contact = frappe.db.sql("SELECT parent FROM `tabContact Phone` WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s LIMIT 1", {"num": f"%{normalised}"}, as_dict=True)
- if contact: _add_link(doc, "Contact", contact[0].parent); return
- customer = frappe.db.get_value("Customer", {"mobile_no": ["like", f"%{normalised}"]}, "name")
- if customer: _add_link(doc, "Customer", customer)
+ if not phone_number:
+ return
+ normalised = phone_number.replace("+", "").replace(" ", "").replace("-", "")
+ contact = frappe.db.sql(
+ "SELECT parent FROM `tabContact Phone` "
+ "WHERE REPLACE(REPLACE(REPLACE(phone,'+',''),' ',''),'-','') LIKE %(num)s LIMIT 1",
+ {"num": f"%{normalised}"},
+ as_dict=True,
+ )
+ if contact:
+ _add_link(doc, "Contact", contact[0].parent)
+ return
+ customer = frappe.db.get_value(
+ "Customer", {"mobile_no": ["like", f"%{normalised}"]}, "name"
+ )
+ if customer:
+ _add_link(doc, "Customer", customer)
+
def _add_link(doc, link_doctype, link_name):
for row in doc.get("links") or []:
- if row.link_doctype == link_doctype and row.link_name == link_name: return
+ if row.link_doctype == link_doctype and row.link_name == link_name:
+ return
doc.append("links", {"link_doctype": link_doctype, "link_name": link_name})
- doc.save(ignore_permissions=True)
\ No newline at end of file
+ doc.save(ignore_permissions=True)