diff --git a/crm/api/comment.py b/crm/api/comment.py index 8f2df3f2..378a01b8 100644 --- a/crm/api/comment.py +++ b/crm/api/comment.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ from bs4 import BeautifulSoup def on_update(self, method): @@ -15,13 +16,26 @@ def notify_mentions(doc): return mentions = extract_mentions(content) for mention in mentions: + owner = frappe.get_cached_value("User", doc.owner, "full_name") + doctype = doc.reference_doctype + if doctype.startswith("CRM "): + doctype = doctype[4:].lower() + notification_text = f""" +
+ { owner } + { _('mentioned you in {0}').format(doctype) } + { doc.reference_name } +
+ """ values = frappe._dict( doctype="CRM Notification", from_user=doc.owner, to_user=mention.email, type="Mention", message=doc.content, - comment=doc.name, + notification_text=notification_text, + notification_type_doctype="Comment", + notification_type_doc=doc.name, reference_doctype=doc.reference_doctype, reference_name=doc.reference_name, ) diff --git a/crm/api/notifications.py b/crm/api/notifications.py index 3dde662c..972fa58e 100644 --- a/crm/api/notifications.py +++ b/crm/api/notifications.py @@ -28,13 +28,16 @@ def get_notifications(): "to_user": notification.to_user, "read": notification.read, "comment": notification.comment, - "reference_doctype": "deal" - if notification.reference_doctype == "CRM Deal" - else "lead", + "notification_text": notification.notification_text, + "notification_type_doctype": notification.notification_type_doctype, + "notification_type_doc": notification.notification_type_doc, + "reference_doctype": ( + "deal" if notification.reference_doctype == "CRM Deal" else "lead" + ), "reference_name": notification.reference_name, - "route_name": "Deal" - if notification.reference_doctype == "CRM Deal" - else "Lead", + "route_name": ( + "Deal" if notification.reference_doctype == "CRM Deal" else "Lead" + ), } ) @@ -42,12 +45,15 @@ def get_notifications(): @frappe.whitelist() -def mark_as_read(user=None, comment=None): +def mark_as_read(user=None, doc=None): user = user or frappe.session.user filters = {"to_user": user, "read": False} - if comment: - filters["comment"] = comment - for n in frappe.get_all("CRM Notification", filters=filters): + if doc: + or_filters = [ + {"comment": doc}, + {"notification_type_doc": doc}, + ] + for n in frappe.get_all("CRM Notification", filters=filters, or_filters=or_filters): d = frappe.get_doc("CRM Notification", n.name) d.read = True d.save() diff --git a/crm/api/whatsapp.py b/crm/api/whatsapp.py new file mode 100644 index 00000000..d871fc90 --- /dev/null +++ b/crm/api/whatsapp.py @@ -0,0 +1,314 @@ +import frappe +import json +from frappe import _ +from crm.api.doc import get_assigned_users + + +def validate(doc, method): + if doc.type == "Incoming" and doc.get("from"): + name, doctype = get_lead_or_deal_from_number(doc.get("from")) + doc.reference_doctype = doctype + doc.reference_name = name + + +def on_update(doc, method): + frappe.publish_realtime( + "whatsapp_message", + { + "reference_doctype": doc.reference_doctype, + "reference_name": doc.reference_name, + }, + ) + + notify_agent(doc) + + +def notify_agent(doc): + if doc.type == "Incoming": + doctype = doc.reference_doctype + if doctype.startswith("CRM "): + doctype = doctype[4:].lower() + notification_text = f""" +
+ { _('You') } + { _('received a whatsapp message in {0}').format(doctype) } + { doc.reference_name } +
+ """ + assigned_users = get_assigned_users(doc.reference_doctype, doc.reference_name) + for user in assigned_users: + values = frappe._dict( + doctype="CRM Notification", + from_user=doc.owner, + to_user=user, + type="WhatsApp", + message=doc.message, + notification_text=notification_text, + notification_type_doctype="WhatsApp Message", + notification_type_doc=doc.name, + reference_doctype=doc.reference_doctype, + reference_name=doc.reference_name, + ) + + if frappe.db.exists("CRM Notification", values): + return + frappe.get_doc(values).insert(ignore_permissions=True) + + +def get_lead_or_deal_from_number(number): + """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" + + 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 + + +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 == "+"]) + + +@frappe.whitelist() +def is_whatsapp_enabled(): + if not frappe.db.exists("DocType", "WhatsApp Settings"): + return False + return frappe.get_cached_value("WhatsApp Settings", "WhatsApp Settings", "enabled") + + +@frappe.whitelist() +def get_whatsapp_messages(reference_doctype, reference_name): + if not frappe.db.exists("DocType", "WhatsApp Message"): + return [] + messages = frappe.get_all( + "WhatsApp Message", + filters={ + "reference_doctype": reference_doctype, + "reference_name": reference_name, + }, + fields=[ + "name", + "type", + "to", + "from", + "content_type", + "message_type", + "attach", + "template", + "use_template", + "message_id", + "is_reply", + "reply_to_message_id", + "creation", + "message", + "status", + "reference_doctype", + "reference_name", + "template_parameters", + "template_header_parameters", + ], + ) + + # Filter messages to get only Template messages + template_messages = [ + message for message in messages if message["message_type"] == "Template" + ] + + # Iterate through template messages + for template_message in template_messages: + # Find the template that this message is using + template = frappe.get_doc("WhatsApp Templates", template_message["template"]) + + # If the template is found, add the template details to the template message + if template: + template_message["template_name"] = template.template_name + if template_message["template_parameters"]: + parameters = json.loads(template_message["template_parameters"]) + template.template = parse_template_parameters( + template.template, parameters + ) + + template_message["template"] = template.template + if template_message["template_header_parameters"]: + header_parameters = json.loads( + template_message["template_header_parameters"] + ) + template.header = parse_template_parameters( + template.header, header_parameters + ) + template_message["header"] = template.header + template_message["footer"] = template.footer + + # Filter messages to get only reaction messages + reaction_messages = [ + message for message in messages if message["content_type"] == "reaction" + ] + + # Iterate through reaction messages + for reaction_message in reaction_messages: + # Find the message that this reaction is reacting to + reacted_message = next( + ( + m + for m in messages + if m["message_id"] == reaction_message["reply_to_message_id"] + ), + None, + ) + + # If the reacted message is found, add the reaction to it + if reacted_message: + reacted_message["reaction"] = reaction_message["message"] + + for message in messages: + from_name = get_from_name(message) if message["from"] else _("You") + message["from_name"] = from_name + # Filter messages to get only replies + reply_messages = [message for message in messages if message["is_reply"]] + + # Iterate through reply messages + for reply_message in reply_messages: + # Find the message that this message is replying to + replied_message = next( + ( + m + for m in messages + if m["message_id"] == reply_message["reply_to_message_id"] + ), + None, + ) + + # If the replied message is found, add the reply details to the reply message + from_name = ( + get_from_name(reply_message) if replied_message["from"] else _("You") + ) + if replied_message: + message = replied_message["message"] + if replied_message["message_type"] == "Template": + message = replied_message["template"] + reply_message["reply_message"] = message + reply_message["header"] = replied_message.get("header") or "" + reply_message["footer"] = replied_message.get("footer") or "" + reply_message["reply_to"] = replied_message["name"] + reply_message["reply_to_type"] = replied_message["type"] + reply_message["reply_to_from"] = from_name + + return [message for message in messages if message["content_type"] != "reaction"] + + +@frappe.whitelist() +def create_whatsapp_message( + reference_doctype, + reference_name, + message, + to, + attach, + reply_to, + content_type="text", +): + doc = frappe.new_doc("WhatsApp Message") + + if reply_to: + reply_doc = frappe.get_doc("WhatsApp Message", reply_to) + doc.update( + { + "is_reply": True, + "reply_to_message_id": reply_doc.message_id, + } + ) + + doc.update( + { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "message": message or attach, + "to": to, + "attach": attach, + "content_type": content_type, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +@frappe.whitelist() +def send_whatsapp_template(reference_doctype, reference_name, template, to): + doc = frappe.new_doc("WhatsApp Message") + doc.update( + { + "reference_doctype": reference_doctype, + "reference_name": reference_name, + "message_type": "Template", + "message": "Template message", + "content_type": "text", + "use_template": True, + "template": template, + "to": to, + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +@frappe.whitelist() +def react_on_whatsapp_message(emoji, reply_to_name): + reply_to_doc = frappe.get_doc("WhatsApp Message", reply_to_name) + to = reply_to_doc.type == "Incoming" and reply_to_doc.get("from") or reply_to_doc.to + doc = frappe.new_doc("WhatsApp Message") + doc.update( + { + "reference_doctype": reply_to_doc.reference_doctype, + "reference_name": reply_to_doc.reference_name, + "message": emoji, + "to": to, + "reply_to_message_id": reply_to_doc.message_id, + "content_type": "reaction", + } + ) + doc.insert(ignore_permissions=True) + return doc.name + + +def parse_template_parameters(string, parameters): + for i, parameter in enumerate(parameters, start=1): + placeholder = "{{" + str(i) + "}}" + string = string.replace(placeholder, parameter) + + return string + + +def get_from_name(message): + doc = frappe.get_doc(message["reference_doctype"], message["reference_name"]) + from_name = "" + if message["reference_doctype"] == "CRM Deal": + if doc.get("contacts"): + for c in doc.get("contacts"): + if c.is_primary: + from_name = c.full_name or c.mobile_no + break + else: + from_name = doc.get("lead_name") + else: + from_name = doc.get("first_name") + " " + doc.get("last_name") + return from_name diff --git a/crm/fcrm/doctype/crm_notification/crm_notification.json b/crm/fcrm/doctype/crm_notification/crm_notification.json index 7b9cee2e..be7ac1f6 100644 --- a/crm/fcrm/doctype/crm_notification/crm_notification.json +++ b/crm/fcrm/doctype/crm_notification/crm_notification.json @@ -5,14 +5,20 @@ "doctype": "DocType", "engine": "InnoDB", "field_order": [ + "notification_text", + "section_break_hace", "from_user", "type", - "reference_doctype", - "reference_name", "column_break_dduu", "to_user", - "comment", "read", + "section_break_pbvx", + "reference_doctype", + "reference_name", + "column_break_eant", + "notification_type_doctype", + "notification_type_doc", + "comment", "section_break_vpwa", "message" ], @@ -28,7 +34,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Type", - "options": "Mention", + "options": "Mention\nWhatsApp", "reqd": 1 }, { @@ -46,8 +52,8 @@ { "fieldname": "comment", "fieldtype": "Link", + "hidden": 1, "label": "Comment", - "link_filters": "[[{\"fieldname\":\"comment\",\"field_option\":\"Comment\"},\"comment_type\",\"=\",\"Comment\"]]", "options": "Comment" }, { @@ -63,6 +69,7 @@ { "fieldname": "message", "fieldtype": "HTML Editor", + "in_list_view": 1, "label": "Message" }, { @@ -76,11 +83,40 @@ "fieldtype": "Link", "label": "Reference Doctype", "options": "DocType" + }, + { + "fieldname": "section_break_pbvx", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_eant", + "fieldtype": "Column Break" + }, + { + "fieldname": "notification_type_doctype", + "fieldtype": "Link", + "label": "Notification Type Doctype", + "options": "DocType" + }, + { + "fieldname": "notification_type_doc", + "fieldtype": "Dynamic Link", + "label": "Notification Type Doc", + "options": "notification_type_doctype" + }, + { + "fieldname": "notification_text", + "fieldtype": "Text", + "label": "Notification Text" + }, + { + "fieldname": "section_break_hace", + "fieldtype": "Section Break" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-01-30 01:04:27.946030", + "modified": "2024-04-25 16:26:07.484857", "modified_by": "Administrator", "module": "FCRM", "name": "CRM Notification", diff --git a/crm/fcrm/doctype/crm_notification/crm_notification.py b/crm/fcrm/doctype/crm_notification/crm_notification.py index 9470101f..28f4ab80 100644 --- a/crm/fcrm/doctype/crm_notification/crm_notification.py +++ b/crm/fcrm/doctype/crm_notification/crm_notification.py @@ -1,9 +1,10 @@ # Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt -# import frappe +import frappe from frappe.model.document import Document class CRMNotification(Document): - pass + def on_update(self): + frappe.publish_realtime("crm_notification") diff --git a/crm/hooks.py b/crm/hooks.py index b25039c3..740c21b2 100644 --- a/crm/hooks.py +++ b/crm/hooks.py @@ -135,7 +135,11 @@ doc_events = { }, "Comment": { "on_update": ["crm.api.comment.on_update"], - } + }, + "WhatsApp Message": { + "validate": ["crm.api.whatsapp.validate"], + "on_update": ["crm.api.whatsapp.on_update"], + }, } # Scheduled Tasks diff --git a/crm/www/crm.py b/crm/www/crm.py index 05e4014b..44347cf3 100644 --- a/crm/www/crm.py +++ b/crm/www/crm.py @@ -8,9 +8,34 @@ from frappe.utils.telemetry import capture no_cache = 1 -def get_context(context): +def get_context(): csrf_token = frappe.sessions.get_csrf_token() frappe.db.commit() + context = frappe._dict() + context.boot = get_boot() + context.boot.csrf_token = csrf_token if frappe.session.user != "Guest": capture("active_site", "crm") - context.csrf_token = csrf_token + return context + + +@frappe.whitelist(methods=["POST"], allow_guest=True) +def get_context_for_dev(): + if not frappe.conf.developer_mode: + frappe.throw("This method is only meant for developer mode") + return get_boot() + + +def get_boot(): + return frappe._dict( + { + "frappe_version": frappe.__version__, + "default_route": get_default_route(), + "site_name": frappe.local.site, + "read_only_mode": frappe.flags.read_only, + } + ) + + +def get_default_route(): + return "/crm" diff --git a/frappe-ui b/frappe-ui index 05a8eca5..063cf8d3 160000 --- a/frappe-ui +++ b/frappe-ui @@ -1 +1 @@ -Subproject commit 05a8eca589d23d44f55cfe82ae157fd5de997abb +Subproject commit 063cf8d3563776aabe83a93ff5dcea5c435ae1f6 diff --git a/frontend/index.html b/frontend/index.html index 31a720b7..a8d04a14 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,7 +16,11 @@
- + diff --git a/frontend/package.json b/frontend/package.json index 43f8e1a9..8742683b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,8 @@ "@vueuse/core": "^10.3.0", "@vueuse/integrations": "^10.3.0", "feather-icons": "^4.28.0", - "frappe-ui": "^0.1.51", + "frappe-ui": "^0.1.53", + "gemoji": "^8.1.0", "mime": "^4.0.1", "pinia": "^2.0.33", "socket.io-client": "^4.7.2", diff --git a/frontend/src/components/Activities.vue b/frontend/src/components/Activities.vue index 8075a792..c914a125 100644 --- a/frontend/src/components/Activities.vue +++ b/frontend/src/components/Activities.vue @@ -35,32 +35,19 @@ {{ __('New Task') }} - +
+ +
+ diff --git a/frontend/src/components/WhatsAppArea.vue b/frontend/src/components/WhatsAppArea.vue new file mode 100644 index 00000000..4f1ef3e4 --- /dev/null +++ b/frontend/src/components/WhatsAppArea.vue @@ -0,0 +1,263 @@ + + + diff --git a/frontend/src/components/WhatsAppBox.vue b/frontend/src/components/WhatsAppBox.vue new file mode 100644 index 00000000..c3a1aa4c --- /dev/null +++ b/frontend/src/components/WhatsAppBox.vue @@ -0,0 +1,162 @@ +