From b13fa6a5033f868b833cda3ad493a249cce26005 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 28 Aug 2023 02:36:44 +0530 Subject: [PATCH] fix: create call logs with recording_url --- crm/crm/doctype/crm_call_log/__init__.py | 0 crm/crm/doctype/crm_call_log/crm_call_log.js | 8 ++ .../doctype/crm_call_log/crm_call_log.json | 129 ++++++++++++++++++ crm/crm/doctype/crm_call_log/crm_call_log.py | 9 ++ .../doctype/crm_call_log/test_crm_call_log.py | 9 ++ .../twilio_settings/twilio_settings.json | 21 ++- crm/twilio/api.py | 61 ++++++++- crm/twilio/twilio_handler.py | 56 ++++++-- frontend/src/components/CallUI.vue | 23 +++- 9 files changed, 296 insertions(+), 20 deletions(-) create mode 100644 crm/crm/doctype/crm_call_log/__init__.py create mode 100644 crm/crm/doctype/crm_call_log/crm_call_log.js create mode 100644 crm/crm/doctype/crm_call_log/crm_call_log.json create mode 100644 crm/crm/doctype/crm_call_log/crm_call_log.py create mode 100644 crm/crm/doctype/crm_call_log/test_crm_call_log.py diff --git a/crm/crm/doctype/crm_call_log/__init__.py b/crm/crm/doctype/crm_call_log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/crm/doctype/crm_call_log/crm_call_log.js b/crm/crm/doctype/crm_call_log/crm_call_log.js new file mode 100644 index 00000000..de12b62c --- /dev/null +++ b/crm/crm/doctype/crm_call_log/crm_call_log.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("CRM Call Log", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/crm/doctype/crm_call_log/crm_call_log.json b/crm/crm/doctype/crm_call_log/crm_call_log.json new file mode 100644 index 00000000..8ed05487 --- /dev/null +++ b/crm/crm/doctype/crm_call_log/crm_call_log.json @@ -0,0 +1,129 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:id", + "creation": "2023-08-28 00:23:36.229137", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "id", + "from", + "status", + "call_received_by", + "medium", + "start_time", + "column_break_ufnp", + "type", + "to", + "lead", + "duration", + "recording_url", + "end_time" + ], + "fields": [ + { + "fieldname": "id", + "fieldtype": "Data", + "label": "ID", + "unique": 1 + }, + { + "fieldname": "from", + "fieldtype": "Data", + "in_list_view": 1, + "label": "From" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Ringing\nIn Progress\nCompleted\nFailed\nBusy\nNo Answer\nQueued\nCanceled", + "read_only": 1 + }, + { + "depends_on": "to", + "fieldname": "call_received_by", + "fieldtype": "Link", + "label": "Call Received By", + "options": "User" + }, + { + "fieldname": "start_time", + "fieldtype": "Datetime", + "label": "Start Time" + }, + { + "fieldname": "medium", + "fieldtype": "Data", + "label": "Medium" + }, + { + "fieldname": "column_break_ufnp", + "fieldtype": "Column Break" + }, + { + "fieldname": "type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Type", + "options": "Incoming\nOutgoing" + }, + { + "fieldname": "to", + "fieldtype": "Data", + "in_list_view": 1, + "label": "To" + }, + { + "fieldname": "duration", + "fieldtype": "Duration", + "in_list_view": 1, + "label": "Duration", + "read_only": 1 + }, + { + "fieldname": "recording_url", + "fieldtype": "Data", + "label": "Recording URL" + }, + { + "fieldname": "end_time", + "fieldtype": "Datetime", + "label": "End Time" + }, + { + "fieldname": "lead", + "fieldtype": "Link", + "label": "Lead/Deal", + "options": "CRM Lead" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-08-28 01:34:24.864624", + "modified_by": "Administrator", + "module": "CRM", + "name": "CRM Call Log", + "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": [] +} \ No newline at end of file diff --git a/crm/crm/doctype/crm_call_log/crm_call_log.py b/crm/crm/doctype/crm_call_log/crm_call_log.py new file mode 100644 index 00000000..748b75d9 --- /dev/null +++ b/crm/crm/doctype/crm_call_log/crm_call_log.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 CRMCallLog(Document): + pass diff --git a/crm/crm/doctype/crm_call_log/test_crm_call_log.py b/crm/crm/doctype/crm_call_log/test_crm_call_log.py new file mode 100644 index 00000000..9c8198a4 --- /dev/null +++ b/crm/crm/doctype/crm_call_log/test_crm_call_log.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 TestCRMCallLog(FrappeTestCase): + pass diff --git a/crm/crm/doctype/twilio_settings/twilio_settings.json b/crm/crm/doctype/twilio_settings/twilio_settings.json index 72f90de8..1cc502cb 100644 --- a/crm/crm/doctype/twilio_settings/twilio_settings.json +++ b/crm/crm/doctype/twilio_settings/twilio_settings.json @@ -12,7 +12,10 @@ "api_secret", "column_break_idds", "auth_token", - "twiml_sid" + "twiml_sid", + "section_break_ssqj", + "record_calls", + "column_break_avmt" ], "fields": [ { @@ -47,12 +50,26 @@ "fieldname": "twiml_sid", "fieldtype": "Data", "label": "TwiML SID" + }, + { + "fieldname": "section_break_ssqj", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "record_calls", + "fieldtype": "Check", + "label": "Record Calls" + }, + { + "fieldname": "column_break_avmt", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-08-17 18:38:02.655918", + "modified": "2023-08-28 00:44:24.942914", "modified_by": "Administrator", "module": "CRM", "name": "Twilio Settings", diff --git a/crm/twilio/api.py b/crm/twilio/api.py index a1c061de..ebb300e0 100644 --- a/crm/twilio/api.py +++ b/crm/twilio/api.py @@ -2,7 +2,8 @@ from werkzeug.wrappers import Response import json import frappe -from .twilio_handler import Twilio, IncomingCall +from frappe import _ +from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails @frappe.whitelist() def generate_access_token(): @@ -46,19 +47,57 @@ def voice(**kwargs): 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) + 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) + 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() +def create_call_log(call_details: TwilioCallDetails): + call_log = frappe.get_doc({**call_details.to_dict(), + 'doctype': 'CRM Call Log', + 'medium': 'Twilio' + }) + + call_log.flags.ignore_permissions = True + call_log.save() + frappe.db.commit() + +@frappe.whitelist() +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 + + call_details = twilio.get_call_info(call_sid) + call_log = frappe.get_doc("CRM Call Log", call_sid) + call_log.status = status or TwilioCallDetails.get_call_status(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.flags.ignore_permissions = True + call_log.save() + frappe.db.commit() + +@frappe.whitelist(allow_guest=True) +def update_recording_info(**kwargs): + try: + args = frappe._dict(kwargs) + recording_url = args.RecordingUrl + call_sid = args.CallSid + update_call_log(call_sid) + frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url) + except: + frappe.log_error(title=_("Failed to capture Twilio recording")) @frappe.whitelist(allow_guest=True) def get_call_info(**kwargs): @@ -78,4 +117,14 @@ def get_call_info(**kwargs): client = Twilio.get_twilio_client() client.calls(args.ParentCallSid).user_defined_messages.create( content=json.dumps(call_info) - ) \ No newline at end of file + ) + +def get_datetime_from_timestamp(timestamp): + from datetime import datetime + from pytz import timezone + + 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(timezone(system_timezone)) + return frappe.utils.format_datetime(converted_datetime, 'yyyy-MM-dd HH:mm:ss') \ No newline at end of file diff --git a/crm/twilio/twilio_handler.py b/crm/twilio/twilio_handler.py index 1206cc74..7ab8dfa3 100644 --- a/crm/twilio/twilio_handler.py +++ b/crm/twilio/twilio_handler.py @@ -1,5 +1,3 @@ -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 @@ -69,6 +67,10 @@ class Twilio: """Convert safe identity string into emailID. """ return identity.replace('(at)', '@') + + def get_recording_status_callback_url(self): + url_path = "/api/method/crm.twilio.api.update_recording_info" + return get_public_url(url_path) def get_call_status_callback_url(self): url_path = "/api/method/crm.twilio.api.get_call_info" @@ -80,9 +82,9 @@ class Twilio: 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' + record=self.settings.record_calls, + recording_status_callback=self.get_recording_status_callback_url(), + recording_status_callback_event='completed' ) dial.number( to_number, @@ -102,9 +104,9 @@ class Twilio: 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' + 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) @@ -187,3 +189,41 @@ def get_the_call_attender(owners): 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 + self._call_to = call_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): + return { + 'type': self.get_direction(), + 'status': self.call_status, + 'id': self.call_sid, + 'from': self.get_from_number(), + 'to': self.get_to_number() + } \ No newline at end of file diff --git a/frontend/src/components/CallUI.vue b/frontend/src/components/CallUI.vue index 3d867b1d..34d20ac9 100644 --- a/frontend/src/components/CallUI.vue +++ b/frontend/src/components/CallUI.vue @@ -247,6 +247,20 @@ function addDeviceListeners() { device.on('connect', (conn) => { log.value = 'Successfully established call!' }) + + device.on('disconnect', (conn) => { + log.value = 'Call ended disconnect.' + update_call_log(conn) + }) +} + +function update_call_log(conn, status = 'Completed') { + console.log('connection', conn) + if (!conn.parameters.CallSid) return + call('crm.twilio.api.update_call_log', { + call_sid: conn.parameters.CallSid, + status: status, + }) } function toggleMute() { @@ -303,7 +317,7 @@ function hangUpCall() { } function handleDisconnectedIncomingCall() { - log.value = `Call ended.` + log.value = `Call ended from handle disconnected Incoming call.` showCallPopup.value = false if (showSmallCallWindow.value == undefined) { showSmallCallWindow = false @@ -346,8 +360,8 @@ async function makeOutgoingCall(number) { calling.value = true onCall.value = false }) - _call.value.on('disconnect', () => { - log.value = `Call ended.` + _call.value.on('disconnect', (conn) => { + log.value = `Call ended from makeOutgoing call disconnect.` calling.value = false onCall.value = false showCallPopup.value = false @@ -356,9 +370,10 @@ async function makeOutgoingCall(number) { callStatus.value = '' muted.value = false counterUp.value.stop() + update_call_log(conn) }) _call.value.on('cancel', () => { - log.value = `Call ended.` + log.value = `Call ended from makeOutgoing call cancel.` calling.value = false onCall.value = false showCallPopup.value = false