snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
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
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
This commit is contained in:
222
crm/integrations/twilio/api.py
Normal file
222
crm/integrations/twilio/api.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from twilio.twiml.voice_response import VoiceResponse
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
from crm.integrations.api import get_contact_by_phone_number
|
||||
|
||||
from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails
|
||||
|
||||
|
||||
def validate_twilio_request(args, require_application_sid: bool = False):
|
||||
twilio = Twilio.connect()
|
||||
if not twilio:
|
||||
frappe.throw(_("Twilio configuration is missing"), frappe.PermissionError)
|
||||
|
||||
account_sid = frappe.utils.cstr(args.get("AccountSid"))
|
||||
if not account_sid or account_sid != frappe.utils.cstr(twilio.account_sid):
|
||||
frappe.throw(_("Invalid Twilio account"), frappe.PermissionError)
|
||||
|
||||
if require_application_sid:
|
||||
application_sid = frappe.utils.cstr(args.get("ApplicationSid"))
|
||||
if not application_sid or application_sid != frappe.utils.cstr(twilio.application_sid):
|
||||
frappe.throw(_("Invalid Twilio application"), frappe.PermissionError)
|
||||
|
||||
return twilio
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_enabled():
|
||||
return frappe.db.get_single_value("CRM Twilio Settings", "enabled")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_access_token():
|
||||
"""Returns access token that is required to authenticate Twilio Client SDK."""
|
||||
twilio = Twilio.connect()
|
||||
if not twilio:
|
||||
return {}
|
||||
|
||||
from_number = frappe.db.get_value("CRM Telephony Agent", frappe.session.user, "twilio_number")
|
||||
if not from_number:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "caller_phone_identity_missing",
|
||||
"detail": "Phone number is not mapped to the caller",
|
||||
}
|
||||
|
||||
token = twilio.generate_voice_access_token(identity=frappe.session.user)
|
||||
return {"token": frappe.safe_decode(token)}
|
||||
|
||||
|
||||
# webhook authenticity is enforced by validate_twilio_request(); guest access itself is unchanged
|
||||
@frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method
|
||||
def voice(**kwargs):
|
||||
"""This is a webhook called by twilio to get instructions when the voice call request comes to twilio server."""
|
||||
|
||||
def _get_caller_number(caller):
|
||||
identity = caller.replace("client:", "").strip()
|
||||
user = Twilio.emailid_from_identity(identity)
|
||||
return frappe.db.get_value("CRM Telephony Agent", user, "twilio_number")
|
||||
|
||||
args = frappe._dict(kwargs)
|
||||
twilio = validate_twilio_request(args, require_application_sid=True)
|
||||
|
||||
# Generate TwiML instructions to make a call
|
||||
from_number = _get_caller_number(args.Caller)
|
||||
if not from_number:
|
||||
resp = VoiceResponse()
|
||||
resp.say(_("Your account is not configured with a phone number. Please contact your administrator."))
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
call_details = TwilioCallDetails(args, call_from=from_number)
|
||||
try:
|
||||
create_call_log(call_details)
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating Twilio call log")
|
||||
frappe.db.commit()
|
||||
return Response(_call_failed_response().to_xml(), mimetype="text/xml")
|
||||
|
||||
resp = twilio.generate_twilio_dial_response(from_number, args.To)
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
|
||||
# webhook authenticity is enforced by validate_twilio_request(); guest access itself is unchanged
|
||||
@frappe.whitelist(allow_guest=True) # nosemgrep: guest-whitelisted-method
|
||||
def twilio_incoming_call_handler(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
call_details = TwilioCallDetails(args)
|
||||
try:
|
||||
create_call_log(call_details)
|
||||
except Exception:
|
||||
frappe.db.rollback()
|
||||
frappe.log_error(title="Error while creating Twilio call log")
|
||||
frappe.db.commit()
|
||||
return Response(_call_failed_response().to_xml(), mimetype="text/xml")
|
||||
|
||||
resp = IncomingCall(args.From, args.To).process()
|
||||
return Response(resp.to_xml(), mimetype="text/xml")
|
||||
|
||||
|
||||
def _call_failed_response():
|
||||
resp = VoiceResponse()
|
||||
resp.say(_("We're unable to connect your call right now. Please try again later."))
|
||||
return resp
|
||||
|
||||
|
||||
def create_call_log(call_details: TwilioCallDetails):
|
||||
details = call_details.to_dict()
|
||||
|
||||
call_log = frappe.get_doc({**details, "doctype": "CRM Call Log", "telephony_medium": "Twilio"})
|
||||
|
||||
# link call log with lead/deal
|
||||
contact_number = details.get("from") if details.get("type") == "Incoming" else details.get("to")
|
||||
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 update_call_log(call_sid, status=None):
|
||||
"""Update call log status."""
|
||||
twilio = Twilio.connect()
|
||||
if not (twilio and frappe.db.exists("CRM Call Log", call_sid)):
|
||||
return
|
||||
|
||||
try:
|
||||
call_details = twilio.get_call_info(call_sid)
|
||||
call_log = frappe.get_doc("CRM Call Log", call_sid)
|
||||
call_log.status = TwilioCallDetails.get_call_status(status or call_details.status)
|
||||
call_log.duration = call_details.duration
|
||||
call_log.start_time = get_datetime_from_timestamp(call_details.start_time)
|
||||
call_log.end_time = get_datetime_from_timestamp(call_details.end_time)
|
||||
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()
|
||||
|
||||
|
||||
def get_twilio_settings():
|
||||
return frappe.get_single("CRM Twilio Settings")
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_recording_info(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
recording_url = args.RecordingUrl
|
||||
call_sid = args.CallSid
|
||||
call_log = update_call_log(call_sid)
|
||||
if not call_log:
|
||||
frappe.throw(_("Call log not found"), frappe.DoesNotExistError)
|
||||
|
||||
try:
|
||||
frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url)
|
||||
frappe.db.commit()
|
||||
except Exception as exc:
|
||||
frappe.log_error(title=_("Failed to capture Twilio recording"))
|
||||
raise exc
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def update_call_status_info(**kwargs):
|
||||
args = frappe._dict(kwargs)
|
||||
validate_twilio_request(args)
|
||||
|
||||
parent_call_sid = args.ParentCallSid
|
||||
call_log = update_call_log(parent_call_sid, status=args.CallStatus)
|
||||
if not call_log:
|
||||
frappe.throw(_("Call log not found"), frappe.DoesNotExistError)
|
||||
|
||||
call_info = {
|
||||
"ParentCallSid": args.ParentCallSid,
|
||||
"CallSid": args.CallSid,
|
||||
"CallStatus": args.CallStatus,
|
||||
"CallDuration": args.CallDuration,
|
||||
"From": args.From,
|
||||
"To": args.To,
|
||||
}
|
||||
|
||||
try:
|
||||
client = Twilio.get_twilio_client()
|
||||
client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
|
||||
except Exception as exc:
|
||||
frappe.log_error(title=_("Failed to update Twilio call status"))
|
||||
raise exc
|
||||
|
||||
|
||||
def get_datetime_from_timestamp(timestamp):
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if not timestamp:
|
||||
return None
|
||||
|
||||
datetime_utc_tz_str = timestamp.strftime("%Y-%m-%d %H:%M:%S%z")
|
||||
datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, "%Y-%m-%d %H:%M:%S%z")
|
||||
system_timezone = frappe.utils.get_system_timezone()
|
||||
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone))
|
||||
return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss")
|
||||
Reference in New Issue
Block a user