diff --git a/README.md b/README.md index e7123429..21503df7 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ The motivation behind building Frappe CRM stems from the need for a simple, cust ### Integrations - **Twilio:** Integrate Twilio to make and receive calls from the CRM. You can also record calls. It is a built-in integration. +- **Exotel:** Integrate Exotel to make and receive calls via agents mobile phone from the CRM. You can also record calls. It is a built-in integration. - **WhatsApp:** Integrate WhatsApp to send and receive messages from the CRM. [Frappe WhatsApp](https://github.com/shridarpatil/frappe_whatsapp) is used for this integration. - **ERPNext:** Integrate with [ERPNext](https://erpnext.com) to extend the CRM capabilities to include invoicing, accounting, and more. diff --git a/crm/api/activities.py b/crm/api/activities.py index 8cc714eb..7f6fb689 100644 --- a/crm/api/activities.py +++ b/crm/api/activities.py @@ -1,10 +1,13 @@ import json -from bs4 import BeautifulSoup import frappe +from bs4 import BeautifulSoup from frappe import _ -from frappe.utils.caching import redis_cache from frappe.desk.form.load import get_docinfo +from frappe.query_builder import JoinType + +from crm.fcrm.doctype.crm_call_log.crm_call_log import parse_call_log + @frappe.whitelist() def get_activities(name): @@ -15,11 +18,14 @@ def get_activities(name): else: frappe.throw(_("Document not found"), frappe.DoesNotExistError) + def get_deal_activities(name): - get_docinfo('', "CRM Deal", name) + get_docinfo("", "CRM Deal", name) docinfo = frappe.response["docinfo"] deal_meta = frappe.get_meta("CRM Deal") - deal_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields} + deal_fields = { + field.fieldname: {"label": field.label, "options": field.options} for field in deal_meta.fields + } avoid_fields = [ "lead", "response_by", @@ -43,13 +49,15 @@ def get_deal_activities(name): activities, calls, notes, tasks, attachments = get_lead_activities(lead) creation_text = "converted the lead to this deal" - activities.append({ - "activity_type": "creation", - "creation": doc[0], - "owner": doc[1], - "data": creation_text, - "is_lead": False, - }) + activities.append( + { + "activity_type": "creation", + "creation": doc[0], + "owner": doc[1], + "data": creation_text, + "is_lead": False, + } + ) docinfo.versions.reverse() @@ -107,7 +115,7 @@ def get_deal_activities(name): "creation": comment.creation, "owner": comment.owner, "content": comment.content, - "attachments": get_attachments('Comment', comment.name), + "attachments": get_attachments("Comment", comment.name), "is_lead": False, } activities.append(activity) @@ -125,7 +133,7 @@ def get_deal_activities(name): "recipients": communication.recipients, "cc": communication.cc, "bcc": communication.bcc, - "attachments": get_attachments('Communication', communication.name), + "attachments": get_attachments("Communication", communication.name), "read_by_recipient": communication.read_by_recipient, "delivery_status": communication.delivery_status, }, @@ -144,21 +152,24 @@ def get_deal_activities(name): } activities.append(activity) - calls = calls + get_linked_calls(name) - notes = notes + get_linked_notes(name) - tasks = tasks + get_linked_tasks(name) - attachments = attachments + get_attachments('CRM Deal', name) + calls = calls + get_linked_calls(name).get("calls", []) + notes = notes + get_linked_notes(name) + get_linked_calls(name).get("notes", []) + tasks = tasks + get_linked_tasks(name) + get_linked_calls(name).get("tasks", []) + attachments = attachments + get_attachments("CRM Deal", name) activities.sort(key=lambda x: x["creation"], reverse=True) activities = handle_multiple_versions(activities) return activities, calls, notes, tasks, attachments + def get_lead_activities(name): - get_docinfo('', "CRM Lead", name) + get_docinfo("", "CRM Lead", name) docinfo = frappe.response["docinfo"] lead_meta = frappe.get_meta("CRM Lead") - lead_fields = {field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields} + lead_fields = { + field.fieldname: {"label": field.label, "options": field.options} for field in lead_meta.fields + } avoid_fields = [ "converted", "response_by", @@ -169,13 +180,15 @@ def get_lead_activities(name): ] doc = frappe.db.get_values("CRM Lead", name, ["creation", "owner"])[0] - activities = [{ - "activity_type": "creation", - "creation": doc[0], - "owner": doc[1], - "data": "created this lead", - "is_lead": True, - }] + activities = [ + { + "activity_type": "creation", + "creation": doc[0], + "owner": doc[1], + "data": "created this lead", + "is_lead": True, + } + ] docinfo.versions.reverse() @@ -233,7 +246,7 @@ def get_lead_activities(name): "creation": comment.creation, "owner": comment.owner, "content": comment.content, - "attachments": get_attachments('Comment', comment.name), + "attachments": get_attachments("Comment", comment.name), "is_lead": True, } activities.append(activity) @@ -251,7 +264,7 @@ def get_lead_activities(name): "recipients": communication.recipients, "cc": communication.cc, "bcc": communication.bcc, - "attachments": get_attachments('Communication', communication.name), + "attachments": get_attachments("Communication", communication.name), "read_by_recipient": communication.read_by_recipient, "delivery_status": communication.delivery_status, }, @@ -270,10 +283,10 @@ def get_lead_activities(name): } activities.append(activity) - calls = get_linked_calls(name) - notes = get_linked_notes(name) - tasks = get_linked_tasks(name) - attachments = get_attachments('CRM Lead', name) + calls = get_linked_calls(name).get("calls", []) + notes = get_linked_notes(name) + get_linked_calls(name).get("notes", []) + tasks = get_linked_tasks(name) + get_linked_calls(name).get("tasks", []) + attachments = get_attachments("CRM Lead", name) activities.sort(key=lambda x: x["creation"], reverse=True) activities = handle_multiple_versions(activities) @@ -282,11 +295,25 @@ def get_lead_activities(name): def get_attachments(doctype, name): - return frappe.db.get_all( - "File", - filters={"attached_to_doctype": doctype, "attached_to_name": name}, - fields=["name", "file_name", "file_type", "file_url", "file_size", "is_private", "creation", "owner"], - ) or [] + return ( + frappe.db.get_all( + "File", + filters={"attached_to_doctype": doctype, "attached_to_name": name}, + fields=[ + "name", + "file_name", + "file_type", + "file_url", + "file_size", + "is_private", + "modified", + "creation", + "owner", + ], + ) + or [] + ) + def handle_multiple_versions(versions): activities = [] @@ -298,7 +325,8 @@ def handle_multiple_versions(versions): activities.append(version) if not old_version: old_version = version - if is_version: grouped_versions.append(version) + if is_version: + grouped_versions.append(version) continue if is_version and old_version.get("owner") and version["owner"] == old_version["owner"]: grouped_versions.append(version) @@ -306,13 +334,15 @@ def handle_multiple_versions(versions): if grouped_versions: activities.append(parse_grouped_versions(grouped_versions)) grouped_versions = [] - if is_version: grouped_versions.append(version) + if is_version: + grouped_versions.append(version) old_version = version if version == versions[-1] and grouped_versions: activities.append(parse_grouped_versions(grouped_versions)) return activities + def parse_grouped_versions(versions): version = versions[0] if len(versions) == 1: @@ -321,6 +351,7 @@ def parse_grouped_versions(versions): version["other_versions"] = other_versions return version + def get_linked_calls(name): calls = frappe.db.get_all( "CRM Call Log", @@ -341,16 +372,89 @@ def get_linked_calls(name): "note", ], ) - return calls or [] + + linked_calls = frappe.db.get_all( + "Dynamic Link", filters={"link_name": name, "parenttype": "CRM Call Log"}, pluck="parent" + ) + + notes = [] + tasks = [] + + if linked_calls: + CallLog = frappe.qb.DocType("CRM Call Log") + Link = frappe.qb.DocType("Dynamic Link") + query = ( + frappe.qb.from_(CallLog) + .select( + CallLog.name, + CallLog.caller, + CallLog.receiver, + CallLog["from"], + CallLog.to, + CallLog.duration, + CallLog.start_time, + CallLog.end_time, + CallLog.status, + CallLog.type, + CallLog.recording_url, + CallLog.creation, + CallLog.note, + Link.link_doctype, + Link.link_name, + ) + .join(Link, JoinType.inner) + .on(Link.parent == CallLog.name) + .where(CallLog.name.isin(linked_calls)) + ) + _calls = query.run(as_dict=True) + + for call in _calls: + if call.get("link_doctype") == "FCRM Note": + notes.append(call.link_name) + elif call.get("link_doctype") == "CRM Task": + tasks.append(call.link_name) + + _calls = [call for call in _calls if call.get("link_doctype") not in ["FCRM Note", "CRM Task"]] + if _calls: + calls = calls + _calls + + if notes: + notes = frappe.db.get_all( + "FCRM Note", + filters={"name": ("in", notes)}, + fields=["name", "title", "content", "owner", "modified"], + ) + + if tasks: + tasks = frappe.db.get_all( + "CRM Task", + filters={"name": ("in", tasks)}, + fields=[ + "name", + "title", + "description", + "assigned_to", + "due_date", + "priority", + "status", + "modified", + ], + ) + + calls = [parse_call_log(call) for call in calls] if calls else [] + + return {"calls": calls, "notes": notes, "tasks": tasks} + def get_linked_notes(name): notes = frappe.db.get_all( "FCRM Note", filters={"reference_docname": name}, - fields=['name', 'title', 'content', 'owner', 'modified'], + fields=["name", "title", "content", "owner", "modified"], ) return notes or [] + def get_linked_tasks(name): tasks = frappe.db.get_all( "CRM Task", @@ -360,7 +464,6 @@ def get_linked_tasks(name): "title", "description", "assigned_to", - "assigned_to", "due_date", "priority", "status", @@ -369,6 +472,7 @@ def get_linked_tasks(name): ) return tasks or [] + def parse_attachment_log(html, type): soup = BeautifulSoup(html, "html.parser") a_tag = soup.find("a") @@ -390,4 +494,4 @@ def parse_attachment_log(html, type): "file_name": a_tag.text, "file_url": a_tag["href"], "is_private": is_private, - } \ No newline at end of file + } diff --git a/crm/api/doc.py b/crm/api/doc.py index 4001fa7e..282ebe8d 100644 --- a/crm/api/doc.py +++ b/crm/api/doc.py @@ -185,12 +185,13 @@ def get_quick_filters(doctype: str): if field.fieldtype == "Select" and options and isinstance(options, str): options = options.split("\n") options = [{"label": option, "value": option} for option in options] - options.insert(0, {"label": "", "value": ""}) + if not any([not option.get("value") for option in options]): + options.insert(0, {"label": "", "value": ""}) quick_filters.append( { "label": _(field.label), - "name": field.fieldname, - "type": field.fieldtype, + "fieldname": field.fieldname, + "fieldtype": field.fieldtype, "options": options, } ) @@ -306,6 +307,7 @@ def get_data( ) or [] ) + data = parse_list_data(data, doctype) if view_type == "kanban": if not rows: @@ -479,6 +481,13 @@ def get_data( } +def parse_list_data(data, doctype): + _list = get_controller(doctype) + if hasattr(_list, "parse_list_data"): + data = _list.parse_list_data(data) + return data + + def convert_filter_to_tuple(doctype, filters): if isinstance(filters, dict): filters_items = filters.items() diff --git a/crm/api/session.py b/crm/api/session.py index 2e2b9375..746de854 100644 --- a/crm/api/session.py +++ b/crm/api/session.py @@ -5,7 +5,16 @@ import frappe def get_users(): users = frappe.qb.get_query( "User", - fields=["name", "email", "enabled", "user_image", "first_name", "last_name", "full_name", "user_type"], + fields=[ + "name", + "email", + "enabled", + "user_image", + "first_name", + "last_name", + "full_name", + "user_type", + ], order_by="full_name asc", distinct=True, ).run(as_dict=1) @@ -14,11 +23,13 @@ def get_users(): if frappe.session.user == user.name: user.session_user = True - user.is_manager = ( - "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator" - ) + user.is_manager = "Sales Manager" in frappe.get_roles(user.name) or user.name == "Administrator" + + user.is_agent = frappe.db.exists("CRM Telephony Agent", {"user": user.name}) + return users + @frappe.whitelist() def get_contacts(): contacts = frappe.get_all( @@ -37,7 +48,7 @@ def get_contacts(): "mobile_no", "phone", "company_name", - "modified" + "modified", ], order_by="first_name asc", distinct=True, @@ -58,18 +69,12 @@ def get_contacts(): return contacts + @frappe.whitelist() def get_lead_contacts(): lead_contacts = frappe.get_all( "CRM Lead", - fields=[ - "name", - "lead_name", - "mobile_no", - "phone", - "image", - "modified" - ], + fields=["name", "lead_name", "mobile_no", "phone", "image", "modified"], filters={"converted": 0}, order_by="lead_name asc", distinct=True, @@ -77,11 +82,12 @@ def get_lead_contacts(): return lead_contacts + @frappe.whitelist() def get_organizations(): organizations = frappe.qb.get_query( "CRM Organization", - fields=['*'], + fields=["*"], order_by="name asc", distinct=True, ).run(as_dict=1) 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..96416bf9 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,33 @@ "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", + "in_list_view": 1, + "in_standard_filter": 1, + "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-17 21:46:01.558377", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Call Log", diff --git a/crm/fcrm/doctype/crm_call_log/crm_call_log.py b/crm/fcrm/doctype/crm_call_log/crm_call_log.py index 42752c6a..6560aa8e 100644 --- a/crm/fcrm/doctype/crm_call_log/crm_call_log.py +++ b/crm/fcrm/doctype/crm_call_log/crm_call_log.py @@ -4,78 +4,190 @@ import frappe from frappe.model.document import Document +from crm.integrations.api import get_contact_by_phone_number +from crm.utils import seconds_to_duration + class CRMCallLog(Document): - @staticmethod - def default_list_data(): - columns = [ - { - 'label': 'From', - 'type': 'Link', - 'key': 'caller', - 'options': 'User', - 'width': '9rem', - }, - { - 'label': 'To', - 'type': 'Link', - 'key': 'receiver', - 'options': 'User', - 'width': '9rem', - }, - { - 'label': 'Type', - 'type': 'Select', - 'key': 'type', - 'width': '9rem', - }, - { - 'label': 'Status', - 'type': 'Select', - 'key': 'status', - 'width': '9rem', - }, - { - 'label': 'Duration', - 'type': 'Duration', - 'key': 'duration', - 'width': '6rem', - }, - { - 'label': 'From (number)', - 'type': 'Data', - 'key': 'from', - 'width': '9rem', - }, - { - 'label': 'To (number)', - 'type': 'Data', - 'key': 'to', - 'width': '9rem', - }, - { - 'label': 'Created On', - 'type': 'Datetime', - 'key': 'creation', - 'width': '8rem', - }, - ] - rows = [ - "name", - "caller", - "receiver", - "type", - "status", - "duration", - "from", - "to", - "note", - "recording_url", - "reference_doctype", - "reference_docname", - "creation", - ] - return {'columns': columns, 'rows': rows} + @staticmethod + def default_list_data(): + columns = [ + { + "label": "From", + "type": "Link", + "key": "caller", + "options": "User", + "width": "9rem", + }, + { + "label": "To", + "type": "Link", + "key": "receiver", + "options": "User", + "width": "9rem", + }, + { + "label": "Type", + "type": "Select", + "key": "type", + "width": "9rem", + }, + { + "label": "Status", + "type": "Select", + "key": "status", + "width": "9rem", + }, + { + "label": "Duration", + "type": "Duration", + "key": "duration", + "width": "6rem", + }, + { + "label": "From (number)", + "type": "Data", + "key": "from", + "width": "9rem", + }, + { + "label": "To (number)", + "type": "Data", + "key": "to", + "width": "9rem", + }, + { + "label": "Created On", + "type": "Datetime", + "key": "creation", + "width": "8rem", + }, + ] + rows = [ + "name", + "caller", + "receiver", + "type", + "status", + "duration", + "from", + "to", + "note", + "recording_url", + "reference_doctype", + "reference_docname", + "creation", + ] + return {"columns": columns, "rows": rows} + + def parse_list_data(calls): + return [parse_call_log(call) for call in calls] if calls else [] + + def has_link(self, doctype, name): + for link in self.links: + if link.link_doctype == doctype and link.link_name == name: + return True + + def link_with_reference_doc(self, reference_doctype, reference_name): + if self.has_link(reference_doctype, reference_name): + return + + self.append("links", {"link_doctype": reference_doctype, "link_name": reference_name}) + + +def parse_call_log(call): + call["show_recording"] = False + call["duration"] = seconds_to_duration(call.get("duration")) + if call.get("type") == "Incoming": + call["activity_type"] = "incoming_call" + contact = get_contact_by_phone_number(call.get("from")) + receiver = ( + frappe.db.get_values("User", call.get("receiver"), ["full_name", "user_image"])[0] + if call.get("receiver") + else [None, None] + ) + call["caller"] = { + "label": contact.get("full_name", "Unknown"), + "image": contact.get("image"), + } + call["receiver"] = { + "label": receiver[0], + "image": receiver[1], + } + elif call.get("type") == "Outgoing": + call["activity_type"] = "outgoing_call" + contact = get_contact_by_phone_number(call.get("to")) + caller = ( + frappe.db.get_values("User", call.get("caller"), ["full_name", "user_image"])[0] + if call.get("caller") + else [None, None] + ) + call["caller"] = { + "label": caller[0], + "image": caller[1], + } + call["receiver"] = { + "label": contact.get("full_name", "Unknown"), + "image": contact.get("image"), + } + + return call + + +@frappe.whitelist() +def get_call_log(name): + call = frappe.get_cached_doc( + "CRM Call Log", + name, + fields=[ + "name", + "caller", + "receiver", + "duration", + "type", + "status", + "from", + "to", + "note", + "recording_url", + "reference_doctype", + "reference_docname", + "creation", + ], + ).as_dict() + + call = parse_call_log(call) + + notes = [] + tasks = [] + + if call.get("note"): + note = frappe.get_cached_doc("FCRM Note", call.get("note")).as_dict() + notes.append(note) + + if call.get("reference_doctype") and call.get("reference_docname"): + if call.get("reference_doctype") == "CRM Lead": + call["_lead"] = call.get("reference_docname") + elif call.get("reference_doctype") == "CRM Deal": + call["_deal"] = call.get("reference_docname") + + if call.get("links"): + for link in call.get("links"): + if link.get("link_doctype") == "CRM Task": + task = frappe.get_cached_doc("CRM Task", link.get("link_name")).as_dict() + tasks.append(task) + elif link.get("link_doctype") == "FCRM Note": + note = frappe.get_cached_doc("FCRM Note", link.get("link_name")).as_dict() + notes.append(note) + elif link.get("link_doctype") == "CRM Lead": + call["_lead"] = link.get("link_name") + elif link.get("link_doctype") == "CRM Deal": + call["_deal"] = link.get("link_name") + + call["_tasks"] = tasks + call["_notes"] = notes + return call + @frappe.whitelist() def create_lead_from_call_log(call_log): @@ -85,15 +197,9 @@ def create_lead_from_call_log(call_log): lead.lead_owner = frappe.session.user lead.save(ignore_permissions=True) - frappe.db.set_value("CRM Call Log", call_log.get("name"), { - "reference_doctype": "CRM Lead", - "reference_docname": lead.name - }) + # link call log with lead + call_log = frappe.get_doc("CRM Call Log", call_log.get("name")) + call_log.link_with_reference_doc("CRM Lead", lead.name) + call_log.save(ignore_permissions=True) - if call_log.get("note"): - frappe.db.set_value("FCRM Note", call_log.get("note"), { - "reference_doctype": "CRM Lead", - "reference_docname": lead.name - }) - - return lead.name \ No newline at end of file + return lead.name diff --git a/crm/fcrm/doctype/twilio_agents/__init__.py b/crm/fcrm/doctype/crm_exotel_settings/__init__.py similarity index 100% rename from crm/fcrm/doctype/twilio_agents/__init__.py rename to crm/fcrm/doctype/crm_exotel_settings/__init__.py diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js new file mode 100644 index 00000000..b7b3339d --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Exotel Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json new file mode 100644 index 00000000..82a9a45f --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.json @@ -0,0 +1,136 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-08 15:55:50.710356", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "column_break_uxtz", + "record_call", + "section_break_kfez", + "account_sid", + "subdomain", + "column_break_qwfn", + "webhook_verify_token", + "section_break_iuct", + "api_key", + "column_break_hyen", + "api_token" + ], + "fields": [ + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled" + }, + { + "fieldname": "section_break_kfez", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "enabled", + "fieldname": "account_sid", + "fieldtype": "Data", + "label": "Account SID", + "mandatory_depends_on": "enabled" + }, + { + "depends_on": "enabled", + "fieldname": "section_break_iuct", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_hyen", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "api_key", + "fieldtype": "Data", + "in_list_view": 1, + "label": "API Key", + "mandatory_depends_on": "enabled" + }, + { + "fieldname": "column_break_uxtz", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "api_token", + "fieldtype": "Password", + "in_list_view": 1, + "label": "API Token", + "mandatory_depends_on": "enabled" + }, + { + "default": "0", + "depends_on": "enabled", + "fieldname": "record_call", + "fieldtype": "Check", + "label": "Record Call" + }, + { + "fieldname": "column_break_qwfn", + "fieldtype": "Column Break" + }, + { + "depends_on": "enabled", + "fieldname": "webhook_verify_token", + "fieldtype": "Data", + "label": "Webhook Verify Token", + "mandatory_depends_on": "enabled" + }, + { + "depends_on": "enabled", + "fieldname": "subdomain", + "fieldtype": "Data", + "label": "Subdomain", + "mandatory_depends_on": "enabled" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2025-01-20 15:00:51.096985", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Exotel Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "email": 1, + "print": 1, + "read": 1, + "role": "All", + "share": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py new file mode 100644 index 00000000..c21c777f --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/crm_exotel_settings.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import requests +from frappe import _ +from frappe.model.document import Document + + +class CRMExotelSettings(Document): + def validate(self): + self.verify_credentials() + + def verify_credentials(self): + if self.enabled: + response = requests.get( + "https://{subdomain}/v1/Accounts/{sid}".format( + subdomain=self.subdomain, sid=self.account_sid + ), + auth=(self.api_key, self.get_password("api_token")), + ) + if response.status_code != 200: + frappe.throw( + _(f"Please enter valid exotel Account SID, API key & API token: {response.reason}"), + title=_("Invalid credentials"), + ) diff --git a/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py new file mode 100644 index 00000000..476525f4 --- /dev/null +++ b/crm/fcrm/doctype/crm_exotel_settings/test_crm_exotel_settings.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMExotelSettings(UnitTestCase): + """ + Unit tests for CRMExotelSettings. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMExotelSettings(IntegrationTestCase): + """ + Integration tests for CRMExotelSettings. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/twilio_settings/__init__.py b/crm/fcrm/doctype/crm_telephony_agent/__init__.py similarity index 100% rename from crm/fcrm/doctype/twilio_settings/__init__.py rename to crm/fcrm/doctype/crm_telephony_agent/__init__.py diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js new file mode 100644 index 00000000..b1bea173 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("CRM Telephony Agent", { +// refresh(frm) { + +// }, +// }); diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json new file mode 100644 index 00000000..042632f6 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.json @@ -0,0 +1,131 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:user", + "creation": "2025-01-11 16:12:46.602782", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "user", + "user_name", + "column_break_hdec", + "mobile_no", + "default_medium", + "section_break_ozjn", + "twilio", + "twilio_number", + "column_break_aydj", + "exotel", + "exotel_number", + "section_break_phlq", + "phone_nos" + ], + "fields": [ + { + "fieldname": "user", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "User", + "options": "User", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "column_break_hdec", + "fieldtype": "Column Break" + }, + { + "fieldname": "mobile_no", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Mobile No.", + "read_only": 1 + }, + { + "fetch_from": "user.full_name", + "fieldname": "user_name", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "User Name" + }, + { + "depends_on": "exotel", + "fieldname": "exotel_number", + "fieldtype": "Data", + "label": "Exotel Number", + "mandatory_depends_on": "exotel" + }, + { + "fieldname": "section_break_phlq", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_ozjn", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_aydj", + "fieldtype": "Column Break" + }, + { + "depends_on": "twilio", + "fieldname": "twilio_number", + "fieldtype": "Data", + "label": "Twilio Number", + "mandatory_depends_on": "twilio" + }, + { + "fieldname": "phone_nos", + "fieldtype": "Table", + "label": "Phone Numbers", + "options": "CRM Telephony Phone" + }, + { + "default": "0", + "fieldname": "twilio", + "fieldtype": "Check", + "label": "Twilio" + }, + { + "default": "0", + "fieldname": "exotel", + "fieldtype": "Check", + "label": "Exotel" + }, + { + "fieldname": "default_medium", + "fieldtype": "Select", + "label": "Default Medium", + "options": "\nTwilio\nExotel" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-01-19 14:17:12.880185", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Telephony Agent", + "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": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "user_name" +} \ No newline at end of file diff --git a/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py new file mode 100644 index 00000000..ff1f85e9 --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/crm_telephony_agent.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class CRMTelephonyAgent(Document): + def validate(self): + self.set_primary() + + def set_primary(self): + # Used to set primary mobile no. + if len(self.phone_nos) == 0: + self.mobile_no = "" + return + + is_primary = [phone.number for phone in self.phone_nos if phone.get("is_primary")] + + if len(is_primary) > 1: + frappe.throw( + _("Only one {0} can be set as primary.").format(frappe.bold(frappe.unscrub("mobile_no"))) + ) + + primary_number_exists = False + for d in self.phone_nos: + if d.get("is_primary") == 1: + primary_number_exists = True + self.mobile_no = d.number + break + + if not primary_number_exists: + self.mobile_no = "" diff --git a/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py new file mode 100644 index 00000000..63e0f2fb --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_agent/test_crm_telephony_agent.py @@ -0,0 +1,29 @@ +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class UnitTestCRMTelephonyAgent(UnitTestCase): + """ + Unit tests for CRMTelephonyAgent. + Use this class for testing individual functions and methods. + """ + + pass + + +class IntegrationTestCRMTelephonyAgent(IntegrationTestCase): + """ + Integration tests for CRMTelephonyAgent. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/crm/fcrm/doctype/crm_telephony_phone/__init__.py b/crm/fcrm/doctype/crm_telephony_phone/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json new file mode 100644 index 00000000..6450a96a --- /dev/null +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-01-19 13:57:01.702519", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "number", + "is_primary" + ], + "fields": [ + { + "fieldname": "number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Number", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "is_primary", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Is Primary" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-01-19 13:58:59.063775", + "modified_by": "Administrator", + "module": "FCRM", + "name": "CRM Telephony Phone", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.py b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py similarity index 55% rename from crm/fcrm/doctype/twilio_agents/twilio_agents.py rename to crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py index fb660dd8..5522b84d 100644 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.py +++ b/crm/fcrm/doctype/crm_telephony_phone/crm_telephony_phone.py @@ -1,9 +1,9 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt # import frappe from frappe.model.document import Document -class TwilioAgents(Document): +class CRMTelephonyPhone(Document): pass diff --git a/crm/fcrm/doctype/crm_twilio_settings/__init__.py b/crm/fcrm/doctype/crm_twilio_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.js b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js similarity index 77% rename from crm/fcrm/doctype/twilio_agents/twilio_agents.js rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js index 8d8bf294..1bfa5e44 100644 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.js +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.js @@ -1,7 +1,7 @@ // Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors // For license information, please see license.txt -// frappe.ui.form.on("Twilio Agents", { +// frappe.ui.form.on("CRM Twilio Settings", { // refresh(frm) { // }, diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.json b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json similarity index 88% rename from crm/fcrm/doctype/twilio_settings/twilio_settings.json rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json index cf48bb6a..d898b631 100644 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.json +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.json @@ -23,6 +23,7 @@ ], "fields": [ { + "depends_on": "enabled", "fieldname": "account_sid", "fieldtype": "Data", "in_list_view": 1, @@ -30,6 +31,7 @@ "mandatory_depends_on": "eval: doc.enabled" }, { + "depends_on": "enabled", "fieldname": "api_key", "fieldtype": "Data", "label": "API Key", @@ -37,6 +39,7 @@ "read_only": 1 }, { + "depends_on": "enabled", "fieldname": "api_secret", "fieldtype": "Password", "label": "API Secret", @@ -48,6 +51,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "enabled", "fieldname": "auth_token", "fieldtype": "Password", "in_list_view": 1, @@ -55,6 +59,7 @@ "mandatory_depends_on": "eval: doc.enabled" }, { + "depends_on": "enabled", "fieldname": "twiml_sid", "fieldtype": "Data", "label": "TwiML SID", @@ -67,6 +72,7 @@ }, { "default": "0", + "depends_on": "enabled", "fieldname": "record_calls", "fieldtype": "Check", "label": "Record Calls" @@ -77,7 +83,8 @@ }, { "fieldname": "section_break_malx", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "default": "0", @@ -87,7 +94,8 @@ }, { "fieldname": "section_break_eklq", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "column_break_yqvr", @@ -97,10 +105,10 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-12-30 19:23:04.401439", + "modified": "2025-01-15 19:35:13.406254", "modified_by": "Administrator", "module": "FCRM", - "name": "Twilio Settings", + "name": "CRM Twilio Settings", "owner": "Administrator", "permissions": [ { diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.py b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py similarity index 76% rename from crm/fcrm/doctype/twilio_settings/twilio_settings.py rename to crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py index 1d3a20b6..f2737c6c 100644 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.py +++ b/crm/fcrm/doctype/crm_twilio_settings/crm_twilio_settings.py @@ -2,13 +2,13 @@ # For license information, please see license.txt import frappe -from frappe.model.document import Document from frappe import _ - +from frappe.model.document import Document from twilio.rest import Client -class TwilioSettings(Document): - friendly_resource_name = "Frappe CRM" # System creates TwiML app & API keys with this name. + +class CRMTwilioSettings(Document): + friendly_resource_name = "Frappe CRM" # System creates TwiML app & API keys with this name. def validate(self): self.validate_twilio_account() @@ -33,28 +33,26 @@ class TwilioSettings(Document): frappe.throw(_("Invalid Account SID or Auth Token.")) def set_api_credentials(self, twilio): - """Generate Twilio API credentials if not exist and update them. - """ + """Generate Twilio API credentials if not exist and update them.""" if self.api_key and self.api_secret: return new_key = self.create_api_key(twilio) self.api_key = new_key.sid self.api_secret = new_key.secret - frappe.db.set_value('Twilio Settings', 'Twilio Settings', { - 'api_key': self.api_key, - 'api_secret': self.api_secret - }) + frappe.db.set_value( + "CRM Twilio Settings", + "CRM Twilio Settings", + {"api_key": self.api_key, "api_secret": self.api_secret}, + ) def set_application_credentials(self, twilio): - """Generate TwiML app credentials if not exist and update them. - """ + """Generate TwiML app credentials if not exist and update them.""" credentials = self.get_application(twilio) or self.create_application(twilio) self.twiml_sid = credentials.sid - frappe.db.set_value('Twilio Settings', 'Twilio Settings', 'twiml_sid', self.twiml_sid) + frappe.db.set_value("CRM Twilio Settings", "CRM Twilio Settings", "twiml_sid", self.twiml_sid) def create_api_key(self, twilio): - """Create API keys in twilio account. - """ + """Create API keys in twilio account.""" try: return twilio.new_keys.create(friendly_name=self.friendly_resource_name) except Exception: @@ -66,23 +64,21 @@ class TwilioSettings(Document): return get_public_url(url_path) def get_application(self, twilio, friendly_name=None): - """Get TwiML App from twilio account if exists. - """ + """Get TwiML App from twilio account if exists.""" friendly_name = friendly_name or self.friendly_resource_name applications = twilio.applications.list(friendly_name) return applications and applications[0] def create_application(self, twilio, friendly_name=None): - """Create TwilML App in twilio account. - """ + """Create TwilML App in twilio account.""" friendly_name = friendly_name or self.friendly_resource_name application = twilio.applications.create( - voice_method='POST', - voice_url=self.get_twilio_voice_url(), - friendly_name=friendly_name - ) + voice_method="POST", voice_url=self.get_twilio_voice_url(), friendly_name=friendly_name + ) return application -def get_public_url(path: str=None): + +def get_public_url(path: str | None = None): from frappe.utils import get_url - return get_url().split(":8", 1)[0] + path \ No newline at end of file + + return get_url().split(":8", 1)[0] + path diff --git a/crm/fcrm/doctype/twilio_agents/test_twilio_agents.py b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py similarity index 77% rename from crm/fcrm/doctype/twilio_agents/test_twilio_agents.py rename to crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py index 29ecc305..531076af 100644 --- a/crm/fcrm/doctype/twilio_agents/test_twilio_agents.py +++ b/crm/fcrm/doctype/crm_twilio_settings/test_crm_twilio_settings.py @@ -5,5 +5,5 @@ from frappe.tests import UnitTestCase -class TestTwilioAgents(UnitTestCase): +class TestCRMTwilioSettings(UnitTestCase): pass diff --git a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json index 1cb48bf7..f445541d 100644 --- a/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json +++ b/crm/fcrm/doctype/fcrm_settings/fcrm_settings.json @@ -61,7 +61,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-12-30 19:21:30.847343", + "modified": "2025-01-19 14:23:05.981355", "modified_by": "Administrator", "module": "FCRM", "name": "FCRM Settings", diff --git a/crm/fcrm/doctype/twilio_agents/twilio_agents.json b/crm/fcrm/doctype/twilio_agents/twilio_agents.json deleted file mode 100644 index 65251130..00000000 --- a/crm/fcrm/doctype/twilio_agents/twilio_agents.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "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": "2024-01-19 21:57:18.626669", - "modified_by": "Administrator", - "module": "FCRM", - "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": "Sales 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/fcrm/doctype/twilio_settings/test_twilio_settings.py b/crm/fcrm/doctype/twilio_settings/test_twilio_settings.py deleted file mode 100644 index 21b03841..00000000 --- a/crm/fcrm/doctype/twilio_settings/test_twilio_settings.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors -# See license.txt - -# import frappe -from frappe.tests import UnitTestCase - - -class TestTwilioSettings(UnitTestCase): - pass diff --git a/crm/fcrm/doctype/twilio_settings/twilio_settings.js b/crm/fcrm/doctype/twilio_settings/twilio_settings.js deleted file mode 100644 index 9837c18d..00000000 --- a/crm/fcrm/doctype/twilio_settings/twilio_settings.js +++ /dev/null @@ -1,8 +0,0 @@ -// 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/integrations/__init__.py b/crm/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/crm/integrations/api.py b/crm/integrations/api.py new file mode 100644 index 00000000..b440e629 --- /dev/null +++ b/crm/integrations/api.py @@ -0,0 +1,167 @@ +import frappe +from frappe.query_builder import Order +from pypika.functions import Replace + +from crm.utils import are_same_phone_number, parse_phone_number + + +@frappe.whitelist() +def is_call_integration_enabled(): + twilio_enabled = frappe.db.get_single_value("CRM Twilio Settings", "enabled") + exotel_enabled = frappe.db.get_single_value("CRM Exotel Settings", "enabled") + + return { + "twilio_enabled": twilio_enabled, + "exotel_enabled": exotel_enabled, + "default_calling_medium": get_user_default_calling_medium(), + } + + +def get_user_default_calling_medium(): + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + return None + + default_medium = frappe.db.get_value("CRM Telephony Agent", frappe.session.user, "default_medium") + + if not default_medium: + return None + + return default_medium + + +@frappe.whitelist() +def set_default_calling_medium(medium): + if not frappe.db.exists("CRM Telephony Agent", frappe.session.user): + frappe.get_doc( + { + "doctype": "CRM Telephony Agent", + "agent": frappe.session.user, + "default_medium": medium, + } + ).insert(ignore_permissions=True) + else: + frappe.db.set_value("CRM Telephony Agent", frappe.session.user, "default_medium", medium) + + return get_user_default_calling_medium() + + +@frappe.whitelist() +def add_note_to_call_log(call_sid, note): + """Add/Update note to call log based on call sid.""" + _note = None + if not note.get("name"): + _note = frappe.get_doc( + { + "doctype": "FCRM Note", + "content": note.get("content"), + } + ).insert(ignore_permissions=True) + call_log = frappe.get_cached_doc("CRM Call Log", call_sid) + call_log.link_with_reference_doc("FCRM Note", _note.name) + call_log.save(ignore_permissions=True) + else: + _note = frappe.set_value("FCRM Note", note.get("name"), "content", note.get("content")) + + return _note + + +@frappe.whitelist() +def add_task_to_call_log(call_sid, task): + """Add/Update task to call log based on call sid.""" + _task = None + if not task.get("name"): + _task = frappe.get_doc( + { + "doctype": "CRM Task", + "title": task.get("title"), + "description": task.get("description"), + } + ).insert(ignore_permissions=True) + call_log = frappe.get_doc("CRM Call Log", call_sid) + call_log.link_with_reference_doc("CRM Task", _task.name) + call_log.save(ignore_permissions=True) + else: + _task = frappe.get_doc("CRM Task", task.get("name")) + _task.update( + { + "title": task.get("title"), + "description": task.get("description"), + } + ) + _task.save(ignore_permissions=True) + + return _task + + +@frappe.whitelist() +def get_contact_by_phone_number(phone_number): + """Get contact by phone number.""" + number = parse_phone_number(phone_number) + + if number.get("is_valid"): + return get_contact(number.get("national_number")) + else: + return get_contact(phone_number, exact_match=True) + + +def get_contact(phone_number, exact_match=False): + cleaned_number = ( + phone_number.strip() + .replace(" ", "") + .replace("-", "") + .replace("(", "") + .replace(")", "") + .replace("+", "") + ) + + # Check if the number is associated with a contact + Contact = frappe.qb.DocType("Contact") + normalized_phone = Replace( + Replace(Replace(Replace(Replace(Contact.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", "" + ) + + query = ( + frappe.qb.from_(Contact) + .select(Contact.name, Contact.full_name, Contact.image, Contact.mobile_no) + .where(normalized_phone.like(f"%{cleaned_number}%")) + .orderby("modified", order=Order.desc) + ) + contacts = query.run(as_dict=True) + + if len(contacts): + # Check if the contact is associated with a deal + for contact in contacts: + if frappe.db.exists("CRM Contacts", {"contact": contact.name, "is_primary": 1}): + deal = frappe.db.get_value( + "CRM Contacts", {"contact": contact.name, "is_primary": 1}, "parent" + ) + if are_same_phone_number(contact.mobile_no, phone_number, validate=not exact_match): + contact["deal"] = deal + return contact + # Else, return the first contact + if are_same_phone_number(contacts[0].mobile_no, phone_number, validate=not exact_match): + return contacts[0] + + # Else, Check if the number is associated with a lead + Lead = frappe.qb.DocType("CRM Lead") + normalized_phone = Replace( + Replace(Replace(Replace(Replace(Lead.mobile_no, " ", ""), "-", ""), "(", ""), ")", ""), "+", "" + ) + + query = ( + frappe.qb.from_(Lead) + .select(Lead.name, Lead.lead_name, Lead.image, Lead.mobile_no) + .where(Lead.converted == 0) + .where(normalized_phone.like(f"%{cleaned_number}%")) + .orderby("modified", order=Order.desc) + ) + leads = query.run(as_dict=True) + + if len(leads): + for lead in leads: + if are_same_phone_number(lead.mobile_no, phone_number, validate=not exact_match): + lead["lead"] = lead.name + lead["full_name"] = lead.lead_name + return lead + + return {"mobile_no": phone_number} diff --git a/crm/integrations/exotel/handler.py b/crm/integrations/exotel/handler.py new file mode 100644 index 00000000..add2267c --- /dev/null +++ b/crm/integrations/exotel/handler.py @@ -0,0 +1,285 @@ +import bleach +import frappe +import requests +from frappe import _ +from frappe.integrations.utils import create_request_log + +from crm.integrations.api import get_contact_by_phone_number + +# Endpoints for webhook + +# Incoming Call: +# /api/method/crm.integrations.exotel.handler.handle_request?key= + +# Exotel Reference: +# https://developer.exotel.com/api/ +# https://support.exotel.com/support/solutions/articles/48283-working-with-passthru-applet + + +# Incoming Call +@frappe.whitelist(allow_guest=True) +def handle_request(**kwargs): + validate_request() + if not is_integration_enabled(): + return + + request_log = create_request_log( + kwargs, + request_description="Exotel Call", + service_name="Exotel", + request_headers=frappe.request.headers, + is_remote_request=1, + ) + + try: + request_log.status = "Completed" + exotel_settings = get_exotel_settings() + if not exotel_settings.enabled: + return + + call_payload = kwargs + + frappe.publish_realtime("exotel_call", call_payload) + status = call_payload.get("Status") + if status == "free": + return + + if call_log := get_call_log(call_payload): + update_call_log(call_payload, call_log=call_log) + else: + create_call_log( + call_id=call_payload.get("CallSid"), + from_number=call_payload.get("CallFrom"), + to_number=call_payload.get("DialWhomNumber"), + medium=call_payload.get("To"), + status=get_call_log_status(call_payload), + agent=call_payload.get("AgentEmail"), + ) + except Exception: + request_log.status = "Failed" + request_log.error = frappe.get_traceback() + frappe.db.rollback() + frappe.log_error(title="Error while creating/updating call record") + frappe.db.commit() + finally: + request_log.save(ignore_permissions=True) + frappe.db.commit() + + +# Outgoing Call +@frappe.whitelist() +def make_a_call(to_number, from_number=None, caller_id=None): + if not is_integration_enabled(): + frappe.throw(_("Please setup Exotel intergration"), title=_("Integration Not Enabled")) + + endpoint = get_exotel_endpoint("Calls/connect.json?details=true") + + if not from_number: + from_number = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "mobile_no") + + if not caller_id: + caller_id = frappe.get_value("CRM Telephony Agent", {"user": frappe.session.user}, "exotel_number") + + if not caller_id: + frappe.throw( + _("You do not have Exotel Number set in your Telephony Agent"), title=_("Exotel Number Missing") + ) + + if caller_id and caller_id not in get_all_exophones(): + frappe.throw(_("Exotel Number {0} is not valid").format(caller_id), title=_("Invalid Exotel Number")) + + if not from_number: + frappe.throw( + _("You do not have mobile number set in your Telephony Agent"), title=_("Mobile Number Missing") + ) + + record_call = frappe.db.get_single_value("CRM Exotel Settings", "record_call") + + try: + response = requests.post( + endpoint, + data={ + "From": from_number, + "To": to_number, + "CallerId": caller_id, + "Record": "true" if record_call else "false", + "StatusCallback": get_status_updater_url(), + "StatusCallbackEvents[0]": "terminal", + "StatusCallbackEvents[1]": "answered", + }, + ) + response.raise_for_status() + except requests.exceptions.HTTPError: + if exc := response.json().get("RestException"): + frappe.throw(bleach.linkify(exc.get("Message")), title=_("Exotel Exception")) + else: + res = response.json() + call_payload = res.get("Call", {}) + + create_call_log( + call_id=call_payload.get("Sid"), + from_number=call_payload.get("From"), + to_number=call_payload.get("To"), + medium=call_payload.get("PhoneNumberSid"), + call_type="Outgoing", + agent=frappe.session.user, + ) + + call_details = response.json().get("Call", {}) + call_details["CallSid"] = call_details.get("Sid", "") + return call_details + + +def get_exotel_endpoint(action=None): + settings = get_exotel_settings() + return "https://{api_key}:{api_token}@{subdomain}/v1/Accounts/{sid}/{action}".format( + api_key=settings.api_key, + api_token=settings.get_password("api_token"), + subdomain=settings.subdomain, + sid=settings.account_sid, + action=action, + ) + + +def get_all_exophones(): + endpoint = get_exotel_endpoint("IncomingPhoneNumbers.json") + response = requests.get(endpoint) + return [ + phone.get("IncomingPhoneNumber", {}).get("PhoneNumber") + for phone in response.json().get("IncomingPhoneNumbers", []) + ] + + +def get_status_updater_url(): + from frappe.utils.data import get_url + + webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token") + return get_url(f"api/method/crm.integrations.exotel.handler.handle_request?key={webhook_verify_token}") + + +def get_exotel_settings(): + return frappe.get_single("CRM Exotel Settings") + + +def validate_request(): + # workaround security since exotel does not support request signature + # /api/method/?key= + webhook_verify_token = frappe.db.get_single_value("CRM Exotel Settings", "webhook_verify_token") + key = frappe.request.args.get("key") + is_valid = key and key == webhook_verify_token + + if not is_valid: + frappe.throw(_("Unauthorized request"), exc=frappe.PermissionError) + + +@frappe.whitelist() +def is_integration_enabled(): + return frappe.db.get_single_value("CRM Exotel Settings", "enabled", True) + + +# Call Log Functions +def create_call_log( + call_id, + from_number, + to_number, + medium, + agent, + status="Ringing", + call_type="Incoming", +): + call_log = frappe.new_doc("CRM Call Log") + call_log.id = call_id + call_log.to = to_number + call_log.medium = medium + call_log.type = call_type + call_log.status = status + call_log.telephony_medium = "Exotel" + setattr(call_log, "from", from_number) + + if call_type == "Incoming": + call_log.receiver = agent + else: + call_log.caller = agent + + # link call log with lead/deal + contact_number = from_number if call_type == "Incoming" else to_number + 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 get_call_log(call_payload): + call_log_id = call_payload.get("CallSid") + if frappe.db.exists("CRM Call Log", call_log_id): + return frappe.get_doc("CRM Call Log", call_log_id) + + +def get_call_log_status(call_payload, direction="inbound"): + if direction == "outbound-api" or direction == "outbound-dial": + status = call_payload.get("Status") + if status == "completed": + return "Completed" + elif status == "in-progress": + return "In Progress" + elif status == "busy": + return "Ringing" + elif status == "no-answer": + return "No Answer" + elif status == "failed": + return "Failed" + + status = call_payload.get("DialCallStatus") + call_type = call_payload.get("CallType") + dial_call_status = call_payload.get("DialCallStatus") + + if call_type == "incomplete" and dial_call_status == "no-answer": + status = "No Answer" + elif call_type == "client-hangup" and dial_call_status == "canceled": + status = "Canceled" + elif call_type == "incomplete" and dial_call_status == "failed": + status = "Failed" + elif call_type == "completed": + status = "Completed" + elif dial_call_status == "busy": + status = "Ringing" + + return status + + +def update_call_log(call_payload, status="Ringing", call_log=None): + direction = call_payload.get("Direction") + call_log = call_log or get_call_log(call_payload) + status = get_call_log_status(call_payload, direction) + try: + if call_log: + call_log.status = status + # resetting this because call might be redirected to other number + call_log.to = call_payload.get("DialWhomNumber") or call_payload.get("To") + call_log.duration = ( + call_payload.get("DialCallDuration") or call_payload.get("ConversationDuration") or 0 + ) + call_log.recording_url = call_payload.get("RecordingUrl") + call_log.start_time = call_payload.get("StartTime") + call_log.end_time = call_payload.get("EndTime") + 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() diff --git a/crm/integrations/twilio/api.py b/crm/integrations/twilio/api.py index 5ed4c28e..0df049ae 100644 --- a/crm/integrations/twilio/api.py +++ b/crm/integrations/twilio/api.py @@ -1,44 +1,46 @@ -from werkzeug.wrappers import Response import json import frappe from frappe import _ -from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails -from .utils import parse_mobile_no +from werkzeug.wrappers import Response + +from crm.integrations.api import get_contact_by_phone_number + +from .twilio_handler import IncomingCall, Twilio, TwilioCallDetails + @frappe.whitelist() def is_enabled(): - return frappe.db.get_single_value("Twilio Settings", "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. - """ + """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 +56,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,36 +66,57 @@ 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.reference_docname, call_log.reference_doctype = get_lead_or_deal_from_number(call_log) - call_log.flags.ignore_permissions = True - call_log.save() + 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. - """ + """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 + + 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() - 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) - if call_log.note and call_log.reference_docname: - frappe.db.set_value("FCRM Note", call_log.note, "reference_doctype", call_log.reference_doctype) - frappe.db.set_value("FCRM Note", call_log.note, "reference_docname", call_log.reference_docname) - call_log.flags.ignore_permissions = True - call_log.save() - frappe.db.commit() @frappe.whitelist(allow_guest=True) def update_recording_info(**kwargs): @@ -102,9 +126,10 @@ def update_recording_info(**kwargs): call_sid = args.CallSid update_call_log(call_sid) frappe.db.set_value("CRM Call Log", call_sid, "recording_url", recording_url) - except: + except Exception: frappe.log_error(title=_("Failed to capture Twilio recording")) + @frappe.whitelist(allow_guest=True) def update_call_status_info(**kwargs): try: @@ -113,70 +138,29 @@ 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) - ) - except: + client.calls(args.ParentCallSid).user_defined_messages.create(content=json.dumps(call_info)) + except Exception: 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') - -@frappe.whitelist() -def add_note_to_call_log(call_sid, note): - """Add note to call log. based on child call sid. - """ - twilio = Twilio.connect() - 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 - - 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=''): - mobile_no = parse_mobile_no(mobile_no) - - query = f""" - SELECT name, mobile_no - FROM `tab{doctype}` - WHERE CONCAT('+', REGEXP_REPLACE(mobile_no, '[^0-9]', '')) = {mobile_no} - """ - - data = frappe.db.sql(query + where, as_dict=True) - return data[0].name if data else None - - doctype = "CRM Deal" - 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') - if not doc: - doc = find_record(doctype, number) - - return doc, doctype + return frappe.utils.format_datetime(converted_datetime, "yyyy-MM-dd HH:mm:ss") diff --git a/crm/integrations/twilio/twilio_handler.py b/crm/integrations/twilio/twilio_handler.py index 1ece9296..2d6b7df1 100644 --- a/crm/integrations/twilio/twilio_handler.py +++ b/crm/integrations/twilio/twilio_handler.py @@ -1,19 +1,20 @@ -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 + :param settings: `CRM Twilio Settings` doctype """ self.settings = settings self.account_sid = settings.account_sid @@ -24,22 +25,19 @@ class Twilio: @classmethod def connect(self): - """Make a twilio connection. - """ - settings = frappe.get_doc("Twilio Settings") + """Make a twilio connection.""" + 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. - """ + """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,36 +94,36 @@ 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 @classmethod def get_twilio_client(self): - twilio_settings = frappe.get_doc("Twilio Settings") + 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("Twilio Settings", "Twilio Settings", 'auth_token') + + 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 @@ -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..0430f311 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,5 @@ 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' - """ - return ''.join([c for c in mobile_no if c.isdigit() or c == '+']) \ No newline at end of file diff --git a/crm/patches.txt b/crm/patches.txt index f964d13f..f8fbb4c6 100644 --- a/crm/patches.txt +++ b/crm/patches.txt @@ -2,6 +2,7 @@ # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations crm.patches.v1_0.move_crm_note_data_to_fcrm_note +crm.patches.v1_0.rename_twilio_settings_to_crm_twilio_settings [post_model_sync] # Patches added in this section will be executed after doctypes are migrated @@ -9,4 +10,5 @@ crm.patches.v1_0.create_email_template_custom_fields crm.patches.v1_0.create_default_fields_layout #10/12/2024 crm.patches.v1_0.create_default_sidebar_fields_layout crm.patches.v1_0.update_deal_quick_entry_layout -crm.patches.v1_0.update_layouts_to_new_format \ No newline at end of file +crm.patches.v1_0.update_layouts_to_new_format +crm.patches.v1_0.move_twilio_agent_to_telephony_agent \ No newline at end of file diff --git a/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py b/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py new file mode 100644 index 00000000..7049ab62 --- /dev/null +++ b/crm/patches/v1_0/move_twilio_agent_to_telephony_agent.py @@ -0,0 +1,27 @@ +import frappe + + +def execute(): + if not frappe.db.exists("DocType", "CRM Telephony Agent"): + frappe.reload_doctype("CRM Telephony Agent", force=True) + + if frappe.db.exists("DocType", "Twilio Agents") and frappe.db.count("Twilio Agents") == 0: + return + + agents = frappe.db.sql("SELECT * FROM `tabTwilio Agents`", as_dict=True) + if agents: + for agent in agents: + doc = frappe.get_doc( + { + "doctype": "CRM Telephony Agent", + "creation": agent.get("creation"), + "modified": agent.get("modified"), + "modified_by": agent.get("modified_by"), + "owner": agent.get("owner"), + "user": agent.get("user"), + "twilio_number": agent.get("twilio_number"), + "user_name": agent.get("user_name"), + "twilio": True, + } + ) + doc.db_insert() diff --git a/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py new file mode 100644 index 00000000..34976484 --- /dev/null +++ b/crm/patches/v1_0/rename_twilio_settings_to_crm_twilio_settings.py @@ -0,0 +1,20 @@ +import frappe +from frappe.model.rename_doc import rename_doc + + +def execute(): + if frappe.db.exists("DocType", "Twilio Settings"): + frappe.flags.ignore_route_conflict_validation = True + rename_doc("DocType", "Twilio Settings", "CRM Twilio Settings") + frappe.flags.ignore_route_conflict_validation = False + + frappe.reload_doctype("CRM Twilio Settings", force=True) + + if frappe.db.exists("__Auth", {"doctype": "Twilio Settings"}): + Auth = frappe.qb.DocType("__Auth") + result = frappe.qb.from_(Auth).select("*").where(Auth.doctype == "Twilio Settings").run(as_dict=True) + + for row in result: + frappe.qb.into(Auth).insert( + "CRM Twilio Settings", "CRM Twilio Settings", row.fieldname, row.password, row.encrypted + ).run() diff --git a/crm/utils/__init__.py b/crm/utils/__init__.py new file mode 100644 index 00000000..cd4a4ff8 --- /dev/null +++ b/crm/utils/__init__.py @@ -0,0 +1,95 @@ +import phonenumbers +from frappe.utils import floor +from phonenumbers import NumberParseException +from phonenumbers import PhoneNumberFormat as PNF + + +def parse_phone_number(phone_number, default_country="IN"): + try: + # Parse the number + number = phonenumbers.parse(phone_number, default_country) + + # Get various information about the number + result = { + "is_valid": phonenumbers.is_valid_number(number), + "country_code": number.country_code, + "national_number": str(number.national_number), + "formats": { + "international": phonenumbers.format_number(number, PNF.INTERNATIONAL), + "national": phonenumbers.format_number(number, PNF.NATIONAL), + "E164": phonenumbers.format_number(number, PNF.E164), + "RFC3966": phonenumbers.format_number(number, PNF.RFC3966), + }, + "type": phonenumbers.number_type(number), + "country": phonenumbers.region_code_for_number(number), + "is_possible": phonenumbers.is_possible_number(number), + } + + return {"success": True, **result} + except NumberParseException as e: + return {"success": False, "error": str(e)} + + +def are_same_phone_number(number1, number2, default_region="IN", validate=True): + """ + Check if two phone numbers are the same, regardless of their format. + + Args: + number1 (str): First phone number + number2 (str): Second phone number + default_region (str): Default region code for parsing ambiguous numbers + + Returns: + bool: True if numbers are same, False otherwise + """ + try: + # Parse both numbers + parsed1 = phonenumbers.parse(number1, default_region) + parsed2 = phonenumbers.parse(number2, default_region) + + # Check if both numbers are valid + if validate and not (phonenumbers.is_valid_number(parsed1) and phonenumbers.is_valid_number(parsed2)): + return False + + # Convert both to E164 format and compare + formatted1 = phonenumbers.format_number(parsed1, phonenumbers.PhoneNumberFormat.E164) + formatted2 = phonenumbers.format_number(parsed2, phonenumbers.PhoneNumberFormat.E164) + + return formatted1 == formatted2 + + except phonenumbers.NumberParseException: + return False + + +def seconds_to_duration(seconds): + if not seconds: + return "0s" + + hours = floor(seconds // 3600) + minutes = floor((seconds % 3600) // 60) + seconds = floor((seconds % 3600) % 60) + + # 1h 0m 0s -> 1h + # 0h 1m 0s -> 1m + # 0h 0m 1s -> 1s + # 1h 1m 0s -> 1h 1m + # 1h 0m 1s -> 1h 1s + # 0h 1m 1s -> 1m 1s + # 1h 1m 1s -> 1h 1m 1s + + if hours and minutes and seconds: + return f"{hours}h {minutes}m {seconds}s" + elif hours and minutes: + return f"{hours}h {minutes}m" + elif hours and seconds: + return f"{hours}h {seconds}s" + elif minutes and seconds: + return f"{minutes}m {seconds}s" + elif hours: + return f"{hours}h" + elif minutes: + return f"{minutes}m" + elif seconds: + return f"{seconds}s" + else: + return "0s" diff --git a/frappe-ui b/frappe-ui index d82b3a12..863eaae9 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit d82b3a12eeb6cb9e83375550508b462ce5cfdaf2 +Subproject commit 863eaae9ada2edb287fc09fb21d05212bb5eebe9 diff --git a/frontend/package.json b/frontend/package.json index 377a8c34..1ac29f78 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,7 +14,7 @@ "@vueuse/core": "^10.3.0", "@vueuse/integrations": "^10.3.0", "feather-icons": "^4.28.0", - "frappe-ui": "^0.1.104", + "frappe-ui": "^0.1.105", "gemoji": "^8.1.0", "lodash": "^4.17.21", "mime": "^4.0.1", @@ -22,7 +22,7 @@ "socket.io-client": "^4.7.2", "sortablejs": "^1.15.0", "tailwindcss": "^3.3.3", - "vue": "^3.4.12", + "vue": "^3.5.13", "vue-router": "^4.2.2", "vuedraggable": "^4.1.0" }, diff --git a/frontend/src/components/Activities/Activities.vue b/frontend/src/components/Activities/Activities.vue index baf28de0..392bf4b5 100644 --- a/frontend/src/components/Activities/Activities.vue +++ b/frontend/src/components/Activities/Activities.vue @@ -484,7 +484,7 @@ import CommunicationArea from '@/components/CommunicationArea.vue' import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue' import AllModals from '@/components/Activities/AllModals.vue' import FilesUploader from '@/components/FilesUploader/FilesUploader.vue' -import { timeAgo, formatDate, secondsToDuration, startCase } from '@/utils' +import { timeAgo, formatDate, startCase } from '@/utils' import { globalStore } from '@/stores/global' import { usersStore } from '@/stores/users' import { contactsStore } from '@/stores/contacts' @@ -544,40 +544,6 @@ const all_activities = createResource({ cache: ['activity', doc.value.data.name], auto: true, transform: ([versions, calls, notes, tasks, attachments]) => { - if (calls?.length) { - calls.forEach((doc) => { - doc.show_recording = false - doc.activity_type = - doc.type === 'Incoming' ? 'incoming_call' : 'outgoing_call' - doc.duration = secondsToDuration(doc.duration) - if (doc.type === 'Incoming') { - doc.caller = { - label: - getContact(doc.from)?.full_name || - getLeadContact(doc.from)?.full_name || - 'Unknown', - image: - getContact(doc.from)?.image || getLeadContact(doc.from)?.image, - } - doc.receiver = { - label: getUser(doc.receiver).full_name, - image: getUser(doc.receiver).user_image, - } - } else { - doc.caller = { - label: getUser(doc.caller).full_name, - image: getUser(doc.caller).user_image, - } - doc.receiver = { - label: - getContact(doc.to)?.full_name || - getLeadContact(doc.to)?.full_name || - 'Unknown', - image: getContact(doc.to)?.image || getLeadContact(doc.to)?.image, - } - } - }) - } return { versions, calls, notes, tasks, attachments } }, }) @@ -662,13 +628,13 @@ const activities = computed(() => { return sortByCreation(all_activities.data.calls) } else if (title.value == 'Tasks') { if (!all_activities.data?.tasks) return [] - return sortByCreation(all_activities.data.tasks) + return sortByModified(all_activities.data.tasks) } else if (title.value == 'Notes') { if (!all_activities.data?.notes) return [] - return sortByCreation(all_activities.data.notes) + return sortByModified(all_activities.data.notes) } else if (title.value == 'Attachments') { if (!all_activities.data?.attachments) return [] - return sortByCreation(all_activities.data.attachments) + return sortByModified(all_activities.data.attachments) } _activities.forEach((activity) => { @@ -696,6 +662,9 @@ const activities = computed(() => { function sortByCreation(list) { return list.sort((a, b) => new Date(a.creation) - new Date(b.creation)) } +function sortByModified(list) { + return list.sort((b, a) => new Date(a.modified) - new Date(b.modified)) +} function update_activities_details(activity) { activity.owner_name = getUser(activity.owner).full_name diff --git a/frontend/src/components/Activities/CallArea.vue b/frontend/src/components/Activities/CallArea.vue index 48fe6f72..d2689580 100644 --- a/frontend/src/components/Activities/CallArea.vue +++ b/frontend/src/components/Activities/CallArea.vue @@ -1,5 +1,5 @@ diff --git a/frontend/src/components/Layouts/AppHeader.vue b/frontend/src/components/Layouts/AppHeader.vue index 00a04369..6db3579a 100644 --- a/frontend/src/components/Layouts/AppHeader.vue +++ b/frontend/src/components/Layouts/AppHeader.vue @@ -8,5 +8,5 @@ diff --git a/frontend/src/components/ListViews/CallLogsListView.vue b/frontend/src/components/ListViews/CallLogsListView.vue index 1522e0a5..bf04ccf7 100644 --- a/frontend/src/components/ListViews/CallLogsListView.vue +++ b/frontend/src/components/ListViews/CallLogsListView.vue @@ -198,6 +198,7 @@ const props = defineProps({ }) const emit = defineEmits([ + 'showCallLog', 'loadMore', 'updatePageCount', 'columnWidthUpdated', diff --git a/frontend/src/components/Mobile/MobileAppHeader.vue b/frontend/src/components/Mobile/MobileAppHeader.vue index de564def..2e777ba2 100644 --- a/frontend/src/components/Mobile/MobileAppHeader.vue +++ b/frontend/src/components/Mobile/MobileAppHeader.vue @@ -16,6 +16,6 @@ diff --git a/frontend/src/components/Modals/CallLogModal.vue b/frontend/src/components/Modals/CallLogModal.vue index 23c2eeec..fb8ec499 100644 --- a/frontend/src/components/Modals/CallLogModal.vue +++ b/frontend/src/components/Modals/CallLogModal.vue @@ -83,12 +83,7 @@ - diff --git a/frontend/src/components/Settings/TwilioSettings.vue b/frontend/src/components/Settings/TwilioSettings.vue deleted file mode 100644 index 8d8479ba..00000000 --- a/frontend/src/components/Settings/TwilioSettings.vue +++ /dev/null @@ -1,6 +0,0 @@ - - diff --git a/frontend/src/components/Telephony/CallUI.vue b/frontend/src/components/Telephony/CallUI.vue new file mode 100644 index 00000000..a7e1e431 --- /dev/null +++ b/frontend/src/components/Telephony/CallUI.vue @@ -0,0 +1,140 @@ + + diff --git a/frontend/src/components/Telephony/ExotelCallUI.vue b/frontend/src/components/Telephony/ExotelCallUI.vue new file mode 100644 index 00000000..bb7844fa --- /dev/null +++ b/frontend/src/components/Telephony/ExotelCallUI.vue @@ -0,0 +1,584 @@ + + + diff --git a/frontend/src/components/Telephony/TaskPanel.vue b/frontend/src/components/Telephony/TaskPanel.vue new file mode 100644 index 00000000..bf9641a5 --- /dev/null +++ b/frontend/src/components/Telephony/TaskPanel.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/components/CallUI.vue b/frontend/src/components/Telephony/TwilioCallUI.vue similarity index 88% rename from frontend/src/components/CallUI.vue rename to frontend/src/components/Telephony/TwilioCallUI.vue index fb88f6ef..7738b1d5 100644 --- a/frontend/src/components/CallUI.vue +++ b/frontend/src/components/Telephony/TwilioCallUI.vue @@ -13,6 +13,7 @@
- {{ contact.full_name }} + {{ contact?.full_name ?? __('Unknown') }}
-
{{ contact.mobile_no }}
+
{{ contact?.mobile_no }}
@@ -34,10 +35,10 @@ callStatus == 'initiating' ? __('Initiating call...') : callStatus == 'ringing' - ? __('Ringing...') - : calling - ? __('Calling...') - : __('Incoming call...') + ? __('Ringing...') + : calling + ? __('Calling...') + : __('Incoming call...') }}
@@ -120,12 +121,13 @@ >
- {{ contact.full_name }} + {{ contact?.full_name ?? __('Unknown') }}
@@ -195,22 +197,13 @@ import CountUpTimer from '@/components/CountUpTimer.vue' import NoteModal from '@/components/Modals/NoteModal.vue' import { Device } from '@twilio/voice-sdk' import { useDraggable, useWindowSize } from '@vueuse/core' -import { globalStore } from '@/stores/global' -import { contactsStore } from '@/stores/contacts' import { capture } from '@/telemetry' -import { Avatar, call } from 'frappe-ui' -import { onMounted, ref, watch } from 'vue' - -const { getContact, getLeadContact } = contactsStore() -const { setMakeCall, setTwilioEnabled } = globalStore() +import { Avatar, call, createResource } from 'frappe-ui' +import { ref, watch } from 'vue' let device = '' let log = ref('Connecting...') let _call = null -const contact = ref({ - full_name: '', - mobile_no: '', -}) let showCallPopup = ref(false) let showSmallCallWindow = ref(false) @@ -220,8 +213,36 @@ let muted = ref(false) let callPopup = ref(null) let counterUp = ref(null) let callStatus = ref('') + +const phoneNumber = ref('') + +const contact = ref({ + full_name: '', + image: '', + mobile_no: '', +}) + +watch(phoneNumber, (value) => { + if (!value) return + getContact.fetch() +}) + +const getContact = createResource({ + url: 'crm.integrations.api.get_contact_by_phone_number', + makeParams() { + return { + phone_number: phoneNumber.value, + } + }, + cache: ['contact', phoneNumber.value], + onSuccess(data) { + contact.value = data + }, +}) + const showNoteModal = ref(false) const note = ref({ + name: '', title: '', content: '', }) @@ -229,9 +250,9 @@ const note = ref({ async function updateNote(_note, insert_mode = false) { note.value = _note if (insert_mode && _note.name) { - await call('crm.integrations.twilio.api.add_note_to_call_log', { + await call('crm.integrations.api.add_note_to_call_log', { call_sid: _call.parameters.CallSid, - note: _note.name, + note: _note, }) } } @@ -243,10 +264,6 @@ let { style } = useDraggable(callPopup, { preventDefault: true, }) -async function is_twilio_enabled() { - return await call('crm.integrations.twilio.api.is_enabled') -} - async function startupClient() { log.value = 'Requesting Access Token...' @@ -304,19 +321,7 @@ function toggleMute() { function handleIncomingCall(call) { log.value = `Incoming call from ${call.parameters.From}` - - // get name of the caller from the phone number - contact.value = getContact(call.parameters.From) - if (!contact.value) { - contact.value = getLeadContact(call.parameters.From) - } - - if (!contact.value) { - contact.value = { - full_name: __('Unknown'), - mobile_no: call.parameters.From, - } - } + phoneNumber.value = call.parameters.From showCallPopup.value = true _call = call @@ -358,6 +363,7 @@ function hangUpCall() { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } @@ -379,19 +385,7 @@ function handleDisconnectedIncomingCall() { } async function makeOutgoingCall(number) { - // check if number has a country code - // if (number?.replace(/[^0-9+]/g, '').length == 10) { - // $dialog({ - // title: 'Invalid Mobile Number', - // message: `${number} is not a valid mobile number. Either add a country code or check the number again.`, - // }) - // return - // } - - contact.value = getContact(number) - if (!contact.value) { - contact.value = getLeadContact(number) - } + phoneNumber.value = number if (device) { log.value = `Attempting to call ${number} ...` @@ -437,6 +431,7 @@ async function makeOutgoingCall(number) { muted.value = false counterUp.value.stop() note.value = { + name: '', title: '', content: '', } @@ -451,6 +446,7 @@ async function makeOutgoingCall(number) { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } @@ -477,6 +473,7 @@ function cancelCall() { callStatus.value = '' muted.value = false note.value = { + name: '', title: '', content: '', } @@ -491,21 +488,15 @@ function toggleCallWindow() { } } -onMounted(async () => { - let enabled = await is_twilio_enabled() - setTwilioEnabled(enabled) - enabled && startupClient() - - setMakeCall(makeOutgoingCall) -}) - watch( () => log.value, (value) => { console.log(value) }, - { immediate: true } + { immediate: true }, ) + +defineExpose({ makeOutgoingCall, setup: startupClient })