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")
|
||||
267
crm/integrations/twilio/twilio_handler.py
Normal file
267
crm/integrations/twilio/twilio_handler.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils.password import get_decrypted_password
|
||||
from twilio.jwt.access_token import AccessToken
|
||||
from twilio.jwt.access_token.grants import VoiceGrant
|
||||
from twilio.rest import Client as TwilioClient
|
||||
from twilio.twiml.voice_response import Dial, VoiceResponse
|
||||
|
||||
from .utils import get_public_url, merge_dicts
|
||||
|
||||
|
||||
class Twilio:
|
||||
"""Twilio connector over TwilioClient."""
|
||||
|
||||
def __init__(self, settings):
|
||||
"""
|
||||
:param settings: `CRM Twilio Settings` doctype
|
||||
"""
|
||||
self.settings = settings
|
||||
self.account_sid = settings.account_sid
|
||||
self.application_sid = settings.twiml_sid
|
||||
self.api_key = settings.api_key
|
||||
self.api_secret = settings.get_password("api_secret")
|
||||
self.twilio_client = self.get_twilio_client()
|
||||
|
||||
@classmethod
|
||||
def connect(cls):
|
||||
"""Read CRM Twilio Settings and return a configured Twilio instance, or None if not enabled."""
|
||||
settings = frappe.get_doc("CRM Twilio Settings")
|
||||
if not (settings and settings.enabled):
|
||||
return
|
||||
return Twilio(settings=settings)
|
||||
|
||||
def get_phone_numbers(self):
|
||||
"""Get account's twilio phone numbers."""
|
||||
numbers = self.twilio_client.incoming_phone_numbers.list()
|
||||
return [n.phone_number for n in numbers]
|
||||
|
||||
def generate_voice_access_token(self, identity: str, ttl=60 * 60):
|
||||
"""Generates a token required to make voice calls from the browser."""
|
||||
# identity is used by twilio to identify the user uniqueness at browser(or any endpoints).
|
||||
identity = self.safe_identity(identity)
|
||||
|
||||
# Create access token with credentials
|
||||
token = AccessToken(self.account_sid, self.api_key, self.api_secret, identity=identity, ttl=ttl)
|
||||
|
||||
# Create a Voice grant and add to token
|
||||
voice_grant = VoiceGrant(
|
||||
outgoing_application_sid=self.application_sid,
|
||||
incoming_allow=True, # Allow incoming calls
|
||||
)
|
||||
token.add_grant(voice_grant)
|
||||
return token.to_jwt()
|
||||
|
||||
@classmethod
|
||||
def safe_identity(cls, identity: str):
|
||||
"""Create a safe identity by replacing unsupported special characters `@` with (at)).
|
||||
Twilio Client JS fails to make a call connection if identity has special characters like @, [, / etc)
|
||||
https://www.twilio.com/docs/voice/client/errors (#31105)
|
||||
"""
|
||||
return identity.replace("@", "(at)")
|
||||
|
||||
@classmethod
|
||||
def emailid_from_identity(cls, identity: str):
|
||||
"""Convert safe identity string into emailID."""
|
||||
return identity.replace("(at)", "@")
|
||||
|
||||
def get_recording_status_callback_url(self):
|
||||
url_path = "/api/method/crm.integrations.twilio.api.update_recording_info"
|
||||
return get_public_url(url_path)
|
||||
|
||||
def get_update_call_status_callback_url(self):
|
||||
url_path = "/api/method/crm.integrations.twilio.api.update_call_status_info"
|
||||
return get_public_url(url_path)
|
||||
|
||||
def generate_twilio_dial_response(self, from_number: str, to_number: str):
|
||||
"""Generates voice call instructions to forward the call to agents Phone."""
|
||||
resp = VoiceResponse()
|
||||
dial = Dial(
|
||||
caller_id=from_number,
|
||||
record=self.settings.record_calls,
|
||||
recording_status_callback=self.get_recording_status_callback_url(),
|
||||
recording_status_callback_event="completed",
|
||||
)
|
||||
dial.number(
|
||||
to_number,
|
||||
status_callback_event="initiated ringing answered completed",
|
||||
status_callback=self.get_update_call_status_callback_url(),
|
||||
status_callback_method="POST",
|
||||
)
|
||||
resp.append(dial)
|
||||
return resp
|
||||
|
||||
def get_call_info(self, call_sid):
|
||||
return self.twilio_client.calls(call_sid).fetch()
|
||||
|
||||
def generate_twilio_client_response(self, client, ring_tone="at"):
|
||||
"""Generates voice call instructions to forward the call to agents computer."""
|
||||
resp = VoiceResponse()
|
||||
dial = Dial(
|
||||
ring_tone=ring_tone,
|
||||
record=self.settings.record_calls,
|
||||
recording_status_callback=self.get_recording_status_callback_url(),
|
||||
recording_status_callback_event="completed",
|
||||
)
|
||||
dial.client(
|
||||
client,
|
||||
status_callback_event="initiated ringing answered completed",
|
||||
status_callback=self.get_update_call_status_callback_url(),
|
||||
status_callback_method="POST",
|
||||
)
|
||||
resp.append(dial)
|
||||
return resp
|
||||
|
||||
@classmethod
|
||||
def get_twilio_client(self):
|
||||
twilio_settings = frappe.get_doc("CRM Twilio Settings")
|
||||
if not twilio_settings.enabled:
|
||||
frappe.throw(_("Please enable twilio settings before making a call."))
|
||||
|
||||
auth_token = get_decrypted_password("CRM Twilio Settings", "CRM Twilio Settings", "auth_token")
|
||||
client = TwilioClient(twilio_settings.account_sid, auth_token)
|
||||
|
||||
return client
|
||||
|
||||
|
||||
class IncomingCall:
|
||||
def __init__(self, from_number, to_number, meta=None):
|
||||
self.from_number = from_number
|
||||
self.to_number = to_number
|
||||
self.meta = meta
|
||||
|
||||
def process(self):
|
||||
"""Process the incoming call
|
||||
* Figure out who is going to pick the call (call attender)
|
||||
* Check call attender settings and forward the call to Phone
|
||||
"""
|
||||
twilio = Twilio.connect()
|
||||
owners = get_twilio_number_owners(self.to_number)
|
||||
attender = get_the_call_attender(owners, self.from_number)
|
||||
|
||||
if not attender:
|
||||
resp = VoiceResponse()
|
||||
resp.say(_("Agent is unavailable to take the call, please call after some time."))
|
||||
return resp
|
||||
|
||||
if attender["call_receiving_device"] == "Phone":
|
||||
return twilio.generate_twilio_dial_response(self.from_number, attender["mobile_no"])
|
||||
else:
|
||||
return twilio.generate_twilio_client_response(twilio.safe_identity(attender["name"]))
|
||||
|
||||
|
||||
def get_twilio_number_owners(phone_number):
|
||||
"""Get list of users who is using the phone_number.
|
||||
>>> get_twilio_number_owners("+11234567890")
|
||||
{
|
||||
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
|
||||
'owner2': {....}
|
||||
}
|
||||
"""
|
||||
# remove special characters from phone number and get only digits also remove white spaces
|
||||
# keep + sign in the number at start of the number
|
||||
phone_number = "".join([c for c in phone_number if c.isdigit() or c == "+"])
|
||||
user_voice_settings = frappe.get_all(
|
||||
"CRM Telephony Agent",
|
||||
filters={"twilio_number": phone_number},
|
||||
fields=["name", "call_receiving_device"],
|
||||
)
|
||||
user_wise_voice_settings = {user["name"]: user for user in user_voice_settings}
|
||||
|
||||
user_general_settings = frappe.get_all(
|
||||
"User", filters=[["name", "IN", user_wise_voice_settings.keys()]], fields=["name", "mobile_no"]
|
||||
)
|
||||
user_wise_general_settings = {user["name"]: user for user in user_general_settings}
|
||||
|
||||
return merge_dicts(user_wise_general_settings, user_wise_voice_settings)
|
||||
|
||||
|
||||
def get_active_loggedin_users(users):
|
||||
"""Filter the current loggedin users from the given users list"""
|
||||
rows = frappe.db.sql(
|
||||
"""
|
||||
SELECT `user`
|
||||
FROM `tabSessions`
|
||||
WHERE `user` IN %(users)s
|
||||
""",
|
||||
{"users": users},
|
||||
)
|
||||
return [row[0] for row in set(rows)]
|
||||
|
||||
|
||||
def get_the_call_attender(owners, caller=None):
|
||||
"""Get attender details from list of owners"""
|
||||
if not owners:
|
||||
return
|
||||
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
|
||||
|
||||
if len(current_loggedin_users) > 1 and caller:
|
||||
deal_owner = frappe.db.get_value("CRM Deal", {"mobile_no": caller}, "deal_owner")
|
||||
if not deal_owner:
|
||||
deal_owner = frappe.db.get_value(
|
||||
"CRM Lead", {"mobile_no": caller, "converted": False}, "lead_owner"
|
||||
)
|
||||
for user in current_loggedin_users:
|
||||
if user == deal_owner:
|
||||
current_loggedin_users = [user]
|
||||
|
||||
for name, details in owners.items():
|
||||
if (details["call_receiving_device"] == "Phone" and details["mobile_no"]) or (
|
||||
details["call_receiving_device"] == "Computer" and name in current_loggedin_users
|
||||
):
|
||||
return details
|
||||
|
||||
|
||||
class TwilioCallDetails:
|
||||
def __init__(self, call_info, call_from=None, call_to=None):
|
||||
self.call_info = call_info
|
||||
self.account_sid = call_info.get("AccountSid")
|
||||
self.application_sid = call_info.get("ApplicationSid")
|
||||
self.call_sid = call_info.get("CallSid")
|
||||
self.call_status = self.get_call_status(call_info.get("CallStatus"))
|
||||
self._call_from = call_from or call_info.get("From")
|
||||
self._call_to = call_to or call_info.get("To")
|
||||
|
||||
def get_direction(self):
|
||||
if self.call_info.get("Caller").lower().startswith("client"):
|
||||
return "Outgoing"
|
||||
return "Incoming"
|
||||
|
||||
def get_from_number(self):
|
||||
return self._call_from or self.call_info.get("From")
|
||||
|
||||
def get_to_number(self):
|
||||
return self._call_to or self.call_info.get("To")
|
||||
|
||||
@classmethod
|
||||
def get_call_status(cls, twilio_status):
|
||||
"""Convert Twilio given status into system status."""
|
||||
twilio_status = twilio_status or ""
|
||||
return " ".join(twilio_status.split("-")).title()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert call details into dict."""
|
||||
direction = self.get_direction()
|
||||
from_number = self.get_from_number()
|
||||
to_number = self.get_to_number()
|
||||
caller = ""
|
||||
receiver = ""
|
||||
|
||||
if direction == "Outgoing":
|
||||
caller = self.call_info.get("Caller")
|
||||
identity = caller.replace("client:", "").strip()
|
||||
caller = Twilio.emailid_from_identity(identity) if identity else ""
|
||||
else:
|
||||
owners = get_twilio_number_owners(to_number)
|
||||
attender = get_the_call_attender(owners, from_number)
|
||||
receiver = attender["name"] if attender else ""
|
||||
|
||||
return {
|
||||
"type": direction,
|
||||
"status": self.call_status,
|
||||
"id": self.call_sid,
|
||||
"from": from_number,
|
||||
"to": to_number,
|
||||
"receiver": receiver,
|
||||
"caller": caller,
|
||||
}
|
||||
16
crm/integrations/twilio/utils.py
Normal file
16
crm/integrations/twilio/utils.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from frappe.utils import get_url
|
||||
|
||||
|
||||
def get_public_url(path: str | None = None):
|
||||
return get_url().split(":8", 1)[0] + path
|
||||
|
||||
|
||||
def merge_dicts(d1: dict, d2: dict):
|
||||
"""Merge dicts of dictionaries.
|
||||
>>> merge_dicts(
|
||||
{'name1': {'age': 20}, 'name2': {'age': 30}},
|
||||
{'name1': {'phone': '+xxx'}, 'name2': {'phone': '+yyy'}, 'name3': {'phone': '+zzz'}}
|
||||
)
|
||||
... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}}
|
||||
"""
|
||||
return {k: {**v, **d2.get(k, {})} for k, v in d1.items()}
|
||||
Reference in New Issue
Block a user