diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.json b/crm/fcrm/doctype/crm_call_log/crm_call_log.json index ebefc2ab..c13c6cc7 100644 --- a/crm/fcrm/doctype/crm_call_log/crm_call_log.json +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.json @@ -9,6 +9,8 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "telephony_medium", + "section_break_gyqe", "id", "from", "status", @@ -24,7 +26,9 @@ "caller", "recording_url", "end_time", - "note" + "note", + "section_break_kebz", + "links" ], "fields": [ { @@ -75,6 +79,7 @@ "label": "To" }, { + "description": "Call duration in seconds", "fieldname": "duration", "fieldtype": "Duration", "in_list_view": 1, @@ -123,11 +128,31 @@ "fieldtype": "Dynamic Link", "label": "Reference Name", "options": "reference_doctype" + }, + { + "fieldname": "section_break_kebz", + "fieldtype": "Section Break" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "Dynamic Link" + }, + { + "fieldname": "telephony_medium", + "fieldtype": "Select", + "label": "Telephony Medium", + "options": "\nManual\nTwilio\nExotel" + }, + { + "fieldname": "section_break_gyqe", + "fieldtype": "Section Break" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-16 13:23:09.201843", + "modified": "2025-01-11 16:27:56.992950", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Call Log", diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py index 5ed4c28e..c88b6a58 100644 --- a/crm/integrations/twilio/api.py +++ b/crm/integrations/twilio/api.py @@ -1,44 +1,45 @@ -from werkzeug.wrappers import Response import json import frappe from frappe import _ -from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails +from werkzeug.wrappers import Response + +from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails from .utils import parse_mobile_no + @frappe.whitelist() def is_enabled(): return frappe.db.get_single_value("Twilio Settings", "enabled") + @frappe.whitelist() def generate_access_token(): - """Returns access token that is required to authenticate Twilio Client SDK. - """ + """Returns access token that is required to authenticate Twilio Client SDK.""" twilio = Twilio.connect() if not twilio: return {} - from_number = frappe.db.get_value('Twilio Agents', frappe.session.user, 'twilio_number') + from_number = frappe.db.get_value("Twilio Agents", 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" + "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) - } + token = twilio.generate_voice_access_token(identity=frappe.session.user) + return {"token": frappe.safe_decode(token)} + @frappe.whitelist(allow_guest=True) def voice(**kwargs): - """This is a webhook called by twilio to get instructions when the voice call request comes to twilio server. - """ + """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() + identity = caller.replace("client:", "").strip() user = Twilio.emailid_from_identity(identity) - return frappe.db.get_value('Twilio Agents', user, 'twilio_number') + return frappe.db.get_value("Twilio Agents", user, "twilio_number") args = frappe._dict(kwargs) twilio = Twilio.connect() @@ -54,7 +55,8 @@ def voice(**kwargs): call_details = TwilioCallDetails(args, call_from=from_number) create_call_log(call_details) - return Response(resp.to_xml(), mimetype='text/xml') + return Response(resp.to_xml(), mimetype="text/xml") + @frappe.whitelist(allow_guest=True) def twilio_incoming_call_handler(**kwargs): @@ -63,23 +65,24 @@ def twilio_incoming_call_handler(**kwargs): create_call_log(call_details) resp = IncomingCall(args.From, args.To).process() - return Response(resp.to_xml(), mimetype='text/xml') + return Response(resp.to_xml(), mimetype="text/xml") + def create_call_log(call_details: TwilioCallDetails): - call_log = frappe.get_doc({**call_details.to_dict(), - 'doctype': 'CRM Call Log', - 'medium': 'Twilio' - }) + call_log = frappe.get_doc( + {**call_details.to_dict(), "doctype": "CRM Call Log", "telephony_medium": "Twilio"} + ) call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log) call_log.flags.ignore_permissions = True call_log.save() frappe.db.commit() + def update_call_log(call_sid, status=None): - """Update call log status. - """ + """Update call log status.""" twilio = Twilio.connect() - if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): return + if not (twilio and frappe.db.exists("CRM Call Log", call_sid)): + return call_details = twilio.get_call_info(call_sid) call_log = frappe.get_doc("CRM Call Log", call_sid) @@ -94,6 +97,7 @@ def update_call_log(call_sid, status=None): call_log.save() frappe.db.commit() + @frappe.whitelist(allow_guest=True) def update_recording_info(**kwargs): try: @@ -105,6 +109,7 @@ def update_recording_info(**kwargs): except: frappe.log_error(title=_("Failed to capture Twilio recording")) + @frappe.whitelist(allow_guest=True) def update_call_status_info(**kwargs): try: @@ -113,53 +118,54 @@ def update_call_status_info(**kwargs): update_call_log(parent_call_sid, status=args.CallStatus) call_info = { - 'ParentCallSid': args.ParentCallSid, - 'CallSid': args.CallSid, - 'CallStatus': args.CallStatus, - 'CallDuration': args.CallDuration, - 'From': args.From, - 'To': args.To, + "ParentCallSid": args.ParentCallSid, + "CallSid": args.CallSid, + "CallStatus": args.CallStatus, + "CallDuration": args.CallDuration, + "From": args.From, + "To": args.To, } client = Twilio.get_twilio_client() - client.calls(args.ParentCallSid).user_defined_messages.create( - content=json.dumps(call_info) - ) + client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info)) except: frappe.log_error(title=_("Failed to update Twilio call status")) + def get_datetime_from_timestamp(timestamp): from datetime import datetime from zoneinfo import ZoneInfo - if not timestamp: return None + 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') + 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') + return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss") + @frappe.whitelist() def add_note_to_call_log(call_sid, note): - """Add note to call log. based on child call sid. - """ + """Add note to call log. based on child call sid.""" twilio = Twilio.connect() - if not twilio: return + if not twilio: + return call_details = twilio.get_call_info(call_sid) - sid = call_sid if call_details.direction == 'inbound' else call_details.parent_call_sid + sid = call_sid if call_details.direction == "inbound" else call_details.parent_call_sid frappe.db.set_value("CRM Call Log", sid, "note", note) frappe.db.commit() -def get_lead_or_deal_from_number(call): - """Get lead/deal from the given number. - """ - def find_record(doctype, mobile_no, where=''): +def get_lead_or_deal_from_number(call): + """Get lead/deal from the given number.""" + + def find_record(doctype, mobile_no, where=""): mobile_no = parse_mobile_no(mobile_no) - + query = f""" SELECT name, mobile_no FROM `tab{doctype}` @@ -170,12 +176,12 @@ def get_lead_or_deal_from_number(call): return data[0].name if data else None doctype = "CRM Deal" - number = call.get('to') if call.type == 'Outgoing' else call.get('from') + number = call.get("to") if call.type == "Outgoing" else call.get("from") doc = find_record(doctype, number) or None if not doc: doctype = "CRM Lead" - doc = find_record(doctype, number, 'AND converted is not True') + doc = find_record(doctype, number, "AND converted is not True") if not doc: doc = find_record(doctype, number) diff --git a/crm/integrations/twilio/twilio_handler.py b/crm/integrations/twilio/twilio_handler.py index 1ece9296..b24e0e9d 100644 --- a/crm/integrations/twilio/twilio_handler.py +++ b/crm/integrations/twilio/twilio_handler.py @@ -1,16 +1,17 @@ -from twilio.rest import Client as TwilioClient -from twilio.jwt.access_token import AccessToken -from twilio.jwt.access_token.grants import VoiceGrant -from twilio.twiml.voice_response import VoiceResponse, Dial -from .utils import get_public_url, merge_dicts - 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. - """ + """Twilio connector over TwilioClient.""" + def __init__(self, settings): """ :param settings: `Twilio Settings` doctype @@ -24,22 +25,19 @@ class Twilio: @classmethod def connect(self): - """Make a twilio connection. - """ + """Make a twilio connection.""" settings = frappe.get_doc("Twilio Settings") if not (settings and settings.enabled): return return Twilio(settings=settings) def get_phone_numbers(self): - """Get account's twilio phone numbers. - """ + """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. - """ + 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) @@ -49,7 +47,7 @@ class Twilio: # Create a Voice grant and add to token voice_grant = VoiceGrant( outgoing_application_sid=self.application_sid, - incoming_allow=True, # Allow incoming calls + incoming_allow=True, # Allow incoming calls ) token.add_grant(voice_grant) return token.to_jwt() @@ -60,14 +58,13 @@ class Twilio: 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)') + return identity.replace("@", "(at)") @classmethod def emailid_from_identity(cls, identity: str): - """Convert safe identity string into emailID. - """ - return identity.replace('(at)', '@') - + """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) @@ -77,20 +74,19 @@ class Twilio: 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. - """ + """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' + recording_status_callback_event="completed", ) dial.number( to_number, - status_callback_event='initiated ringing answered completed', + status_callback_event="initiated ringing answered completed", status_callback=self.get_update_call_status_callback_url(), - status_callback_method='POST' + status_callback_method="POST", ) resp.append(dial) return resp @@ -98,21 +94,20 @@ class Twilio: 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. - """ + 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' + recording_status_callback_event="completed", ) dial.client( client, - status_callback_event='initiated ringing answered completed', + status_callback_event="initiated ringing answered completed", status_callback=self.get_update_call_status_callback_url(), - status_callback_method='POST' + status_callback_method="POST", ) resp.append(dial) return resp @@ -122,12 +117,13 @@ class Twilio: twilio_settings = frappe.get_doc("Twilio Settings") if not twilio_settings.enabled: frappe.throw(_("Please enable twilio settings before making a call.")) - - auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token') + + auth_token = get_decrypted_password("Twilio Settings", "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 @@ -145,17 +141,18 @@ class IncomingCall: if not attender: resp = VoiceResponse() - resp.say(_('Agent is unavailable to take the call, please call after some time.')) + 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']) + 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'])) + 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') + >>> get_twilio_number_owners("+11234567890") { 'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'}, 'owner2': {....} @@ -163,105 +160,106 @@ def get_twilio_number_owners(phone_number): """ # 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 == '+']) + phone_number = "".join([c for c in phone_number if c.isdigit() or c == "+"]) user_voice_settings = frappe.get_all( - 'Twilio Agents', - filters={'twilio_number': phone_number}, - fields=["name", "call_receiving_device"] + "Twilio Agents", filters={"twilio_number": phone_number}, fields=["name", "call_receiving_device"] ) - user_wise_voice_settings = {user['name']: user for user in user_voice_settings} + 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", 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} + 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(""" + """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}) + """, + {"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 + """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') + 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') + 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)): + 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): + 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') + 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' + 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') + 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') + 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() + """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. - """ + """Convert call details into dict.""" direction = self.get_direction() from_number = self.get_from_number() to_number = self.get_to_number() - caller = '' - receiver = '' + 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 '' + 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 '' + 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, - } \ No newline at end of file + "type": direction, + "status": self.call_status, + "id": self.call_sid, + "from": from_number, + "to": to_number, + "receiver": receiver, + "caller": caller, + } diff --git a/crm/integrations/twilio/utils.py b/crm/integrations/twilio/utils.py index 2f037684..a47e604d 100644 --- a/crm/integrations/twilio/utils.py +++ b/crm/integrations/twilio/utils.py @@ -1,7 +1,7 @@ from frappe.utils import get_url -def get_public_url(path: str=None): +def get_public_url(path: str | None = None): return get_url().split(":8", 1)[0] + path @@ -13,11 +13,12 @@ def merge_dicts(d1: dict, d2: dict): ) ... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}} """ - return {k:{**v, **d2.get(k, {})} for k, v in d1.items()} + return {k: {**v, **d2.get(k, {})} for k, v in d1.items()} + def parse_mobile_no(mobile_no: str): """Parse mobile number to remove spaces, brackets, etc. - >>> parse_mobile_no('+91 (766) 667 6666') - ... '+917666676666' + >>> parse_mobile_no("+91 (766) 667 6666") + ... "+917666676666" """ - return ''.join([c for c in mobile_no if c.isdigit() or c == '+']) \ No newline at end of file + return "".join([c for c in mobile_no if c.isdigit() or c == "+"])