Files
upstream-mirror db69de1c11
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
2026-09-18 01:36:01 +05:00

343 lines
10 KiB
Python

import frappe
import requests
from frappe import _
from frappe.integrations.utils import create_request_log
from crm.integrations.api import get_contact_by_phone_number
# Endpoints for webhook
# Incoming Call:
# <site>/api/method/crm.integrations.exotel.handler.handle_request?key=<exotel-webhook-verify-token>
# Exotel Reference:
# https://developer.exotel.com/api/
# https://support.exotel.com/support/solutions/articles/48283-working-with-passthru-applet
# Incoming Call
@frappe.whitelist(allow_guest=True)
def handle_request(**kwargs):
validate_request()
if not is_integration_enabled():
return
request_log = create_request_log(
kwargs,
request_description="Exotel Call",
service_name="Exotel",
request_headers=frappe.request.headers,
is_remote_request=1,
)
try:
request_log.status = "Completed"
exotel_settings = get_exotel_settings()
if not exotel_settings.enabled:
return
call_payload = kwargs
frappe.publish_realtime("exotel_call", call_payload)
status = call_payload.get("Status")
if status == "free":
return
if call_log := get_call_log(call_payload):
update_call_log(call_payload, call_log=call_log)
elif call_payload.get("Direction") == "incoming":
create_call_log(
call_id=call_payload.get("CallSid"),
from_number=call_payload.get("CallFrom"),
to_number=call_payload.get("DialWhomNumber"),
medium=call_payload.get("To"),
status=get_call_log_status(call_payload),
agent=call_payload.get("AgentEmail"),
)
elif agent := frappe.request.args.get("agent"):
create_call_log(
call_id=call_payload.get("CallSid"),
from_number=call_payload.get("From") or call_payload.get("CallFrom"),
to_number=call_payload.get("DialWhomNumber") or call_payload.get("To"),
medium=call_payload.get("To"),
status=get_call_log_status(call_payload, direction=call_payload.get("Direction")),
agent=agent,
call_type="Outgoing",
)
except Exception:
request_log.status = "Failed"
request_log.error = frappe.get_traceback()
frappe.db.rollback()
frappe.log_error(title="Error while creating/updating call record")
frappe.db.commit()
finally:
request_log.save(ignore_permissions=True)
frappe.db.commit()
# Outgoing Call
@frappe.whitelist()
def make_a_call(to_number: str, from_number: str | None = None, caller_id: str | None = None):
if not is_integration_enabled():
frappe.throw(_("Please setup Exotel integration"), title=_("Integration Not Enabled"))
endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
if not from_number:
from_number = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "mobile_no")
if not caller_id:
caller_id = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "exotel_number")
if not caller_id:
frappe.throw(
_("You do not have Exotel Number set in your Telephony Agent"), title=_("Exotel Number Missing")
)
if caller_id and caller_id not in get_all_exophones():
frappe.throw(_("Exotel Number {0} is not valid").format(caller_id), title=_("Invalid Exotel Number"))
if not from_number:
frappe.throw(
_("You do not have mobile number set in your Telephony Agent"), title=_("Mobile Number Missing")
)
record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call")
call_log_creation_failed = False
try:
response = requests.post(
endpoint,
data={
"From": from_number,
"To": to_number,
"CallerId": caller_id,
"Record": "true" if record_call else "false",
"StatusCallback": get_status_updater_url(),
"StatusCallbackEvents[0]": "terminal",
"StatusCallbackEvents[1]": "answered",
},
)
response.raise_for_status()
except requests.exceptions.HTTPError:
if exc := response.json().get("RestException"):
frappe.throw(exc.get("Message"), title=_("Exotel Exception"))
else:
res = response.json()
call_payload = res.get("Call", {})
try:
create_call_log(
call_id=call_payload.get("Sid"),
from_number=call_payload.get("From"),
to_number=call_payload.get("To"),
medium=call_payload.get("PhoneNumberSid"),
call_type="Outgoing",
agent=frappe.session.user,
)
except Exception:
frappe.db.rollback()
frappe.log_error(title="Error while creating Exotel call log")
frappe.db.commit()
call_log_creation_failed = True
call_details = response.json().get("Call", {})
call_details["CallSid"] = call_details.get("Sid", "")
call_details["call_log_creation_failed"] = call_log_creation_failed
return call_details
def get_exotel_endpoint(action=None, version="v1"):
settings = get_exotel_settings()
return "https://{api_key}:{api_token}@{subdomain}/{version}/Accounts/{sid}/{action}".format(
api_key=settings.api_key,
api_token=settings.get_password("api_token"),
subdomain=settings.subdomain,
version=version,
sid=settings.account_sid,
action=action,
)
def get_all_exophones():
endpoint = get_exotel_endpoint("IncomingPhoneNumbers", "v2_beta")
response = requests.get(endpoint)
return [phone.get("friendly_name") for phone in response.json().get("incoming_phone_numbers", [])]
def get_status_updater_url():
from frappe.utils.data import get_url
webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token")
return get_url(
f"api/method/crm.integrations.exotel.handler.handle_request"
f"?key={webhook_verify_token}&agent={frappe.session.user}"
)
def get_exotel_settings():
return frappe.get_single("CRM Exotel Settings")
def validate_request():
# workaround security since exotel does not support request signature
# /api/method/<exotel-integration-method>?key=<exotel-webhook=verify-token>
webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token")
key = frappe.request.args.get("key")
is_valid = key and key == webhook_verify_token
if not is_valid:
frappe.throw(_("Unauthorized request"), exc=frappe.PermissionError)
@frappe.whitelist()
def is_integration_enabled():
return frappe.db.get_single_value("CRM Exotel Settings", "enabled", True)
# Call Log Functions
def create_call_log(
call_id,
from_number,
to_number,
medium,
agent,
status="Ringing",
call_type="Incoming",
):
call_log = frappe.new_doc("CRM Call Log")
call_log.id = call_id
call_log.to = to_number
call_log.medium = medium
call_log.type = call_type
call_log.status = status
call_log.telephony_medium = "Exotel"
setattr(call_log, "from", from_number)
if call_type == "Incoming":
call_log.receiver = agent
else:
call_log.caller = agent
# link call log with lead/deal
contact_number = from_number if call_type == "Incoming" else to_number
link(contact_number, call_log)
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
def link(contact_number, call_log):
contact = get_contact_by_phone_number(contact_number)
if contact.get("name"):
doctype = "Contact"
docname = contact.get("name")
if contact.get("lead"):
doctype = "CRM Lead"
docname = contact.get("lead")
elif contact.get("deal"):
doctype = "CRM Deal"
docname = contact.get("deal")
call_log.link_with_reference_doc(doctype, docname)
def get_call_log(call_payload):
call_log_id = call_payload.get("CallSid")
if frappe.db.exists("CRM Call Log", call_log_id):
return frappe.get_doc("CRM Call Log", call_log_id)
# Exotel reports call status in lowercase, hyphenated form. Map it onto the
# options of the CRM Call Log status field, which is a mandatory Select.
# https://support.exotel.com/support/solutions/articles/27323-call-status
EXOTEL_CALL_STATUSES = {
"initiated": "Initiated",
"ringing": "Ringing",
"in-progress": "In Progress",
"completed": "Completed",
"failed": "Failed",
"busy": "Busy",
"no-answer": "No Answer",
"queued": "Queued",
"canceled": "Canceled",
"cancelled": "Canceled",
}
# Exotel sends these when a leg never connected, eg. the caller hung up before
# an agent picked up. There is no status to record, not an unknown one.
# Leg2Status is documented as empty when there is no second leg:
# https://support.exotel.com/support/solutions/articles/27323-call-status
EXOTEL_EMPTY_STATUSES = ("", "null", "none", "undefined")
def normalize_call_status(status):
"""Map a raw Exotel status onto a CRM Call Log status, or None if there isn't one."""
if not status:
return None
status = str(status).strip().lower().replace("_", "-").replace(" ", "-")
if status in EXOTEL_EMPTY_STATUSES:
return None
if status not in EXOTEL_CALL_STATUSES:
frappe.log_error(
title="Unknown Exotel call status",
message=f"Exotel sent call status {status!r}, which is not in EXOTEL_CALL_STATUSES",
)
return None
return EXOTEL_CALL_STATUSES[status]
def get_call_log_status(call_payload, direction="inbound"):
if direction == "outbound-api" or direction == "outbound-dial":
status = normalize_call_status(call_payload.get("Status"))
if status == "Busy":
# the call is still ringing on another leg
status = "Ringing"
if status:
return status
call_type = call_payload.get("CallType")
status = normalize_call_status(call_payload.get("DialCallStatus") or call_payload.get("Status"))
if call_type == "completed":
status = "Completed"
elif status == "Busy":
status = "Ringing"
return status
def update_call_log(call_payload, status="Ringing", call_log=None):
direction = call_payload.get("Direction")
call_log = call_log or get_call_log(call_payload)
status = get_call_log_status(call_payload, direction)
try:
if call_log:
if status:
call_log.status = status
# resetting this because call might be redirected to other number
call_log.to = call_payload.get("DialWhomNumber") or call_payload.get("To")
call_log.duration = (
call_payload.get("DialCallDuration") or call_payload.get("ConversationDuration") or 0
)
call_log.recording_url = (
call_payload.get("RecordingUrl") if call_payload.get("RecordingUrl") else ""
)
call_log.start_time = call_payload.get("StartTime")
call_log.end_time = call_payload.get("EndTime")
if direction == "incoming" and call_payload.get("AgentEmail"):
call_log.receiver = call_payload.get("AgentEmail")
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
except Exception:
frappe.log_error(title="Error while updating call record")
frappe.db.commit()