From b3abc00e82960a493bc202d997d6b1db32ba53a2 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 17 Aug 2023 23:06:49 +0530 Subject: [PATCH] feat: phone call (testing) --- crm/crm/doctype/twilio_agents/__init__.py | 0 .../twilio_agents/test_twilio_agents.py | 9 + .../doctype/twilio_agents/twilio_agents.js | 8 + .../doctype/twilio_agents/twilio_agents.json | 78 ++++++++ .../doctype/twilio_agents/twilio_agents.py | 9 + crm/crm/doctype/twilio_settings/__init__.py | 0 .../twilio_settings/test_twilio_settings.py | 9 + .../twilio_settings/twilio_settings.js | 8 + .../twilio_settings/twilio_settings.json | 76 ++++++++ .../twilio_settings/twilio_settings.py | 9 + crm/twilio/api.py | 105 ++++++++++ crm/twilio/twilio_handler.py | 184 ++++++++++++++++++ crm/twilio/utils.py | 16 ++ frontend/package.json | 1 + frontend/src/components/CommunicationArea.vue | 101 +++++++++- pyproject.toml | 1 + yarn.lock | 68 +++++++ 17 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 crm/crm/doctype/twilio_agents/__init__.py create mode 100644 crm/crm/doctype/twilio_agents/test_twilio_agents.py create mode 100644 crm/crm/doctype/twilio_agents/twilio_agents.js create mode 100644 crm/crm/doctype/twilio_agents/twilio_agents.json create mode 100644 crm/crm/doctype/twilio_agents/twilio_agents.py create mode 100644 crm/crm/doctype/twilio_settings/__init__.py create mode 100644 crm/crm/doctype/twilio_settings/test_twilio_settings.py create mode 100644 crm/crm/doctype/twilio_settings/twilio_settings.js create mode 100644 crm/crm/doctype/twilio_settings/twilio_settings.json create mode 100644 crm/crm/doctype/twilio_settings/twilio_settings.py create mode 100644 crm/twilio/api.py create mode 100644 crm/twilio/twilio_handler.py create mode 100644 crm/twilio/utils.py diff --git a/crm/crm/doctype/twilio_agents/__init__.py b/crm/crm/doctype/twilio_agents/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/crm/doctype/twilio_agents/test_twilio_agents.py b/crm/crm/doctype/twilio_agents/test_twilio_agents.py new file mode 100644 index 00000000..72cdd5cc --- /dev/null +++ b/crm/crm/doctype/twilio_agents/test_twilio_agents.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestTwilioAgents(FrappeTestCase): + pass diff --git a/crm/crm/doctype/twilio_agents/twilio_agents.js b/crm/crm/doctype/twilio_agents/twilio_agents.js new file mode 100644 index 00000000..8d8bf294 --- /dev/null +++ b/crm/crm/doctype/twilio_agents/twilio_agents.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Twilio Agents", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/crm/doctype/twilio_agents/twilio_agents.json b/crm/crm/doctype/twilio_agents/twilio_agents.json new file mode 100644 index 00000000..29890aea --- /dev/null +++ b/crm/crm/doctype/twilio_agents/twilio_agents.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:user", + "creation": "2023-08-17 19:59:56.239729", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "user", + "user_name", + "call_receiving_device", + "column_break_ljne", + "twilio_number" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "label": "User", + "options": "User", + "unique": 1 + }, + { + "fieldname": "column_break_ljne", + "fieldtype": "Column Break" + }, + { + "fieldname": "twilio_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Twilio Number", + "options": "Phone" + }, + { + "fetch_from": "user.full_name", + "fieldname": "user_name", + "fieldtype": "Data", + "label": "User Name", + "read_only": 1 + }, + { + "default": "Computer", + "fieldname": "call_receiving_device", + "fieldtype": "Select", + "label": "Device", + "options": "Computer\nPhone" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-08-17 22:21:00.606384", + "modified_by": "Administrator", + "module": "CRM", + "name": "Twilio Agents", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/crm/crm/doctype/twilio_agents/twilio_agents.py b/crm/crm/doctype/twilio_agents/twilio_agents.py new file mode 100644 index 00000000..fb660dd8 --- /dev/null +++ b/crm/crm/doctype/twilio_agents/twilio_agents.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class TwilioAgents(Document): + pass diff --git a/crm/crm/doctype/twilio_settings/__init__.py b/crm/crm/doctype/twilio_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/crm/doctype/twilio_settings/test_twilio_settings.py b/crm/crm/doctype/twilio_settings/test_twilio_settings.py new file mode 100644 index 00000000..095e747e --- /dev/null +++ b/crm/crm/doctype/twilio_settings/test_twilio_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestTwilioSettings(FrappeTestCase): + pass diff --git a/crm/crm/doctype/twilio_settings/twilio_settings.js b/crm/crm/doctype/twilio_settings/twilio_settings.js new file mode 100644 index 00000000..9837c18d --- /dev/null +++ b/crm/crm/doctype/twilio_settings/twilio_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Twilio Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/crm/doctype/twilio_settings/twilio_settings.json b/crm/crm/doctype/twilio_settings/twilio_settings.json new file mode 100644 index 00000000..72f90de8 --- /dev/null +++ b/crm/crm/doctype/twilio_settings/twilio_settings.json @@ -0,0 +1,76 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-08-17 18:38:02.655918", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "account_sid", + "api_key", + "api_secret", + "column_break_idds", + "auth_token", + "twiml_sid" + ], + "fields": [ + { + "fieldname": "account_sid", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Account SID", + "reqd": 1 + }, + { + "fieldname": "api_key", + "fieldtype": "Data", + "label": "API Key" + }, + { + "fieldname": "api_secret", + "fieldtype": "Password", + "label": "API Secret" + }, + { + "fieldname": "column_break_idds", + "fieldtype": "Column Break" + }, + { + "fieldname": "auth_token", + "fieldtype": "Password", + "in_list_view": 1, + "label": "Auth Token", + "reqd": 1 + }, + { + "fieldname": "twiml_sid", + "fieldtype": "Data", + "label": "TwiML SID" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2023-08-17 18:38:02.655918", + "modified_by": "Administrator", + "module": "CRM", + "name": "Twilio Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/crm/crm/doctype/twilio_settings/twilio_settings.py b/crm/crm/doctype/twilio_settings/twilio_settings.py new file mode 100644 index 00000000..a2b3b497 --- /dev/null +++ b/crm/crm/doctype/twilio_settings/twilio_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class TwilioSettings(Document): + pass diff --git a/crm/twilio/api.py b/crm/twilio/api.py new file mode 100644 index 00000000..897c128d --- /dev/null +++ b/crm/twilio/api.py @@ -0,0 +1,105 @@ +from werkzeug.wrappers import Response + +import frappe +from twilio.rest import Client +from .twilio_handler import Twilio, IncomingCall + +@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('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" + } + + 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. + """ + def _get_caller_number(caller): + identity = caller.replace('client:', '').strip() + user = Twilio.emailid_from_identity(identity) + return frappe.db.get_value('Twilio Agents', user, 'twilio_number') + + args = frappe._dict(kwargs) + twilio = Twilio.connect() + if not twilio: + return + + assert args.AccountSid == twilio.account_sid + assert args.ApplicationSid == twilio.application_sid + + # Generate TwiML instructions to make a call + from_number = _get_caller_number(args.Caller) + resp = twilio.generate_twilio_dial_response(from_number, args.To) + + # call_details = TwilioCallDetails(args, call_from=from_number) + # create_call_log(call_details) + return Response(resp.to_xml(), mimetype='text/xml') + +@frappe.whitelist(allow_guest=True) +def twilio_incoming_call_handler(**kwargs): + args = frappe._dict(kwargs) + # call_details = TwilioCallDetails(args) + # create_call_log(call_details) + + resp = IncomingCall(args.From, args.To).process() + return Response(resp.to_xml(), mimetype='text/xml') + + + + + + + + + + + + + + + + + +# @frappe.whitelist(allow_guest=True) +# def twilio_incoming_call_handler(**kwargs): +# args = frappe._dict(kwargs) +# resp = VoiceResponse() + +# resp.say("Thank you for calling! Have a great day.", voice='Polly.Amy') + +# todo = frappe.get_doc({ +# "doctype": "ToDo", +# "description": "Call from {0} to {1} is {2}".format(args.From, args.To, args.CallStatus), +# }) +# todo.insert(ignore_permissions=True) + + +@frappe.whitelist() +def make_call(to, from_='+13134748669'): + application_sid = 'APa7a85c103b7477c8eb25e9a8aafae055' + account_sid = 'AC1a65d630772fbdb3a9a977c46aacef61' + auth_token = '1eb29b621c6a60f4afdde18160bc1e2d' + client = Client(account_sid, auth_token) + + call = client.calls.create( + url='http://demo.twilio.com/docs/voice.xml', + to=to, + from_=from_ + ) + + print(call.sid) \ No newline at end of file diff --git a/crm/twilio/twilio_handler.py b/crm/twilio/twilio_handler.py new file mode 100644 index 00000000..a041942b --- /dev/null +++ b/crm/twilio/twilio_handler.py @@ -0,0 +1,184 @@ +import re +import json +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 + +class Twilio: + """Twilio connector over TwilioClient. + """ + def __init__(self, settings): + """ + :param settings: `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(self): + """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. + """ + 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 charaters `@` 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/twilio_integration.twilio_integration.api.update_recording_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) + 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) + resp.append(dial) + return resp + + @classmethod + def get_twilio_client(self): + twilio_settings = frappe.get_doc("Twilio Settings") + + 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 + 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) + + 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': {....} + } + """ + user_voice_settings = frappe.get_all( + '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_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): + """Get attender details from list of owners + """ + if not owners: return + current_loggedin_users = get_active_loggedin_users(list(owners.keys())) + 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 diff --git a/crm/twilio/utils.py b/crm/twilio/utils.py new file mode 100644 index 00000000..0112bdb0 --- /dev/null +++ b/crm/twilio/utils.py @@ -0,0 +1,16 @@ +from frappe.utils import get_url + + +def get_public_url(path: str=None): + return get_url(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()} \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 6d35efdb..6dd94c27 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@tiptap/vue-3": "^2.0.4", + "@twilio/voice-sdk": "^2.7.1", "@vitejs/plugin-vue": "^4.2.3", "@vueuse/core": "^10.3.0", "@vueuse/integrations": "^10.3.0", diff --git a/frontend/src/components/CommunicationArea.vue b/frontend/src/components/CommunicationArea.vue index 176415d1..7ebcba24 100644 --- a/frontend/src/components/CommunicationArea.vue +++ b/frontend/src/components/CommunicationArea.vue @@ -1,12 +1,18 @@