fix: added telephony medium field to capture Exotel, Twilio or Manual call log
This commit is contained in:
parent
3991c819ba
commit
5c2aa522fc
@ -9,6 +9,8 @@
|
|||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
|
"telephony_medium",
|
||||||
|
"section_break_gyqe",
|
||||||
"id",
|
"id",
|
||||||
"from",
|
"from",
|
||||||
"status",
|
"status",
|
||||||
@ -24,7 +26,9 @@
|
|||||||
"caller",
|
"caller",
|
||||||
"recording_url",
|
"recording_url",
|
||||||
"end_time",
|
"end_time",
|
||||||
"note"
|
"note",
|
||||||
|
"section_break_kebz",
|
||||||
|
"links"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@ -75,6 +79,7 @@
|
|||||||
"label": "To"
|
"label": "To"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"description": "Call duration in seconds",
|
||||||
"fieldname": "duration",
|
"fieldname": "duration",
|
||||||
"fieldtype": "Duration",
|
"fieldtype": "Duration",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
@ -123,11 +128,31 @@
|
|||||||
"fieldtype": "Dynamic Link",
|
"fieldtype": "Dynamic Link",
|
||||||
"label": "Reference Name",
|
"label": "Reference Name",
|
||||||
"options": "reference_doctype"
|
"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,
|
"index_web_pages_for_search": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-16 13:23:09.201843",
|
"modified": "2025-01-11 16:27:56.992950",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "FCRM",
|
"module": "FCRM",
|
||||||
"name": "CRM Call Log",
|
"name": "CRM Call Log",
|
||||||
|
|||||||
@ -1,44 +1,45 @@
|
|||||||
from werkzeug.wrappers import Response
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
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
|
from .utils import parse_mobile_no
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def is_enabled():
|
def is_enabled():
|
||||||
return frappe.db.get_single_value("Twilio Settings", "enabled")
|
return frappe.db.get_single_value("Twilio Settings", "enabled")
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def generate_access_token():
|
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()
|
twilio = Twilio.connect()
|
||||||
if not twilio:
|
if not twilio:
|
||||||
return {}
|
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:
|
if not from_number:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": "caller_phone_identity_missing",
|
"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)
|
token = twilio.generate_voice_access_token(identity=frappe.session.user)
|
||||||
return {
|
return {"token": frappe.safe_decode(token)}
|
||||||
'token': frappe.safe_decode(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def voice(**kwargs):
|
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):
|
def _get_caller_number(caller):
|
||||||
identity = caller.replace('client:', '').strip()
|
identity = caller.replace("client:", "").strip()
|
||||||
user = Twilio.emailid_from_identity(identity)
|
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)
|
args = frappe._dict(kwargs)
|
||||||
twilio = Twilio.connect()
|
twilio = Twilio.connect()
|
||||||
@ -54,7 +55,8 @@ def voice(**kwargs):
|
|||||||
|
|
||||||
call_details = TwilioCallDetails(args, call_from=from_number)
|
call_details = TwilioCallDetails(args, call_from=from_number)
|
||||||
create_call_log(call_details)
|
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)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def twilio_incoming_call_handler(**kwargs):
|
def twilio_incoming_call_handler(**kwargs):
|
||||||
@ -63,23 +65,24 @@ def twilio_incoming_call_handler(**kwargs):
|
|||||||
create_call_log(call_details)
|
create_call_log(call_details)
|
||||||
|
|
||||||
resp = IncomingCall(args.From, args.To).process()
|
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):
|
def create_call_log(call_details: TwilioCallDetails):
|
||||||
call_log = frappe.get_doc({**call_details.to_dict(),
|
call_log = frappe.get_doc(
|
||||||
'doctype': 'CRM Call Log',
|
{**call_details.to_dict(), "doctype": "CRM Call Log", "telephony_medium": "Twilio"}
|
||||||
'medium': 'Twilio'
|
)
|
||||||
})
|
|
||||||
call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log)
|
call_log.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log)
|
||||||
call_log.flags.ignore_permissions = True
|
call_log.flags.ignore_permissions = True
|
||||||
call_log.save()
|
call_log.save()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
def update_call_log(call_sid, status=None):
|
def update_call_log(call_sid, status=None):
|
||||||
"""Update call log status.
|
"""Update call log status."""
|
||||||
"""
|
|
||||||
twilio = Twilio.connect()
|
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_details = twilio.get_call_info(call_sid)
|
||||||
call_log = frappe.get_doc("CRM Call Log", 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()
|
call_log.save()
|
||||||
frappe.db.commit()
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def update_recording_info(**kwargs):
|
def update_recording_info(**kwargs):
|
||||||
try:
|
try:
|
||||||
@ -105,6 +109,7 @@ def update_recording_info(**kwargs):
|
|||||||
except:
|
except:
|
||||||
frappe.log_error(title=_("Failed to capture Twilio recording"))
|
frappe.log_error(title=_("Failed to capture Twilio recording"))
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist(allow_guest=True)
|
@frappe.whitelist(allow_guest=True)
|
||||||
def update_call_status_info(**kwargs):
|
def update_call_status_info(**kwargs):
|
||||||
try:
|
try:
|
||||||
@ -113,53 +118,54 @@ def update_call_status_info(**kwargs):
|
|||||||
update_call_log(parent_call_sid, status=args.CallStatus)
|
update_call_log(parent_call_sid, status=args.CallStatus)
|
||||||
|
|
||||||
call_info = {
|
call_info = {
|
||||||
'ParentCallSid': args.ParentCallSid,
|
"ParentCallSid": args.ParentCallSid,
|
||||||
'CallSid': args.CallSid,
|
"CallSid": args.CallSid,
|
||||||
'CallStatus': args.CallStatus,
|
"CallStatus": args.CallStatus,
|
||||||
'CallDuration': args.CallDuration,
|
"CallDuration": args.CallDuration,
|
||||||
'From': args.From,
|
"From": args.From,
|
||||||
'To': args.To,
|
"To": args.To,
|
||||||
}
|
}
|
||||||
|
|
||||||
client = Twilio.get_twilio_client()
|
client = Twilio.get_twilio_client()
|
||||||
client.calls(args.ParentCallSid).user_defined_messages.create(
|
client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info))
|
||||||
content=json.dumps(call_info)
|
|
||||||
)
|
|
||||||
except:
|
except:
|
||||||
frappe.log_error(title=_("Failed to update Twilio call status"))
|
frappe.log_error(title=_("Failed to update Twilio call status"))
|
||||||
|
|
||||||
|
|
||||||
def get_datetime_from_timestamp(timestamp):
|
def get_datetime_from_timestamp(timestamp):
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from zoneinfo import ZoneInfo
|
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_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 = datetime.strptime(datetime_utc_tz_str, "%Y-%m-%d %H:%M:%S%z")
|
||||||
system_timezone = frappe.utils.get_system_timezone()
|
system_timezone = frappe.utils.get_system_timezone()
|
||||||
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(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()
|
@frappe.whitelist()
|
||||||
def add_note_to_call_log(call_sid, note):
|
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()
|
twilio = Twilio.connect()
|
||||||
if not twilio: return
|
if not twilio:
|
||||||
|
return
|
||||||
|
|
||||||
call_details = twilio.get_call_info(call_sid)
|
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.set_value("CRM Call Log", sid, "note", note)
|
||||||
frappe.db.commit()
|
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)
|
mobile_no = parse_mobile_no(mobile_no)
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT name, mobile_no
|
SELECT name, mobile_no
|
||||||
FROM `tab{doctype}`
|
FROM `tab{doctype}`
|
||||||
@ -170,12 +176,12 @@ def get_lead_or_deal_from_number(call):
|
|||||||
return data[0].name if data else None
|
return data[0].name if data else None
|
||||||
|
|
||||||
doctype = "CRM Deal"
|
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
|
doc = find_record(doctype, number) or None
|
||||||
if not doc:
|
if not doc:
|
||||||
doctype = "CRM Lead"
|
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:
|
if not doc:
|
||||||
doc = find_record(doctype, number)
|
doc = find_record(doctype, number)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.utils.password import get_decrypted_password
|
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:
|
class Twilio:
|
||||||
"""Twilio connector over TwilioClient.
|
"""Twilio connector over TwilioClient."""
|
||||||
"""
|
|
||||||
def __init__(self, settings):
|
def __init__(self, settings):
|
||||||
"""
|
"""
|
||||||
:param settings: `Twilio Settings` doctype
|
:param settings: `Twilio Settings` doctype
|
||||||
@ -24,22 +25,19 @@ class Twilio:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def connect(self):
|
def connect(self):
|
||||||
"""Make a twilio connection.
|
"""Make a twilio connection."""
|
||||||
"""
|
|
||||||
settings = frappe.get_doc("Twilio Settings")
|
settings = frappe.get_doc("Twilio Settings")
|
||||||
if not (settings and settings.enabled):
|
if not (settings and settings.enabled):
|
||||||
return
|
return
|
||||||
return Twilio(settings=settings)
|
return Twilio(settings=settings)
|
||||||
|
|
||||||
def get_phone_numbers(self):
|
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()
|
numbers = self.twilio_client.incoming_phone_numbers.list()
|
||||||
return [n.phone_number for n in numbers]
|
return [n.phone_number for n in numbers]
|
||||||
|
|
||||||
def generate_voice_access_token(self, identity: str, ttl=60*60):
|
def generate_voice_access_token(self, identity: str, ttl=60 * 60):
|
||||||
"""Generates a token required to make voice calls from the browser.
|
"""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 is used by twilio to identify the user uniqueness at browser(or any endpoints).
|
||||||
identity = self.safe_identity(identity)
|
identity = self.safe_identity(identity)
|
||||||
|
|
||||||
@ -49,7 +47,7 @@ class Twilio:
|
|||||||
# Create a Voice grant and add to token
|
# Create a Voice grant and add to token
|
||||||
voice_grant = VoiceGrant(
|
voice_grant = VoiceGrant(
|
||||||
outgoing_application_sid=self.application_sid,
|
outgoing_application_sid=self.application_sid,
|
||||||
incoming_allow=True, # Allow incoming calls
|
incoming_allow=True, # Allow incoming calls
|
||||||
)
|
)
|
||||||
token.add_grant(voice_grant)
|
token.add_grant(voice_grant)
|
||||||
return token.to_jwt()
|
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)
|
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)
|
https://www.twilio.com/docs/voice/client/errors (#31105)
|
||||||
"""
|
"""
|
||||||
return identity.replace('@', '(at)')
|
return identity.replace("@", "(at)")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def emailid_from_identity(cls, identity: str):
|
def emailid_from_identity(cls, identity: str):
|
||||||
"""Convert safe identity string into emailID.
|
"""Convert safe identity string into emailID."""
|
||||||
"""
|
return identity.replace("(at)", "@")
|
||||||
return identity.replace('(at)', '@')
|
|
||||||
|
|
||||||
def get_recording_status_callback_url(self):
|
def get_recording_status_callback_url(self):
|
||||||
url_path = "/api/method/crm.integrations.twilio.api.update_recording_info"
|
url_path = "/api/method/crm.integrations.twilio.api.update_recording_info"
|
||||||
return get_public_url(url_path)
|
return get_public_url(url_path)
|
||||||
@ -77,20 +74,19 @@ class Twilio:
|
|||||||
return get_public_url(url_path)
|
return get_public_url(url_path)
|
||||||
|
|
||||||
def generate_twilio_dial_response(self, from_number: str, to_number: str):
|
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()
|
resp = VoiceResponse()
|
||||||
dial = Dial(
|
dial = Dial(
|
||||||
caller_id=from_number,
|
caller_id=from_number,
|
||||||
record=self.settings.record_calls,
|
record=self.settings.record_calls,
|
||||||
recording_status_callback=self.get_recording_status_callback_url(),
|
recording_status_callback=self.get_recording_status_callback_url(),
|
||||||
recording_status_callback_event='completed'
|
recording_status_callback_event="completed",
|
||||||
)
|
)
|
||||||
dial.number(
|
dial.number(
|
||||||
to_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=self.get_update_call_status_callback_url(),
|
||||||
status_callback_method='POST'
|
status_callback_method="POST",
|
||||||
)
|
)
|
||||||
resp.append(dial)
|
resp.append(dial)
|
||||||
return resp
|
return resp
|
||||||
@ -98,21 +94,20 @@ class Twilio:
|
|||||||
def get_call_info(self, call_sid):
|
def get_call_info(self, call_sid):
|
||||||
return self.twilio_client.calls(call_sid).fetch()
|
return self.twilio_client.calls(call_sid).fetch()
|
||||||
|
|
||||||
def generate_twilio_client_response(self, client, ring_tone='at'):
|
def generate_twilio_client_response(self, client, ring_tone="at"):
|
||||||
"""Generates voice call instructions to forward the call to agents computer.
|
"""Generates voice call instructions to forward the call to agents computer."""
|
||||||
"""
|
|
||||||
resp = VoiceResponse()
|
resp = VoiceResponse()
|
||||||
dial = Dial(
|
dial = Dial(
|
||||||
ring_tone=ring_tone,
|
ring_tone=ring_tone,
|
||||||
record=self.settings.record_calls,
|
record=self.settings.record_calls,
|
||||||
recording_status_callback=self.get_recording_status_callback_url(),
|
recording_status_callback=self.get_recording_status_callback_url(),
|
||||||
recording_status_callback_event='completed'
|
recording_status_callback_event="completed",
|
||||||
)
|
)
|
||||||
dial.client(
|
dial.client(
|
||||||
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=self.get_update_call_status_callback_url(),
|
||||||
status_callback_method='POST'
|
status_callback_method="POST",
|
||||||
)
|
)
|
||||||
resp.append(dial)
|
resp.append(dial)
|
||||||
return resp
|
return resp
|
||||||
@ -122,12 +117,13 @@ class Twilio:
|
|||||||
twilio_settings = frappe.get_doc("Twilio Settings")
|
twilio_settings = frappe.get_doc("Twilio Settings")
|
||||||
if not twilio_settings.enabled:
|
if not twilio_settings.enabled:
|
||||||
frappe.throw(_("Please enable twilio settings before making a call."))
|
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)
|
client = TwilioClient(twilio_settings.account_sid, auth_token)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
class IncomingCall:
|
class IncomingCall:
|
||||||
def __init__(self, from_number, to_number, meta=None):
|
def __init__(self, from_number, to_number, meta=None):
|
||||||
self.from_number = from_number
|
self.from_number = from_number
|
||||||
@ -145,17 +141,18 @@ class IncomingCall:
|
|||||||
|
|
||||||
if not attender:
|
if not attender:
|
||||||
resp = VoiceResponse()
|
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
|
return resp
|
||||||
|
|
||||||
if attender['call_receiving_device'] == 'Phone':
|
if attender["call_receiving_device"] == "Phone":
|
||||||
return twilio.generate_twilio_dial_response(self.from_number, attender['mobile_no'])
|
return twilio.generate_twilio_dial_response(self.from_number, attender["mobile_no"])
|
||||||
else:
|
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):
|
def get_twilio_number_owners(phone_number):
|
||||||
"""Get list of users who is using the 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': '...'},
|
'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'},
|
||||||
'owner2': {....}
|
'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
|
# remove special characters from phone number and get only digits also remove white spaces
|
||||||
# keep + sign in the number at start of the number
|
# 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(
|
user_voice_settings = frappe.get_all(
|
||||||
'Twilio Agents',
|
"Twilio Agents", filters={"twilio_number": phone_number}, fields=["name", "call_receiving_device"]
|
||||||
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_general_settings = frappe.get_all(
|
||||||
'User',
|
"User", filters=[["name", "IN", user_wise_voice_settings.keys()]], fields=["name", "mobile_no"]
|
||||||
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)
|
return merge_dicts(user_wise_general_settings, user_wise_voice_settings)
|
||||||
|
|
||||||
|
|
||||||
def get_active_loggedin_users(users):
|
def get_active_loggedin_users(users):
|
||||||
"""Filter the current loggedin users from the given users list
|
"""Filter the current loggedin users from the given users list"""
|
||||||
"""
|
rows = frappe.db.sql(
|
||||||
rows = frappe.db.sql("""
|
"""
|
||||||
SELECT `user`
|
SELECT `user`
|
||||||
FROM `tabSessions`
|
FROM `tabSessions`
|
||||||
WHERE `user` IN %(users)s
|
WHERE `user` IN %(users)s
|
||||||
""", {'users': users})
|
""",
|
||||||
|
{"users": users},
|
||||||
|
)
|
||||||
return [row[0] for row in set(rows)]
|
return [row[0] for row in set(rows)]
|
||||||
|
|
||||||
|
|
||||||
def get_the_call_attender(owners, caller=None):
|
def get_the_call_attender(owners, caller=None):
|
||||||
"""Get attender details from list of owners
|
"""Get attender details from list of owners"""
|
||||||
"""
|
if not owners:
|
||||||
if not owners: return
|
return
|
||||||
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
|
current_loggedin_users = get_active_loggedin_users(list(owners.keys()))
|
||||||
|
|
||||||
if len(current_loggedin_users) > 1 and caller:
|
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:
|
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:
|
for user in current_loggedin_users:
|
||||||
if user == deal_owner:
|
if user == deal_owner:
|
||||||
current_loggedin_users = [user]
|
current_loggedin_users = [user]
|
||||||
|
|
||||||
for name, details in owners.items():
|
for name, details in owners.items():
|
||||||
if ((details['call_receiving_device'] == 'Phone' and details['mobile_no']) or
|
if (details["call_receiving_device"] == "Phone" and details["mobile_no"]) or (
|
||||||
(details['call_receiving_device'] == 'Computer' and name in current_loggedin_users)):
|
details["call_receiving_device"] == "Computer" and name in current_loggedin_users
|
||||||
|
):
|
||||||
return details
|
return details
|
||||||
|
|
||||||
|
|
||||||
class TwilioCallDetails:
|
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.call_info = call_info
|
||||||
self.account_sid = call_info.get('AccountSid')
|
self.account_sid = call_info.get("AccountSid")
|
||||||
self.application_sid = call_info.get('ApplicationSid')
|
self.application_sid = call_info.get("ApplicationSid")
|
||||||
self.call_sid = call_info.get('CallSid')
|
self.call_sid = call_info.get("CallSid")
|
||||||
self.call_status = self.get_call_status(call_info.get('CallStatus'))
|
self.call_status = self.get_call_status(call_info.get("CallStatus"))
|
||||||
self._call_from = call_from or call_info.get('From')
|
self._call_from = call_from or call_info.get("From")
|
||||||
self._call_to = call_to or call_info.get('To')
|
self._call_to = call_to or call_info.get("To")
|
||||||
|
|
||||||
def get_direction(self):
|
def get_direction(self):
|
||||||
if self.call_info.get('Caller').lower().startswith('client'):
|
if self.call_info.get("Caller").lower().startswith("client"):
|
||||||
return 'Outgoing'
|
return "Outgoing"
|
||||||
return 'Incoming'
|
return "Incoming"
|
||||||
|
|
||||||
def get_from_number(self):
|
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):
|
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
|
@classmethod
|
||||||
def get_call_status(cls, twilio_status):
|
def get_call_status(cls, twilio_status):
|
||||||
"""Convert Twilio given status into system status.
|
"""Convert Twilio given status into system status."""
|
||||||
"""
|
twilio_status = twilio_status or ""
|
||||||
twilio_status = twilio_status or ''
|
return " ".join(twilio_status.split("-")).title()
|
||||||
return ' '.join(twilio_status.split('-')).title()
|
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
"""Convert call details into dict.
|
"""Convert call details into dict."""
|
||||||
"""
|
|
||||||
direction = self.get_direction()
|
direction = self.get_direction()
|
||||||
from_number = self.get_from_number()
|
from_number = self.get_from_number()
|
||||||
to_number = self.get_to_number()
|
to_number = self.get_to_number()
|
||||||
caller = ''
|
caller = ""
|
||||||
receiver = ''
|
receiver = ""
|
||||||
|
|
||||||
if direction == 'Outgoing':
|
if direction == "Outgoing":
|
||||||
caller = self.call_info.get('Caller')
|
caller = self.call_info.get("Caller")
|
||||||
identity = caller.replace('client:', '').strip()
|
identity = caller.replace("client:", "").strip()
|
||||||
caller = Twilio.emailid_from_identity(identity) if identity else ''
|
caller = Twilio.emailid_from_identity(identity) if identity else ""
|
||||||
else:
|
else:
|
||||||
owners = get_twilio_number_owners(to_number)
|
owners = get_twilio_number_owners(to_number)
|
||||||
attender = get_the_call_attender(owners, from_number)
|
attender = get_the_call_attender(owners, from_number)
|
||||||
receiver = attender['name'] if attender else ''
|
receiver = attender["name"] if attender else ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'type': direction,
|
"type": direction,
|
||||||
'status': self.call_status,
|
"status": self.call_status,
|
||||||
'id': self.call_sid,
|
"id": self.call_sid,
|
||||||
'from': from_number,
|
"from": from_number,
|
||||||
'to': to_number,
|
"to": to_number,
|
||||||
'receiver': receiver,
|
"receiver": receiver,
|
||||||
'caller': caller,
|
"caller": caller,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from frappe.utils import get_url
|
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
|
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'}}
|
... {'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):
|
def parse_mobile_no(mobile_no: str):
|
||||||
"""Parse mobile number to remove spaces, brackets, etc.
|
"""Parse mobile number to remove spaces, brackets, etc.
|
||||||
>>> parse_mobile_no('+91 (766) 667 6666')
|
>>> parse_mobile_no("+91 (766) 667 6666")
|
||||||
... '+917666676666'
|
... "+917666676666"
|
||||||
"""
|
"""
|
||||||
return ''.join([c for c in mobile_no if c.isdigit() or c == '+'])
|
return "".join([c for c in mobile_no if c.isdigit() or c == "+"])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user