├── license.txt ├── twilio_integration ├── patches.txt ├── config │ ├── __init__.py │ ├── desktop.py │ └── docs.py ├── templates │ ├── __init__.py │ └── pages │ │ └── __init__.py ├── modules.txt ├── twilio_integration │ ├── doctype │ │ ├── __init__.py │ │ ├── twilio_settings │ │ │ ├── __init__.py │ │ │ ├── test_twilio_settings.py │ │ │ ├── twilio_settings.js │ │ │ ├── twilio_settings.json │ │ │ └── twilio_settings.py │ │ ├── whatsapp_campaign │ │ │ ├── __init__.py │ │ │ ├── test_whatsapp_campaign.py │ │ │ ├── whatsapp_campaign.js │ │ │ ├── whatsapp_campaign.py │ │ │ └── whatsapp_campaign.json │ │ ├── whatsapp_message │ │ │ ├── __init__.py │ │ │ ├── test_whatsapp_message.py │ │ │ ├── whatsapp_message.js │ │ │ ├── whatsapp_message.py │ │ │ └── whatsapp_message.json │ │ ├── whatsapp_message_template │ │ │ ├── __init__.py │ │ │ ├── test_whatsapp_message_template.py │ │ │ ├── whatsapp_message_template.js │ │ │ ├── whatsapp_message_template.py │ │ │ └── whatsapp_message_template.json │ │ └── whatsapp_campaign_recipient │ │ │ ├── __init__.py │ │ │ ├── whatsapp_campaign_recipient.py │ │ │ └── whatsapp_campaign_recipient.json │ ├── __init__.py │ ├── utils.py │ ├── api.py │ └── twilio_handler.py ├── public │ ├── build.json │ ├── js │ │ ├── Notification.js │ │ ├── voice_call_settings.js │ │ └── twilio_call_handler.js │ └── css │ │ └── twilio_call_handler.css ├── __init__.py ├── boot.py ├── overrides │ └── notification.py ├── fixtures │ ├── property_setter.json │ └── custom_field.json └── hooks.py ├── .gitignore ├── .github ├── twilio-settings.png ├── twilio-phone-popup.png ├── voice-call-settings.png ├── twilio-incoming-call-popup.png ├── twilio-outgoing-call-popup.png └── twilio-whatsapp-notification.png ├── requirements.txt ├── .editorconfig ├── setup.py ├── MANIFEST.in └── README.md /license.txt: -------------------------------------------------------------------------------- 1 | License: MIT -------------------------------------------------------------------------------- /twilio_integration/patches.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/modules.txt: -------------------------------------------------------------------------------- 1 | Twilio Integration -------------------------------------------------------------------------------- /twilio_integration/templates/pages/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/__init__.py: -------------------------------------------------------------------------------- 1 | from . import api 2 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/twilio_settings/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message_template/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign_recipient/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.egg-info 4 | *.swp 5 | tags 6 | twilio_integration/docs/current -------------------------------------------------------------------------------- /.github/twilio-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/twilio-settings.png -------------------------------------------------------------------------------- /.github/twilio-phone-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/twilio-phone-popup.png -------------------------------------------------------------------------------- /.github/voice-call-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/voice-call-settings.png -------------------------------------------------------------------------------- /twilio_integration/public/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "js/twilio-call-handler.js": [ 3 | "public/js/twilio_call_handler.js" 4 | ] 5 | } -------------------------------------------------------------------------------- /.github/twilio-incoming-call-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/twilio-incoming-call-popup.png -------------------------------------------------------------------------------- /.github/twilio-outgoing-call-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/twilio-outgoing-call-popup.png -------------------------------------------------------------------------------- /twilio_integration/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | __version__ = '0.0.1' 5 | 6 | -------------------------------------------------------------------------------- /.github/twilio-whatsapp-notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frappe/twilio-integration/HEAD/.github/twilio-whatsapp-notification.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # frappe # https://github.com/frappe/frappe is installed during bench-init 2 | # erpnext # to be installed using bench 3 | twilio==6.44.2 4 | pyngrok~=5.1.0 5 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign/test_whatsapp_campaign.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestWhatsAppCampaign(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message/test_whatsapp_message.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestWhatsAppMessage(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WhatsApp Message', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message_template/test_whatsapp_message_template.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and Contributors 2 | # See license.txt 3 | 4 | # import frappe 5 | import unittest 6 | 7 | class TestWhatsAppMessageTemplate(unittest.TestCase): 8 | pass 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Root editor config file 2 | root = true 3 | 4 | # Common settings 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | charset = utf-8 10 | 11 | # python, js indentation settings 12 | [{*.py,*.js}] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message_template/whatsapp_message_template.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WhatsApp Message Template', { 5 | // refresh: function(frm) { 6 | 7 | // } 8 | }); 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message_template/whatsapp_message_template.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | class WhatsAppMessageTemplate(Document): 8 | pass 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign_recipient/whatsapp_campaign_recipient.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | # import frappe 5 | from frappe.model.document import Document 6 | 7 | class WhatsAppCampaignRecipient(Document): 8 | pass 9 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/twilio_settings/test_twilio_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2020, Frappe and Contributors 3 | # See license.txt 4 | from __future__ import unicode_literals 5 | 6 | # import frappe 7 | import unittest 8 | 9 | class TestTwilioSettings(unittest.TestCase): 10 | pass 11 | -------------------------------------------------------------------------------- /twilio_integration/config/desktop.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from frappe import _ 4 | 5 | def get_data(): 6 | return [ 7 | { 8 | "module_name": "Twilio Integration", 9 | "color": "grey", 10 | "icon": "octicon octicon-file-directory", 11 | "type": "module", 12 | "label": _("Twilio Integration") 13 | } 14 | ] 15 | -------------------------------------------------------------------------------- /twilio_integration/config/docs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Configuration for docs 3 | """ 4 | 5 | # source_link = "https://github.com/[org_name]/twilio_integration" 6 | # docs_base_url = "https://[org_name].github.io/twilio_integration" 7 | # headline = "App that does everything" 8 | # sub_heading = "Yes, you got that right the first time, everything" 9 | 10 | def get_context(context): 11 | context.brand_html = "Twilio Integration" 12 | -------------------------------------------------------------------------------- /twilio_integration/boot.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import frappe 3 | 4 | def boot_session(bootinfo): 5 | """Include twilio enabled flag into boot. 6 | """ 7 | twilio_settings_enabled = frappe.db.get_single_value('Twilio Settings', 'enabled') 8 | twilio_enabled_for_user = frappe.db.get_value('Voice Call Settings', frappe.session.user, 'twilio_number') 9 | bootinfo.twilio_enabled = twilio_settings_enabled and twilio_enabled_for_user 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | with open('requirements.txt') as f: 5 | install_requires = f.read().strip().split('\n') 6 | 7 | # get version from __version__ variable in twilio_integration/__init__.py 8 | from twilio_integration import __version__ as version 9 | 10 | setup( 11 | name='twilio_integration', 12 | version=version, 13 | description='Custom Frappe Application for Twilio Integration', 14 | author='Frappe', 15 | author_email='developers@frappe.io', 16 | packages=find_packages(), 17 | zip_safe=False, 18 | include_package_data=True, 19 | install_requires=install_requires 20 | ) 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include MANIFEST.in 2 | include requirements.txt 3 | include *.json 4 | include *.md 5 | include *.py 6 | include *.txt 7 | recursive-include twilio_integration *.css 8 | recursive-include twilio_integration *.csv 9 | recursive-include twilio_integration *.html 10 | recursive-include twilio_integration *.ico 11 | recursive-include twilio_integration *.js 12 | recursive-include twilio_integration *.json 13 | recursive-include twilio_integration *.md 14 | recursive-include twilio_integration *.png 15 | recursive-include twilio_integration *.py 16 | recursive-include twilio_integration *.svg 17 | recursive-include twilio_integration *.txt 18 | recursive-exclude twilio_integration *.pyc -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2020, Frappe Technologies and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('Twilio Settings', { 5 | onload: function(frm) { 6 | frm.set_query('outgoing_voice_medium', function() { 7 | return { 8 | filters: { 9 | communication_channel: "Twilio", 10 | communication_medium_type: "Voice" 11 | } 12 | }; 13 | }); 14 | }, 15 | refresh: function(frm) { 16 | frm.dashboard.set_headline(__("For more information, {0}.", [`${__('Click here')}`])); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /twilio_integration/public/js/Notification.js: -------------------------------------------------------------------------------- 1 | frappe.ui.form.on('Notification', { 2 | onload: function(frm) { 3 | frm.set_query('twilio_number', function() { 4 | return { 5 | filters: { 6 | communication_channel: "Twilio", 7 | communication_medium_type: "WhatsApp" 8 | } 9 | }; 10 | }); 11 | }, 12 | 13 | refresh: function(frm) { 14 | frm.events.setup_whatsapp_template(frm); 15 | }, 16 | 17 | channel: function(frm) { 18 | frm.events.setup_whatsapp_template(frm); 19 | }, 20 | 21 | setup_whatsapp_template: function(frm) { 22 | let template = ''; 23 | if (frm.doc.channel === 'WhatsApp') { 24 | template = `
Warning:
Only Use Pre-Approved WhatsApp for Business Template 25 |
Message Example
26 | 27 |
28 | Your appointment is coming up on {{ doc.date }} at {{ doc.time }}
29 | 
`; 30 | } 31 | if (template) { 32 | frm.set_df_property('message_examples', 'options', template); 33 | } 34 | 35 | } 36 | }); -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/utils.py: -------------------------------------------------------------------------------- 1 | from pyngrok import ngrok 2 | import frappe 3 | from frappe.utils import get_url 4 | 5 | 6 | def get_public_url(path: str=None, use_ngrok: bool=False): 7 | """Returns a public accessible url of a site using ngrok. 8 | """ 9 | if frappe.conf.developer_mode and use_ngrok: 10 | tunnels = ngrok.get_tunnels() 11 | if tunnels: 12 | domain = tunnels[0].public_url 13 | else: 14 | port = frappe.conf.http_port or frappe.conf.webserver_port 15 | domain = ngrok.connect(port) 16 | return '/'.join(map(lambda x: x.strip('/'), [domain, path or ''])) 17 | return get_url(path) 18 | 19 | 20 | def merge_dicts(d1: dict, d2: dict): 21 | """Merge dicts of dictionaries. 22 | >>> merge_dicts( 23 | {'name1': {'age': 20}, 'name2': {'age': 30}}, 24 | {'name1': {'phone': '+xxx'}, 'name2': {'phone': '+yyy'}, 'name3': {'phone': '+zzz'}} 25 | ) 26 | ... {'name1': {'age': 20, 'phone': '+xxx'}, 'name2': {'age': 30, 'phone': '+yyy'}} 27 | """ 28 | return {k:{**v, **d2.get(k, {})} for k, v in d1.items()} 29 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2021, Frappe and contributors 2 | // For license information, please see license.txt 3 | 4 | frappe.ui.form.on('WhatsApp Campaign', { 5 | setup: function(frm) { 6 | frappe.call({ 7 | doc: frm.doc, 8 | method: 'get_doctype_list', 9 | callback: function(r) { 10 | if(r.message) { 11 | let options = [] 12 | r.message.forEach((dt) => { 13 | options.push({ 14 | 'label': dt, 15 | 'value': dt 16 | }); 17 | }) 18 | frappe.meta.get_docfield('WhatsApp Campaign Recipient', 'campaign_for', frm.doc.name).options = [""].concat(options); 19 | } 20 | } 21 | }); 22 | }, 23 | 24 | refresh: function(frm) { 25 | if(frm.doc.status == 'Completed') { 26 | frm.disable_form(); 27 | frm.disable_save(); 28 | } 29 | if(!frm.is_new() && frm.doc.status!='Completed') { 30 | frm.add_custom_button(('Send Now'), function(){ 31 | frappe.call({ 32 | doc: frm.doc, 33 | method: 'send_now', 34 | freeze: true, 35 | callback: (r) => { 36 | frm.reload_doc(); 37 | } 38 | }) 39 | }); 40 | } 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /twilio_integration/public/js/voice_call_settings.js: -------------------------------------------------------------------------------- 1 | 2 | frappe.ui.form.on('Voice Call Settings', { 3 | onload: function(frm) { 4 | if (!frappe.user_roles.includes('System Manager')){ 5 | frm.set_value('user', frappe.session.user) 6 | } 7 | }, 8 | refresh: function(frm) { 9 | frappe.call({ 10 | method: "twilio_integration.twilio_integration.api.get_twilio_phone_numbers", 11 | callback: function(resp) { 12 | if (resp.message.length) { 13 | frm.set_df_property('twilio_number', 'options', resp.message); 14 | frm.refresh_field('twilio_number'); 15 | } 16 | else { 17 | frappe.show_alert({ 18 | message:__('Voice settings rely on Twilio settings. Please make sure that Twilio settings are configured & enabled.'), 19 | indicator:'red' 20 | }, 6); 21 | } 22 | } 23 | }); 24 | }, 25 | user: function(frm) { 26 | frappe.db.exists('Voice Call Settings', frm.doc.user).then( exists => { 27 | if (frm.doc.user && exists) { 28 | var doc_url = `/desk#Form/${frm.doc.doctype}/${frm.doc.user}`; 29 | var link_html = `here`; 30 | frappe.msgprint(__('Voice call settings already exist for user {0}. You can edit them {1}', [frm.doc.user, link_html])); 31 | } 32 | }); 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign_recipient/whatsapp_campaign_recipient.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-06-24 16:06:17.418857", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "campaign_for", 9 | "recipient", 10 | "whatsapp_no" 11 | ], 12 | "fields": [ 13 | { 14 | "fieldname": "campaign_for", 15 | "fieldtype": "Select", 16 | "in_list_view": 1, 17 | "label": "Campaign For", 18 | "reqd": 1 19 | }, 20 | { 21 | "fieldname": "recipient", 22 | "fieldtype": "Dynamic Link", 23 | "in_list_view": 1, 24 | "label": "Recipient", 25 | "options": "campaign_for", 26 | "reqd": 1 27 | }, 28 | { 29 | "fieldname": "whatsapp_no", 30 | "fieldtype": "Data", 31 | "in_list_view": 1, 32 | "label": "WhatsApp No.", 33 | "options": "Phone" 34 | } 35 | ], 36 | "index_web_pages_for_search": 1, 37 | "istable": 1, 38 | "links": [], 39 | "modified": "2021-07-06 17:12:37.779388", 40 | "modified_by": "Administrator", 41 | "module": "Twilio Integration", 42 | "name": "WhatsApp Campaign Recipient", 43 | "owner": "Administrator", 44 | "permissions": [], 45 | "sort_field": "modified", 46 | "sort_order": "DESC", 47 | "track_changes": 1 48 | } -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message_template/whatsapp_message_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "field:template_name", 4 | "creation": "2021-06-24 19:30:31.845274", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "template_name", 10 | "module", 11 | "message" 12 | ], 13 | "fields": [ 14 | { 15 | "fieldname": "template_name", 16 | "fieldtype": "Data", 17 | "label": "Template Name", 18 | "unique": 1 19 | }, 20 | { 21 | "fieldname": "module", 22 | "fieldtype": "Link", 23 | "label": "Module", 24 | "options": "Module Def" 25 | }, 26 | { 27 | "fieldname": "message", 28 | "fieldtype": "Text", 29 | "label": "Message" 30 | } 31 | ], 32 | "index_web_pages_for_search": 1, 33 | "links": [], 34 | "modified": "2021-06-24 23:06:31.692896", 35 | "modified_by": "Administrator", 36 | "module": "Twilio Integration", 37 | "name": "WhatsApp Message Template", 38 | "owner": "Administrator", 39 | "permissions": [ 40 | { 41 | "create": 1, 42 | "delete": 1, 43 | "email": 1, 44 | "export": 1, 45 | "print": 1, 46 | "read": 1, 47 | "report": 1, 48 | "role": "System Manager", 49 | "share": 1, 50 | "write": 1 51 | } 52 | ], 53 | "sort_field": "modified", 54 | "sort_order": "DESC", 55 | "track_changes": 1 56 | } -------------------------------------------------------------------------------- /twilio_integration/overrides/notification.py: -------------------------------------------------------------------------------- 1 | import frappe 2 | from frappe import _ 3 | from frappe.email.doctype.notification.notification import Notification, get_context, json 4 | from twilio_integration.twilio_integration.doctype.whatsapp_message.whatsapp_message import WhatsAppMessage 5 | 6 | class SendNotification(Notification): 7 | def validate(self): 8 | self.validate_twilio_settings() 9 | 10 | def validate_twilio_settings(self): 11 | if self.enabled and self.channel == "WhatsApp" \ 12 | and not frappe.db.get_single_value("Twilio Settings", "enabled"): 13 | frappe.throw(_("Please enable Twilio settings to send WhatsApp messages")) 14 | 15 | def send(self, doc): 16 | context = get_context(doc) 17 | context = {"doc": doc, "alert": self, "comments": None} 18 | if doc.get("_comments"): 19 | context["comments"] = json.loads(doc.get("_comments")) 20 | 21 | if self.is_standard: 22 | self.load_standard_properties(context) 23 | 24 | try: 25 | if self.channel == 'WhatsApp': 26 | self.send_whatsapp_msg(doc, context) 27 | except: 28 | frappe.log_error(title='Failed to send notification', message=frappe.get_traceback()) 29 | 30 | super(SendNotification, self).send(doc) 31 | 32 | def send_whatsapp_msg(self, doc, context): 33 | WhatsAppMessage.send_whatsapp_message( 34 | receiver_list=self.get_receiver_list(doc, context), 35 | message=frappe.render_template(self.message, context), 36 | doctype = self.doctype, 37 | docname = self.name 38 | ) -------------------------------------------------------------------------------- /twilio_integration/public/css/twilio_call_handler.css: -------------------------------------------------------------------------------- 1 | .dialpad-section { 2 | flex: 1; 3 | display: flex; 4 | justify-content: center; 5 | align-items: center; 6 | border-radius: var(--border-radius); 7 | pointer-events: all; 8 | position: absolute; 9 | top: 140px; 10 | right: 23px; 11 | z-index: 2000; 12 | border: 1px solid var(--gray-300); 13 | box-shadow: var(--shadow-base); 14 | background-color: white; 15 | } 16 | 17 | .dialpad-section .dialpad-container { 18 | padding: var(--padding-sm); 19 | } 20 | 21 | .dialpad-section .dialpad-container .dialpad-keys { 22 | display: grid; 23 | grid-template-columns: repeat(3, minmax(0, 1fr)); 24 | } 25 | 26 | .dialpad-section .dialpad-container .dialpad-input { 27 | border-radius: var(--border-radius); 28 | background-color: var(--control-bg); 29 | text-align: center !important; 30 | height: calc(1.5em + 1rem + 1px); 31 | margin-bottom: var(--margin-sm); 32 | cursor: text; 33 | } 34 | 35 | .dialpad-section .dialpad-container .dialpad-keys .dialpad-btn { 36 | cursor: pointer; 37 | border-radius: var(--border-radius-md); 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | padding: var(--padding-md) var(--padding-lg); 42 | background-color: white; 43 | } 44 | 45 | .dialpad-section .dialpad-container .dialpad-keys .dialpad-btn:hover { 46 | background-color: var(--control-bg); 47 | } 48 | 49 | .dialpad-icon { 50 | position: absolute; 51 | top: 4px; 52 | right: 8px; 53 | padding: 3px; 54 | } 55 | 56 | .dialpad--pointer { 57 | position: absolute; 58 | background: white; 59 | border-top: 1px solid var(--gray-300); 60 | border-right: 1px solid var(--gray-300); 61 | width: 10px; 62 | height: 10px; 63 | z-index: 2001; 64 | top: -6px; 65 | right: 11px; 66 | transform: rotate(315deg); 67 | } -------------------------------------------------------------------------------- /twilio_integration/fixtures/property_setter.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "default_value": null, 4 | "doc_type": "Communication Medium", 5 | "docstatus": 0, 6 | "doctype": "Property Setter", 7 | "doctype_or_field": "DocField", 8 | "field_name": "communication_channel", 9 | "modified": "2020-10-26 10:39:10.230657", 10 | "name": "Communication Medium-communication_channel-options", 11 | "parent": null, 12 | "parentfield": null, 13 | "parenttype": null, 14 | "property": "options", 15 | "property_type": "Text", 16 | "row_name": null, 17 | "value": "\nExotel\nTwilio" 18 | }, 19 | { 20 | "default_value": null, 21 | "doc_type": "Notification", 22 | "docstatus": 0, 23 | "doctype": "Property Setter", 24 | "doctype_or_field": "DocField", 25 | "field_name": "channel", 26 | "modified": "2020-11-01 11:05:40.052493", 27 | "name": "Notification-channel-options", 28 | "parent": null, 29 | "parentfield": null, 30 | "parenttype": null, 31 | "property": "options", 32 | "property_type": "Text", 33 | "row_name": null, 34 | "value": "Email\nSlack\nSystem Notification\nSMS\nWhatsApp" 35 | }, 36 | { 37 | "default_value": null, 38 | "doc_type": "Notification", 39 | "docstatus": 0, 40 | "doctype": "Property Setter", 41 | "doctype_or_field": "DocField", 42 | "field_name": "column_break_5", 43 | "modified": "2020-10-25 11:06:45.707981", 44 | "name": "Notification-column_break_5-depends_on", 45 | "parent": null, 46 | "parentfield": null, 47 | "parenttype": null, 48 | "property": "depends_on", 49 | "property_type": "Data", 50 | "row_name": null, 51 | "value": "eval:in_list(['Email', 'SMS', 'WhatsApp'], doc.channel)" 52 | }, 53 | { 54 | "default_value": null, 55 | "doc_type": "Communication Medium", 56 | "docstatus": 0, 57 | "doctype": "Property Setter", 58 | "doctype_or_field": "DocField", 59 | "field_name": "communication_medium_type", 60 | "modified": "2020-10-24 15:04:25.859806", 61 | "name": "Communication Medium-communication_medium_type-options", 62 | "parent": null, 63 | "parentfield": null, 64 | "parenttype": null, 65 | "property": "options", 66 | "property_type": "Text", 67 | "row_name": null, 68 | "value": "Voice\nEmail\nChat\nWhatsApp" 69 | } 70 | ] -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | from six import string_types 7 | from frappe.utils.password import get_decrypted_password 8 | from frappe.utils import get_site_url 9 | from frappe import _ 10 | from ...twilio_handler import Twilio 11 | 12 | class WhatsAppMessage(Document): 13 | def send(self): 14 | client = Twilio.get_twilio_client() 15 | message_dict = self.get_message_dict() 16 | response = frappe._dict() 17 | 18 | try: 19 | response = client.messages.create(**message_dict) 20 | self.sent_received = 'Sent' 21 | self.status = response.status.title() 22 | self.id = response.sid 23 | self.send_on = response.date_sent 24 | self.save(ignore_permissions=True) 25 | 26 | except Exception as e: 27 | self.db_set('status', "Error") 28 | frappe.log_error(e, title = _('Twilio WhatsApp Message Error')) 29 | 30 | def get_message_dict(self): 31 | args = { 32 | 'from_': self.from_, 33 | 'to': self.to, 34 | 'body': self.message, 35 | 'status_callback': '{}/api/method/twilio_integration.twilio_integration.api.whatsapp_message_status_callback'.format(get_site_url(frappe.local.site)) 36 | } 37 | if self.media_link: 38 | args['media_url'] = [self.media_link] 39 | 40 | return args 41 | 42 | @classmethod 43 | def send_whatsapp_message(self, receiver_list, message, doctype, docname, media=None): 44 | if isinstance(receiver_list, string_types): 45 | receiver_list = loads(receiver_list) 46 | if not isinstance(receiver_list, list): 47 | receiver_list = [receiver_list] 48 | 49 | for rec in receiver_list: 50 | message = self.store_whatsapp_message(rec, message, doctype, docname) 51 | message.send() 52 | 53 | def store_whatsapp_message(to, message, doctype=None, docname=None, media=None): 54 | sender = frappe.db.get_single_value('Twilio Settings', 'whatsapp_no') 55 | wa_msg = frappe.get_doc({ 56 | 'doctype': 'WhatsApp Message', 57 | 'from_': 'whatsapp:{}'.format(sender), 58 | 'to': 'whatsapp:{}'.format(to), 59 | 'message': message, 60 | 'reference_doctype': doctype, 61 | 'reference_document_name': docname, 62 | 'media_link': media 63 | }).insert(ignore_permissions=True) 64 | 65 | return wa_msg 66 | 67 | def incoming_message_callback(args): 68 | wa_msg = frappe.get_doc({ 69 | 'doctype': 'WhatsApp Message', 70 | 'from_': args.From, 71 | 'to': args.To, 72 | 'message': args.Body, 73 | 'profile_name': args.ProfileName, 74 | 'sent_received': args.SmsStatus.title(), 75 | 'id': args.MessageSid, 76 | 'send_on': frappe.utils.now(), 77 | 'status': 'Received' 78 | }).insert(ignore_permissions=True) 79 | 80 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_message/whatsapp_message.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2021-06-21 13:41:10.080916", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "id", 9 | "sent_received", 10 | "from_", 11 | "to", 12 | "message", 13 | "media_link", 14 | "status", 15 | "send_on", 16 | "reference_doctype", 17 | "reference_document_name", 18 | "profile_name" 19 | ], 20 | "fields": [ 21 | { 22 | "fieldname": "id", 23 | "fieldtype": "Data", 24 | "label": "ID" 25 | }, 26 | { 27 | "fieldname": "to", 28 | "fieldtype": "Data", 29 | "in_list_view": 1, 30 | "label": "To" 31 | }, 32 | { 33 | "fieldname": "message", 34 | "fieldtype": "Long Text", 35 | "label": "Message" 36 | }, 37 | { 38 | "fieldname": "status", 39 | "fieldtype": "Select", 40 | "in_list_view": 1, 41 | "label": "Status", 42 | "options": "\nQueued\nSent\nReceived\nDelivered\nRead\nUndelivered\nError" 43 | }, 44 | { 45 | "fieldname": "reference_doctype", 46 | "fieldtype": "Link", 47 | "label": "Reference DocType", 48 | "options": "DocType" 49 | }, 50 | { 51 | "fieldname": "reference_document_name", 52 | "fieldtype": "Dynamic Link", 53 | "label": "Reference Document Name", 54 | "options": "reference_doctype" 55 | }, 56 | { 57 | "fieldname": "sent_received", 58 | "fieldtype": "Select", 59 | "in_list_view": 1, 60 | "label": "Sent/Received", 61 | "options": "Sent\nReceived" 62 | }, 63 | { 64 | "fieldname": "from_", 65 | "fieldtype": "Data", 66 | "in_list_view": 1, 67 | "label": "From" 68 | }, 69 | { 70 | "fieldname": "send_on", 71 | "fieldtype": "Datetime", 72 | "label": "Send On" 73 | }, 74 | { 75 | "description": "Must be public.", 76 | "fieldname": "media_link", 77 | "fieldtype": "Data", 78 | "label": "Media Link", 79 | "options": "URL" 80 | }, 81 | { 82 | "fieldname": "profile_name", 83 | "fieldtype": "Data", 84 | "label": "Profile Name" 85 | } 86 | ], 87 | "in_create": 1, 88 | "index_web_pages_for_search": 1, 89 | "links": [], 90 | "max_attachments": 1, 91 | "modified": "2021-07-08 01:09:24.002941", 92 | "modified_by": "Administrator", 93 | "module": "Twilio Integration", 94 | "name": "WhatsApp Message", 95 | "owner": "Administrator", 96 | "permissions": [ 97 | { 98 | "create": 1, 99 | "delete": 1, 100 | "email": 1, 101 | "export": 1, 102 | "print": 1, 103 | "read": 1, 104 | "report": 1, 105 | "role": "System Manager", 106 | "select": 1, 107 | "share": 1, 108 | "write": 1 109 | } 110 | ], 111 | "sort_field": "modified", 112 | "sort_order": "DESC", 113 | "track_changes": 1 114 | } -------------------------------------------------------------------------------- /twilio_integration/fixtures/custom_field.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "allow_in_quick_entry": 0, 4 | "allow_on_submit": 0, 5 | "bold": 0, 6 | "collapsible": 0, 7 | "collapsible_depends_on": null, 8 | "columns": 0, 9 | "default": null, 10 | "depends_on": null, 11 | "description": null, 12 | "docstatus": 0, 13 | "doctype": "Custom Field", 14 | "dt": "Voice Call Settings", 15 | "fetch_from": null, 16 | "fetch_if_empty": 0, 17 | "fieldname": "twilio_number", 18 | "fieldtype": "Select", 19 | "hidden": 0, 20 | "hide_border": 0, 21 | "hide_days": 0, 22 | "hide_seconds": 0, 23 | "ignore_user_permissions": 0, 24 | "ignore_xss_filter": 0, 25 | "in_global_search": 0, 26 | "in_list_view": 0, 27 | "in_preview": 0, 28 | "in_standard_filter": 0, 29 | "insert_after": "call_receiving_device", 30 | "label": "Twilio Number", 31 | "length": 0, 32 | "mandatory_depends_on": null, 33 | "modified": "2020-12-09 08:06:16.330327", 34 | "name": "Voice Call Settings-twilio_number", 35 | "no_copy": 0, 36 | "non_negative": 0, 37 | "options": null, 38 | "parent": null, 39 | "parentfield": null, 40 | "parenttype": null, 41 | "permlevel": 0, 42 | "precision": "", 43 | "print_hide": 0, 44 | "print_hide_if_no_value": 0, 45 | "print_width": null, 46 | "read_only": 0, 47 | "read_only_depends_on": null, 48 | "report_hide": 0, 49 | "reqd": 0, 50 | "search_index": 0, 51 | "translatable": 1, 52 | "unique": 0, 53 | "width": null 54 | }, 55 | { 56 | "allow_in_quick_entry": 0, 57 | "allow_on_submit": 0, 58 | "bold": 0, 59 | "collapsible": 0, 60 | "collapsible_depends_on": null, 61 | "columns": 0, 62 | "default": null, 63 | "depends_on": "eval: doc.channel==='WhatsApp'", 64 | "description": "To use WhatsApp for Business, initialize Twilio Settings.", 65 | "docstatus": 0, 66 | "doctype": "Custom Field", 67 | "dt": "Notification", 68 | "fetch_from": null, 69 | "fetch_if_empty": 0, 70 | "fieldname": "twilio_number", 71 | "fieldtype": "Link", 72 | "hidden": 0, 73 | "hide_border": 0, 74 | "hide_days": 0, 75 | "hide_seconds": 0, 76 | "ignore_user_permissions": 0, 77 | "ignore_xss_filter": 0, 78 | "in_global_search": 0, 79 | "in_list_view": 0, 80 | "in_preview": 0, 81 | "in_standard_filter": 0, 82 | "insert_after": "slack_webhook_url", 83 | "label": "Twilio Number", 84 | "length": 0, 85 | "mandatory_depends_on": "eval: doc.channel==='WhatsApp'", 86 | "modified": "2020-10-26 11:05:12.231781", 87 | "name": "Notification-twilio_number", 88 | "no_copy": 0, 89 | "non_negative": 0, 90 | "options": "Communication Medium", 91 | "parent": null, 92 | "parentfield": null, 93 | "parenttype": null, 94 | "permlevel": 0, 95 | "precision": "", 96 | "print_hide": 0, 97 | "print_hide_if_no_value": 0, 98 | "print_width": null, 99 | "read_only": 0, 100 | "read_only_depends_on": null, 101 | "report_hide": 0, 102 | "reqd": 0, 103 | "search_index": 0, 104 | "translatable": 0, 105 | "unique": 0, 106 | "width": null 107 | } 108 | ] -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2021, Frappe and contributors 2 | # For license information, please see license.txt 3 | 4 | import frappe 5 | from frappe.model.document import Document 6 | from frappe.utils import get_site_url 7 | from twilio_integration.twilio_integration.doctype.whatsapp_message.whatsapp_message import WhatsAppMessage 8 | 9 | supported_file_ext = ['jpg', 10 | 'jpeg', 11 | 'png', 12 | 'mp3', 13 | 'ogg', 14 | 'amr', 15 | 'pdf', 16 | 'mp4' 17 | ] 18 | 19 | class WhatsAppCampaign(Document): 20 | def validate(self): 21 | if self.scheduled_time and self.status != 'Completed': 22 | current_time = frappe.utils.now_datetime() 23 | scheduled_time = frappe.utils.get_datetime(self.scheduled_time) 24 | 25 | if scheduled_time < current_time: 26 | frappe.throw(_("Scheduled Time must be a future time.")) 27 | 28 | self.status = 'Scheduled' 29 | 30 | self.all_missing_recipients() 31 | 32 | def validate_attachment(self): 33 | attachment = self.get_attachment() 34 | if attachment: 35 | if attachment.file_size > 16777216: 36 | frappe.throw(_('Attachment size must be less than 16MB.')) 37 | 38 | if attachment.is_private: 39 | frappe.throw(_('Attachment must be public.')) 40 | 41 | if attachment.get_extension() not in supported_file_ext: 42 | frappe.throw(_('Attachment format not supported.')) 43 | 44 | def get_attachment(self): 45 | file = frappe.db.get_value("File", {"attached_to_name": self.doctype, "attached_to_doctype": self.name, "is_private":0}, 'name') 46 | 47 | if file: 48 | return frappe.get_doc('File', file) 49 | return None 50 | 51 | def get_whatsapp_contact(self): 52 | contacts = [recipient.whatsapp_no for recipient in self.recipients if recipient.whatsapp_no] 53 | 54 | return contacts 55 | 56 | def all_missing_recipients(self): 57 | for recipient in self.recipients: 58 | if not recipient.whatsapp_no: 59 | recipient.whatsapp_no = frappe.db.get_value(recipient.campaign_for, recipient.recipient, 'whatsapp_no') 60 | 61 | self.total_participants = len(self.recipients) 62 | 63 | @frappe.whitelist() 64 | def get_doctype_list(self): 65 | standard_doctype = frappe.db.sql_list("""SELECT dt.parent FROM `tabDocField` 66 | df INNER JOIN `tabDoctype` dt ON dt.name = dt.parent 67 | WHERE df.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""") 68 | 69 | custom_doctype = frappe.db.sql_list("""SELECT dt FROM `tabCustom Field` 70 | cf INNER JOIN `tabDoctype` dt ON dt.name = cf.dt 71 | WHERE cf.fieldname='whatsapp_no' AND dt.istable = 0 AND dt.issingle = 0 AND dt.is_tree = 0""") 72 | 73 | return standard_doctype + custom_doctype 74 | 75 | @frappe.whitelist() 76 | def send_now(self): 77 | self.validate_attachment() 78 | media = self.get_attachment() 79 | self.db_set('status', 'In Progress') 80 | if media: 81 | media = get_site_url(frappe.local.site) + media.file_url 82 | 83 | WhatsAppMessage.send_whatsapp_message( 84 | receiver_list = self.get_whatsapp_contact(), 85 | message = self.message, 86 | doctype = self.doctype, 87 | docname = self.name, 88 | media = media 89 | ) 90 | 91 | self.db_set('status', 'Completed') 92 | 93 | 94 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "creation": "2020-01-28 15:21:44.457163", 4 | "doctype": "DocType", 5 | "editable_grid": 1, 6 | "engine": "InnoDB", 7 | "field_order": [ 8 | "account_sid", 9 | "enabled", 10 | "column_break_3", 11 | "auth_token", 12 | "record_calls", 13 | "whatsapp_section", 14 | "whatsapp_no", 15 | "column_break_8", 16 | "reply_message", 17 | "section_break_6", 18 | "api_key", 19 | "api_secret", 20 | "column_break_9", 21 | "twiml_sid", 22 | "outgoing_voice_medium" 23 | ], 24 | "fields": [ 25 | { 26 | "default": "0", 27 | "fieldname": "enabled", 28 | "fieldtype": "Check", 29 | "label": "Enabled" 30 | }, 31 | { 32 | "fieldname": "account_sid", 33 | "fieldtype": "Data", 34 | "label": "Account SID", 35 | "mandatory_depends_on": "eval: doc.enabled" 36 | }, 37 | { 38 | "fieldname": "auth_token", 39 | "fieldtype": "Password", 40 | "label": "Auth Token", 41 | "mandatory_depends_on": "eval: doc.enabled" 42 | }, 43 | { 44 | "fieldname": "api_key", 45 | "fieldtype": "Data", 46 | "label": "API Key", 47 | "permlevel": 1 48 | }, 49 | { 50 | "fieldname": "api_secret", 51 | "fieldtype": "Password", 52 | "label": "API Secret", 53 | "permlevel": 1 54 | }, 55 | { 56 | "fieldname": "twiml_sid", 57 | "fieldtype": "Data", 58 | "label": "TwiML SID", 59 | "permlevel": 1 60 | }, 61 | { 62 | "fieldname": "column_break_3", 63 | "fieldtype": "Column Break" 64 | }, 65 | { 66 | "default": "1", 67 | "fieldname": "record_calls", 68 | "fieldtype": "Check", 69 | "label": "Record Calls" 70 | }, 71 | { 72 | "fieldname": "outgoing_voice_medium", 73 | "fieldtype": "Link", 74 | "label": "Outgoing Voice Medium", 75 | "options": "Communication Medium", 76 | "permlevel": 1 77 | }, 78 | { 79 | "fieldname": "section_break_6", 80 | "fieldtype": "Section Break" 81 | }, 82 | { 83 | "fieldname": "column_break_9", 84 | "fieldtype": "Column Break" 85 | }, 86 | { 87 | "fieldname": "whatsapp_section", 88 | "fieldtype": "Section Break", 89 | "label": "WhatsApp" 90 | }, 91 | { 92 | "fieldname": "whatsapp_no", 93 | "fieldtype": "Data", 94 | "label": "Number", 95 | "options": "Phone" 96 | }, 97 | { 98 | "fieldname": "column_break_8", 99 | "fieldtype": "Column Break" 100 | }, 101 | { 102 | "fieldname": "reply_message", 103 | "fieldtype": "Small Text", 104 | "label": "Reply Message", 105 | "mandatory_depends_on": "whatsapp_no" 106 | } 107 | ], 108 | "index_web_pages_for_search": 1, 109 | "issingle": 1, 110 | "links": [], 111 | "modified": "2021-07-08 00:46:42.317641", 112 | "modified_by": "Administrator", 113 | "module": "Twilio Integration", 114 | "name": "Twilio Settings", 115 | "owner": "Administrator", 116 | "permissions": [ 117 | { 118 | "create": 1, 119 | "delete": 1, 120 | "email": 1, 121 | "print": 1, 122 | "read": 1, 123 | "role": "System Manager", 124 | "share": 1, 125 | "write": 1 126 | } 127 | ], 128 | "sort_field": "modified", 129 | "sort_order": "DESC" 130 | } -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/whatsapp_campaign/whatsapp_campaign.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions": [], 3 | "autoname": "format:WA-CAMP-{YYYY}-{#####}", 4 | "creation": "2021-06-24 14:58:40.982664", 5 | "doctype": "DocType", 6 | "editable_grid": 1, 7 | "engine": "InnoDB", 8 | "field_order": [ 9 | "campaign", 10 | "scheduled_time", 11 | "column_break_2", 12 | "status", 13 | "module", 14 | "section_break_4", 15 | "condition", 16 | "recipients", 17 | "messge_section", 18 | "template_name", 19 | "message", 20 | "more_information_section", 21 | "send_on", 22 | "column_break_12", 23 | "total_participants" 24 | ], 25 | "fields": [ 26 | { 27 | "fieldname": "template_name", 28 | "fieldtype": "Link", 29 | "in_list_view": 1, 30 | "label": "Template Name", 31 | "options": "WhatsApp Message Template", 32 | "reqd": 1 33 | }, 34 | { 35 | "fieldname": "module", 36 | "fieldtype": "Link", 37 | "label": "Module", 38 | "options": "Module Def" 39 | }, 40 | { 41 | "fetch_from": "template_name.message", 42 | "fetch_if_empty": 1, 43 | "fieldname": "message", 44 | "fieldtype": "Text", 45 | "in_list_view": 1, 46 | "label": "Message", 47 | "reqd": 1 48 | }, 49 | { 50 | "fieldname": "send_on", 51 | "fieldtype": "Datetime", 52 | "label": "Send On" 53 | }, 54 | { 55 | "fieldname": "condition", 56 | "fieldtype": "Code", 57 | "label": "Condition", 58 | "options": "JSON" 59 | }, 60 | { 61 | "fieldname": "total_participants", 62 | "fieldtype": "Int", 63 | "label": "Total Participants" 64 | }, 65 | { 66 | "fieldname": "campaign", 67 | "fieldtype": "Link", 68 | "label": "Campaign", 69 | "options": "Campaign" 70 | }, 71 | { 72 | "fieldname": "column_break_2", 73 | "fieldtype": "Column Break" 74 | }, 75 | { 76 | "fieldname": "section_break_4", 77 | "fieldtype": "Section Break" 78 | }, 79 | { 80 | "fieldname": "recipients", 81 | "fieldtype": "Table", 82 | "label": "Recipients", 83 | "options": "WhatsApp Campaign Recipient", 84 | "reqd": 1 85 | }, 86 | { 87 | "fieldname": "messge_section", 88 | "fieldtype": "Section Break", 89 | "label": "Messge" 90 | }, 91 | { 92 | "fieldname": "more_information_section", 93 | "fieldtype": "Section Break", 94 | "label": "More Information" 95 | }, 96 | { 97 | "fieldname": "column_break_12", 98 | "fieldtype": "Column Break" 99 | }, 100 | { 101 | "fieldname": "status", 102 | "fieldtype": "Select", 103 | "label": "Status", 104 | "options": "\nScheduled\nIn Progress\nCompleted" 105 | }, 106 | { 107 | "fieldname": "scheduled_time", 108 | "fieldtype": "Datetime", 109 | "label": "Scheduled Time" 110 | } 111 | ], 112 | "index_web_pages_for_search": 1, 113 | "links": [], 114 | "modified": "2021-07-14 11:40:59.599233", 115 | "modified_by": "Administrator", 116 | "module": "Twilio Integration", 117 | "name": "WhatsApp Campaign", 118 | "owner": "Administrator", 119 | "permissions": [ 120 | { 121 | "create": 1, 122 | "delete": 1, 123 | "email": 1, 124 | "export": 1, 125 | "print": 1, 126 | "read": 1, 127 | "report": 1, 128 | "role": "System Manager", 129 | "share": 1, 130 | "write": 1 131 | } 132 | ], 133 | "sort_field": "modified", 134 | "sort_order": "DESC", 135 | "track_changes": 1 136 | } -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/doctype/twilio_settings/twilio_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2020, Frappe Technologies and contributors 3 | # For license information, please see license.txt 4 | 5 | from __future__ import unicode_literals 6 | import frappe 7 | from frappe.model.document import Document 8 | from frappe import _ 9 | from frappe.utils.password import get_decrypted_password 10 | 11 | from six import string_types 12 | import re 13 | from json import loads, dumps 14 | from random import randrange 15 | 16 | from twilio.rest import Client 17 | from ...utils import get_public_url 18 | 19 | class TwilioSettings(Document): 20 | friendly_resource_name = "ERPNext" # System creates TwiML app & API keys with this name. 21 | 22 | def validate(self): 23 | self.validate_twilio_account() 24 | 25 | def on_update(self): 26 | # Single doctype records are created in DB at time of installation and those field values are set as null. 27 | # This condition make sure that we handle null. 28 | if not self.account_sid: 29 | return 30 | 31 | twilio = Client(self.account_sid, self.get_password("auth_token")) 32 | self.set_api_credentials(twilio) 33 | self.set_application_credentials(twilio) 34 | self.reload() 35 | 36 | def validate_twilio_account(self): 37 | try: 38 | twilio = Client(self.account_sid, self.get_password("auth_token")) 39 | twilio.api.accounts(self.account_sid).fetch() 40 | return twilio 41 | except Exception: 42 | frappe.throw(_("Invalid Account SID or Auth Token.")) 43 | 44 | def set_api_credentials(self, twilio): 45 | """Generate Twilio API credentials if not exist and update them. 46 | """ 47 | if self.api_key and self.api_secret: 48 | return 49 | new_key = self.create_api_key(twilio) 50 | self.api_key = new_key.sid 51 | self.api_secret = new_key.secret 52 | frappe.db.set_value('Twilio Settings', 'Twilio Settings', { 53 | 'api_key': self.api_key, 54 | 'api_secret': self.api_secret 55 | }) 56 | 57 | def set_application_credentials(self, twilio): 58 | """Generate TwiML app credentials if not exist and update them. 59 | """ 60 | credentials = self.get_application(twilio) or self.create_application(twilio) 61 | self.twiml_sid = credentials.sid 62 | frappe.db.set_value('Twilio Settings', 'Twilio Settings', 'twiml_sid', self.twiml_sid) 63 | 64 | def create_api_key(self, twilio): 65 | """Create API keys in twilio account. 66 | """ 67 | try: 68 | return twilio.new_keys.create(friendly_name=self.friendly_resource_name) 69 | except Exception: 70 | frappe.log_error(title=_("Twilio API credential creation error.")) 71 | frappe.throw(_("Twilio API credential creation error.")) 72 | 73 | def get_twilio_voice_url(self): 74 | url_path = "/api/method/twilio_integration.twilio_integration.api.voice" 75 | return get_public_url(url_path) 76 | 77 | def get_application(self, twilio, friendly_name=None): 78 | """Get TwiML App from twilio account if exists. 79 | """ 80 | friendly_name = friendly_name or self.friendly_resource_name 81 | applications = twilio.applications.list(friendly_name) 82 | return applications and applications[0] 83 | 84 | def create_application(self, twilio, friendly_name=None): 85 | """Create TwilML App in twilio account. 86 | """ 87 | friendly_name = friendly_name or self.friendly_resource_name 88 | application = twilio.applications.create( 89 | voice_method='POST', 90 | voice_url=self.get_twilio_voice_url(), 91 | friendly_name=friendly_name 92 | ) 93 | return application -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Twilio Integration 2 | 3 | Custom Frappe Application for Twilio Integration 4 | 5 | ## Features 6 | - Users can make outgoing voice calls by clicking at the phone icon that shows up next to the phone number. 7 | - Users can receive Incoming calls either in Phone or in browser. 8 | - Users can send notifications through whatsapp. 9 | 10 | ### Authorize twilio 11 | To authorise Twilio integration go to Twilio Settings Doctype and provide `Account SID` and `Auth Token`. Make sure Twilio Settings are enabled to use Twilio in the system. 12 | 13 | Twilio Settings 14 | 15 | ### Voice Call 16 | 17 | Every user(Agent) needs to have voice call settings to make or receive calls. User can use Twilio features once `Voice Call Settings` created. 18 | 19 | In voice call settings 20 | 1. choose the `Call Receiving Device` as `Computer` to receive calls in the browser or `Phone` to receive calls in your Phone(Make sure that `Mobile No` is configured in `My Settings` section to receive calls in your phone). 21 | 2. Choose your twilio number from `Twilio Number` dropdown field. This is going to be your twilio communication number. 22 | 23 | Twilio Voice Call Settings 24 | 25 | #### Outgoing Calls 26 | 27 | Once the user clicks the phone icon next to the phone number, the user will see a phone popup with a call button. Make sure that area code is included in the phone number(ex: +91) before using the call button to make outgoing calls. 28 | 29 | Twilio Outgoing Call Popup 30 | 31 | #### Incoming Calls 32 | 33 | When an incoming call comes, the call will be redirected to phone or to browser depending on user voice call Settings. Here is how it looks when call comes to browser. 34 | 35 | Twilio Incoming Call Popup 36 | 37 | ### WhatsApp message 38 | 39 | Users can send notifications through WhatsApp channel. While creating/editing a notification please Select Channel as `WhatsApp` and Twilio number from dropdown if you want to send whatsapp notification. 40 | 41 | Twilio Whatsapp notification 42 | 43 | 44 | ## Development 45 | 46 | ### Pre-requisites 47 | - [ERPNext](https://docs.erpnext.com/docs/user/manual/en/introduction/getting-started-with-erpnext#4-install-erpnext-on-your-unixlinuxmac-machine 48 | ) 49 | - Twilio account (More details from `Configure Twilio` section) 50 | 51 | ### Configure Twilio 52 | * You need to create a [new project](https://www.twilio.com/console/projects/create) in [Twilio account](https://www.twilio.com/) to use communication features like whatsapp, voice calls etc through ERPNext. 53 | 54 | #### Twilio For Voice Calls 55 | * From your Twilio console you can go to a [programmable voice](https://www.twilio.com/console/voice/dashboard) section and get a Twilio number to use for calls(make sure that the number is voice capable). 56 | * Create a [TwiML App](https://www.twilio.com/console/voice/twiml/apps/create) by passing voice request url as `you_project_domain_name/api/method/twilio_integration.twilio_integration.doctype.twilio_settings.twilio_settings.voice` . Use `ngrok` to get public domain for your project in case it is running locally. 57 | 58 | #### Twilio For Whatsapp 59 | * From your Twilio console you can go to a [Programmable Messaging](https://www.twilio.com/console/sms/dashboard) section and get a Twilio number to use for whatsapp. 60 | 61 | 62 | ### How To Setup 63 | Once you have created a site with ERPNext app installed, you can download and install twilio-integration app using 64 | 65 | ``` 66 | bench get-app https://github.com/frappe/twilio-integration.git 67 | bench --site site_name install-app twilio_integration 68 | ``` 69 | 70 | Use `bench start` command to run the project. 71 | 72 | #### Configure Twilio Settings Through Project's Admin Interface 73 | * For Whatsapp communication you need to set `Account SID` and `Auth Token` in `Twilio Settings`(You can get those from Twilio console). 74 | * For calls you need to pass `TwiML SID`(Get it from TwiML app you have created) and `Outgoing Voice Medium` along with `Account SID` and `Auth Token` in `Twilio Settings` 75 | * Make sure that you enabled twilio by clicking `Enabled` checkbox in settings. 76 | 77 | NOTE: While creating a new `communication medium` for `Outgoing Voice Medium` pass twilio number(including area code(ex:+91)) as Name, `Twilio` as communication channel and `Voice` as Communication Medium Type. 78 | 79 | 80 | #### License 81 | 82 | MIT 83 | 84 | -------------------------------------------------------------------------------- /twilio_integration/hooks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | from . import __version__ as app_version 4 | 5 | app_name = "twilio_integration" 6 | app_title = "Twilio Integration" 7 | app_publisher = "Frappe" 8 | app_description = "Custom Frappe Application for Twilio Integration" 9 | app_icon = "octicon octicon-file-directory" 10 | app_color = "grey" 11 | app_email = "developers@frappe.io" 12 | app_license = "MIT" 13 | fixtures = [{"dt": "Custom Field", "filters": [ 14 | [ 15 | "name", "in", [ 16 | "Notification-twilio_number", "Voice Call Settings-twilio_number" 17 | ] 18 | ] 19 | ]} 20 | , "Property Setter"] 21 | 22 | # Includes in 23 | # ------------------ 24 | 25 | # include js, css files in header of desk.html 26 | app_include_css = "/assets/twilio_integration/css/twilio_call_handler.css" 27 | app_include_js = "/assets/twilio_integration/js/twilio_call_handler.js" 28 | 29 | # include js, css files in header of web template 30 | # web_include_css = "/assets/twilio_integration/css/twilio_integration.css" 31 | # web_include_js = "/assets/twilio_integration/js/twilio_integration.js" 32 | 33 | # include js, css files in header of web form 34 | # webform_include_js = {"doctype": "public/js/doctype.js"} 35 | # webform_include_css = {"doctype": "public/css/doctype.css"} 36 | 37 | # include js in page 38 | # page_js = {"page" : "public/js/file.js"} 39 | 40 | # include js in doctype views 41 | # doctype_js = {"doctype" : "public/js/doctype.js"} 42 | # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} 43 | # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} 44 | # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} 45 | doctype_js = { 46 | "Notification" : "public/js/Notification.js", 47 | "Voice Call Settings": "public/js/voice_call_settings.js" 48 | } 49 | 50 | # Home Pages 51 | # ---------- 52 | 53 | # application home page (will override Website Settings) 54 | # home_page = "login" 55 | 56 | # website user home page (by Role) 57 | # role_home_page = { 58 | # "Role": "home_page" 59 | # } 60 | 61 | # Generators 62 | # ---------- 63 | 64 | # automatically create page for each record of this doctype 65 | # website_generators = ["Web Page"] 66 | 67 | # Installation 68 | # ------------ 69 | 70 | # before_install = "twilio_integration.install.before_install" 71 | # after_install = "twilio_integration.install.after_install" 72 | 73 | # Desk Notifications 74 | # ------------------ 75 | # See frappe.core.notifications.get_notification_config 76 | 77 | # notification_config = "twilio_integration.notifications.get_notification_config" 78 | 79 | # Permissions 80 | # ----------- 81 | # Permissions evaluated in scripted ways 82 | 83 | # permission_query_conditions = { 84 | # "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions", 85 | # } 86 | # 87 | # has_permission = { 88 | # "Event": "frappe.desk.doctype.event.event.has_permission", 89 | # } 90 | 91 | # Document Events 92 | # --------------- 93 | # Hook on document methods and events 94 | 95 | # doc_events = { 96 | # "*": { 97 | # "on_update": "method", 98 | # "on_cancel": "method", 99 | # "on_trash": "method" 100 | # } 101 | # } 102 | 103 | # Scheduled Tasks 104 | # --------------- 105 | 106 | # scheduler_events = { 107 | # "all": [ 108 | # "twilio_integration.tasks.all" 109 | # ], 110 | # "daily": [ 111 | # "twilio_integration.tasks.daily" 112 | # ], 113 | # "hourly": [ 114 | # "twilio_integration.tasks.hourly" 115 | # ], 116 | # "weekly": [ 117 | # "twilio_integration.tasks.weekly" 118 | # ] 119 | # "monthly": [ 120 | # "twilio_integration.tasks.monthly" 121 | # ] 122 | # } 123 | 124 | # Testing 125 | # ------- 126 | 127 | # before_tests = "twilio_integration.install.before_tests" 128 | 129 | # Overriding Methods 130 | # ------------------------------ 131 | # 132 | # override_whitelisted_methods = { 133 | # "frappe.desk.doctype.event.event.get_events": "twilio_integration.event.get_events" 134 | # } 135 | # 136 | # each overriding function accepts a `data` argument; 137 | # generated from the base implementation of the doctype dashboard, 138 | # along with any modifications made in other Frappe apps 139 | # override_doctype_dashboards = { 140 | # "Task": "twilio_integration.task.get_dashboard_data" 141 | # } 142 | 143 | # exempt linked doctypes from being automatically cancelled 144 | # 145 | # auto_cancel_exempted_doctypes = ["Auto Repeat"] 146 | 147 | override_doctype_class = { 148 | "Notification": "twilio_integration.overrides.notification.SendNotification" 149 | } 150 | 151 | # boot 152 | # ---------- 153 | boot_session = "twilio_integration.boot.boot_session" 154 | -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/api.py: -------------------------------------------------------------------------------- 1 | from werkzeug.wrappers import Response 2 | 3 | import frappe 4 | from frappe import _ 5 | from frappe.contacts.doctype.contact.contact import get_contact_with_phone_number 6 | from .twilio_handler import Twilio, IncomingCall, TwilioCallDetails 7 | from twilio_integration.twilio_integration.doctype.whatsapp_message.whatsapp_message import incoming_message_callback 8 | from twilio.twiml.messaging_response import MessagingResponse 9 | 10 | @frappe.whitelist() 11 | def get_twilio_phone_numbers(): 12 | twilio = Twilio.connect() 13 | return (twilio and twilio.get_phone_numbers()) or [] 14 | 15 | @frappe.whitelist() 16 | def generate_access_token(): 17 | """Returns access token that is required to authenticate Twilio Client SDK. 18 | """ 19 | twilio = Twilio.connect() 20 | if not twilio: 21 | return {} 22 | 23 | from_number = frappe.db.get_value('Voice Call Settings', frappe.session.user, 'twilio_number') 24 | if not from_number: 25 | return { 26 | "ok": False, 27 | "error": "caller_phone_identity_missing", 28 | "detail": "Phone number is not mapped to the caller" 29 | } 30 | 31 | token=twilio.generate_voice_access_token(from_number=from_number, identity=frappe.session.user) 32 | return { 33 | 'token': frappe.safe_decode(token) 34 | } 35 | 36 | @frappe.whitelist(allow_guest=True) 37 | def voice(**kwargs): 38 | """This is a webhook called by twilio to get instructions when the voice call request comes to twilio server. 39 | """ 40 | def _get_caller_number(caller): 41 | identity = caller.replace('client:', '').strip() 42 | user = Twilio.emailid_from_identity(identity) 43 | return frappe.db.get_value('Voice Call Settings', user, 'twilio_number') 44 | 45 | args = frappe._dict(kwargs) 46 | twilio = Twilio.connect() 47 | if not twilio: 48 | return 49 | 50 | assert args.AccountSid == twilio.account_sid 51 | assert args.ApplicationSid == twilio.application_sid 52 | 53 | # Generate TwiML instructions to make a call 54 | from_number = _get_caller_number(args.Caller) 55 | resp = twilio.generate_twilio_dial_response(from_number, args.To) 56 | 57 | call_details = TwilioCallDetails(args, call_from=from_number) 58 | create_call_log(call_details) 59 | return Response(resp.to_xml(), mimetype='text/xml') 60 | 61 | @frappe.whitelist(allow_guest=True) 62 | def twilio_incoming_call_handler(**kwargs): 63 | args = frappe._dict(kwargs) 64 | call_details = TwilioCallDetails(args) 65 | create_call_log(call_details) 66 | 67 | resp = IncomingCall(args.From, args.To).process() 68 | return Response(resp.to_xml(), mimetype='text/xml') 69 | 70 | @frappe.whitelist() 71 | def create_call_log(call_details: TwilioCallDetails): 72 | call_log = frappe.get_doc({**call_details.to_dict(), 73 | 'doctype': 'Call Log', 74 | 'medium': 'Twilio' 75 | }) 76 | 77 | call_log.flags.ignore_permissions = True 78 | call_log.save() 79 | frappe.db.commit() 80 | 81 | @frappe.whitelist() 82 | def update_call_log(call_sid, status=None): 83 | """Update call log status. 84 | """ 85 | twilio = Twilio.connect() 86 | if not (twilio and frappe.db.exists("Call Log", call_sid)): return 87 | 88 | call_details = twilio.get_call_info(call_sid) 89 | call_log = frappe.get_doc("Call Log", call_sid) 90 | call_log.status = status or TwilioCallDetails.get_call_status(call_details.status) 91 | call_log.duration = call_details.duration 92 | call_log.flags.ignore_permissions = True 93 | call_log.save() 94 | frappe.db.commit() 95 | 96 | @frappe.whitelist(allow_guest=True) 97 | def update_recording_info(**kwargs): 98 | try: 99 | args = frappe._dict(kwargs) 100 | recording_url = args.RecordingUrl 101 | call_sid = args.CallSid 102 | update_call_log(call_sid) 103 | frappe.db.set_value("Call Log", call_sid, "recording_url", recording_url) 104 | except: 105 | frappe.log_error(title=_("Failed to capture Twilio recording")) 106 | 107 | @frappe.whitelist() 108 | def get_contact_details(phone): 109 | """Get information about existing contact in the system. 110 | """ 111 | contact = get_contact_with_phone_number(phone.strip()) 112 | if not contact: return 113 | contact_doc = frappe.get_doc('Contact', contact) 114 | return contact_doc and { 115 | 'first_name': contact_doc.first_name.title(), 116 | 'email_id': contact_doc.email_id, 117 | 'phone_number': contact_doc.phone 118 | } 119 | 120 | @frappe.whitelist(allow_guest=True) 121 | def incoming_whatsapp_message_handler(**kwargs): 122 | """This is a webhook called by Twilio when a WhatsApp message is received. 123 | """ 124 | args = frappe._dict(kwargs) 125 | incoming_message_callback(args) 126 | resp = MessagingResponse() 127 | 128 | # Add a message 129 | resp.message(frappe.db.get_single_value('Twilio Settings', 'reply_message')) 130 | return Response(resp.to_xml(), mimetype='text/xml') 131 | 132 | @frappe.whitelist(allow_guest=True) 133 | def whatsapp_message_status_callback(**kwargs): 134 | """This is a webhook called by Twilio whenever sent WhatsApp message status is changed. 135 | """ 136 | args = frappe._dict(kwargs) 137 | if frappe.db.exists({'doctype': 'WhatsApp Message', 'id': args.MessageSid, 'from_': args.From, 'to': args.To}): 138 | message = frappe.get_doc('WhatsApp Message', {'id': args.MessageSid, 'from_': args.From, 'to': args.To}) 139 | message.db_set('status', args.MessageStatus.title()) -------------------------------------------------------------------------------- /twilio_integration/twilio_integration/twilio_handler.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | from twilio.rest import Client as TwilioClient 4 | from twilio.jwt.access_token import AccessToken 5 | from twilio.jwt.access_token.grants import VoiceGrant 6 | from twilio.twiml.voice_response import VoiceResponse, Dial 7 | 8 | import frappe 9 | from frappe import _ 10 | from frappe.utils.password import get_decrypted_password 11 | from .utils import get_public_url, merge_dicts 12 | 13 | class Twilio: 14 | """Twilio connector over TwilioClient. 15 | """ 16 | def __init__(self, settings): 17 | """ 18 | :param settings: `Twilio Settings` doctype 19 | """ 20 | self.settings = settings 21 | self.account_sid = settings.account_sid 22 | self.application_sid = settings.twiml_sid 23 | self.api_key = settings.api_key 24 | self.api_secret = settings.get_password("api_secret") 25 | self.twilio_client = self.get_twilio_client() 26 | 27 | @classmethod 28 | def connect(self): 29 | """Make a twilio connection. 30 | """ 31 | settings = frappe.get_doc("Twilio Settings") 32 | if not (settings and settings.enabled): 33 | return 34 | return Twilio(settings=settings) 35 | 36 | def get_phone_numbers(self): 37 | """Get account's twilio phone numbers. 38 | """ 39 | numbers = self.twilio_client.incoming_phone_numbers.list() 40 | return [n.phone_number for n in numbers] 41 | 42 | def generate_voice_access_token(self, from_number: str, identity: str, ttl=60*60): 43 | """Generates a token required to make voice calls from the browser. 44 | """ 45 | # identity is used by twilio to identify the user uniqueness at browser(or any endpoints). 46 | identity = self.safe_identity(identity) 47 | 48 | # Create access token with credentials 49 | token = AccessToken(self.account_sid, self.api_key, self.api_secret, identity=identity, ttl=ttl) 50 | 51 | # Create a Voice grant and add to token 52 | voice_grant = VoiceGrant( 53 | outgoing_application_sid=self.application_sid, 54 | incoming_allow=True, # Allow incoming calls 55 | ) 56 | token.add_grant(voice_grant) 57 | return token.to_jwt() 58 | 59 | @classmethod 60 | def safe_identity(cls, identity: str): 61 | """Create a safe identity by replacing unsupported special charaters `@` with (at)). 62 | Twilio Client JS fails to make a call connection if identity has special characters like @, [, / etc) 63 | https://www.twilio.com/docs/voice/client/errors (#31105) 64 | """ 65 | return identity.replace('@', '(at)') 66 | 67 | @classmethod 68 | def emailid_from_identity(cls, identity: str): 69 | """Convert safe identity string into emailID. 70 | """ 71 | return identity.replace('(at)', '@') 72 | 73 | def get_recording_status_callback_url(self): 74 | url_path = "/api/method/twilio_integration.twilio_integration.api.update_recording_info" 75 | return get_public_url(url_path) 76 | 77 | def generate_twilio_dial_response(self, from_number: str, to_number: str): 78 | """Generates voice call instructions to forward the call to agents Phone. 79 | """ 80 | resp = VoiceResponse() 81 | dial = Dial( 82 | caller_id=from_number, 83 | record=self.settings.record_calls, 84 | recording_status_callback=self.get_recording_status_callback_url(), 85 | recording_status_callback_event='completed' 86 | ) 87 | dial.number(to_number) 88 | resp.append(dial) 89 | return resp 90 | 91 | def get_call_info(self, call_sid): 92 | return self.twilio_client.calls(call_sid).fetch() 93 | 94 | def generate_twilio_client_response(self, client, ring_tone='at'): 95 | """Generates voice call instructions to forward the call to agents computer. 96 | """ 97 | resp = VoiceResponse() 98 | dial = Dial( 99 | ring_tone=ring_tone, 100 | record=self.settings.record_calls, 101 | recording_status_callback=self.get_recording_status_callback_url(), 102 | recording_status_callback_event='completed' 103 | ) 104 | dial.client(client) 105 | resp.append(dial) 106 | return resp 107 | 108 | @classmethod 109 | def get_twilio_client(self): 110 | twilio_settings = frappe.get_doc("Twilio Settings") 111 | if not twilio_settings.enabled: 112 | frappe.throw(_("Please enable twilio settings before sending WhatsApp messages")) 113 | 114 | auth_token = get_decrypted_password("Twilio Settings", "Twilio Settings", 'auth_token') 115 | client = TwilioClient(twilio_settings.account_sid, auth_token) 116 | 117 | return client 118 | 119 | class IncomingCall: 120 | def __init__(self, from_number, to_number, meta=None): 121 | self.from_number = from_number 122 | self.to_number = to_number 123 | self.meta = meta 124 | 125 | def process(self): 126 | """Process the incoming call 127 | * Figure out who is going to pick the call (call attender) 128 | * Check call attender settings and forward the call to Phone 129 | """ 130 | twilio = Twilio.connect() 131 | owners = get_twilio_number_owners(self.to_number) 132 | attender = get_the_call_attender(owners) 133 | 134 | if not attender: 135 | resp = VoiceResponse() 136 | resp.say(_('Agent is unavailable to take the call, please call after some time.')) 137 | return resp 138 | 139 | if attender['call_receiving_device'] == 'Phone': 140 | return twilio.generate_twilio_dial_response(self.from_number, attender['mobile_no']) 141 | else: 142 | return twilio.generate_twilio_client_response(twilio.safe_identity(attender['name'])) 143 | 144 | class TwilioCallDetails: 145 | def __init__(self, call_info, call_from = None, call_to = None): 146 | self.call_info = call_info 147 | self.account_sid = call_info.get('AccountSid') 148 | self.application_sid = call_info.get('ApplicationSid') 149 | self.call_sid = call_info.get('CallSid') 150 | self.call_status = self.get_call_status(call_info.get('CallStatus')) 151 | self._call_from = call_from 152 | self._call_to = call_to 153 | 154 | def get_direction(self): 155 | """TODO: Check why Twilio gives always Direction as `inbound`? 156 | """ 157 | if self.call_info.get('Caller').lower().startswith('client'): 158 | return 'Outgoing' 159 | return 'Incoming' 160 | 161 | def get_from_number(self): 162 | return self._call_from or self.call_info.get('From') 163 | 164 | def get_to_number(self): 165 | return self._call_to or self.call_info.get('To') 166 | 167 | @classmethod 168 | def get_call_status(cls, twilio_status): 169 | """Convert Twilio given status into system status. 170 | """ 171 | twilio_status = twilio_status or '' 172 | return ' '.join(twilio_status.split('-')).title() 173 | 174 | def to_dict(self): 175 | return { 176 | 'type': self.get_direction(), 177 | 'status': self.call_status, 178 | 'id': self.call_sid, 179 | 'from': self.get_from_number(), 180 | 'to': self.get_to_number() 181 | } 182 | 183 | 184 | def get_twilio_number_owners(phone_number): 185 | """Get list of users who is using the phone_number. 186 | >>> get_twilio_number_owners('+11234567890') 187 | { 188 | 'owner1': {'name': '..', 'mobile_no': '..', 'call_receiving_device': '...'}, 189 | 'owner2': {....} 190 | } 191 | """ 192 | user_voice_settings = frappe.get_all( 193 | 'Voice Call Settings', 194 | filters={'twilio_number': phone_number}, 195 | fields=["name", "call_receiving_device"] 196 | ) 197 | user_wise_voice_settings = {user['name']: user for user in user_voice_settings} 198 | 199 | user_general_settings = frappe.get_all( 200 | 'User', 201 | filters = [['name', 'IN', user_wise_voice_settings.keys()]], 202 | fields = ['name', 'mobile_no'] 203 | ) 204 | user_wise_general_settings = {user['name']: user for user in user_general_settings} 205 | 206 | return merge_dicts(user_wise_general_settings, user_wise_voice_settings) 207 | 208 | 209 | def get_active_loggedin_users(users): 210 | """Filter the current loggedin users from the given users list 211 | """ 212 | rows = frappe.db.sql(""" 213 | SELECT `user` 214 | FROM `tabSessions` 215 | WHERE `user` IN %(users)s 216 | """, {'users': users}) 217 | return [row[0] for row in set(rows)] 218 | 219 | def get_the_call_attender(owners): 220 | """Get attender details from list of owners 221 | """ 222 | if not owners: return 223 | current_loggedin_users = get_active_loggedin_users(list(owners.keys())) 224 | for name, details in owners.items(): 225 | if ((details['call_receiving_device'] == 'Phone' and details['mobile_no']) or 226 | (details['call_receiving_device'] == 'Computer' and name in current_loggedin_users)): 227 | return details 228 | -------------------------------------------------------------------------------- /twilio_integration/public/js/twilio_call_handler.js: -------------------------------------------------------------------------------- 1 | var onload_script = function() { 2 | frappe.provide('frappe.phone_call'); 3 | frappe.provide('frappe.twilio_conn_dialog_map') 4 | let device; 5 | 6 | if (frappe.boot.twilio_enabled){ 7 | frappe.run_serially([ 8 | () => setup_device(), 9 | () => dialer_screen() 10 | ]); 11 | } 12 | 13 | function setup_device() { 14 | frappe.call( { 15 | method: "twilio_integration.twilio_integration.api.generate_access_token", 16 | callback: (data) => { 17 | device = new Twilio.Device(data.message.token, { 18 | codecPreferences: ["opus", "pcmu"], 19 | fakeLocalDTMF: true, 20 | enableRingingState: true, 21 | }); 22 | 23 | device.on("ready", function (device) { 24 | Object.values(frappe.twilio_conn_dialog_map).forEach(function(popup){ 25 | popup.set_header('available'); 26 | }) 27 | }); 28 | 29 | device.on("error", function (error) { 30 | Object.values(frappe.twilio_conn_dialog_map).forEach(function(popup){ 31 | popup.set_header('Failed'); 32 | }) 33 | device.disconnectAll(); 34 | console.log("Twilio Device Error:" + error.message); 35 | }); 36 | 37 | device.on("disconnect", function (conn) { 38 | update_call_log(conn); 39 | const popup = frappe.twilio_conn_dialog_map[conn]; 40 | // Reomove the connection from map object 41 | delete frappe.twilio_conn_dialog_map[conn] 42 | popup.dialog.enable_primary_action(); 43 | popup.show_close_button(); 44 | window.onbeforeunload = null; 45 | popup.set_header("available"); 46 | popup.hide_mute_button(); 47 | popup.hide_hangup_button(); 48 | popup.hide_dial_icon(); 49 | popup.hide_dialpad(); 50 | // Make sure that dialog is closed when incoming call is disconnected. 51 | if (conn.direction == 'INCOMING'){ 52 | popup.close(); 53 | } 54 | }); 55 | 56 | device.on("cancel", function () { 57 | Object.values(frappe.twilio_conn_dialog_map).forEach(function(popup){ 58 | popup.close(); 59 | }) 60 | }); 61 | 62 | device.on("connect", function (conn) { 63 | const popup = frappe.twilio_conn_dialog_map[conn]; 64 | popup.setup_mute_button(conn); 65 | popup.dialog.set_secondary_action_label("Hang Up") 66 | popup.set_header("in-progress"); 67 | window.onbeforeunload = function() { 68 | return "you can not refresh the page"; 69 | } 70 | popup.setup_dial_icon(); 71 | popup.setup_dialpad(conn); 72 | document.onkeydown = (e) => { 73 | if (popup.dialog.$wrapper.find('.dialpad-section').is(":hidden")) return; 74 | let key = e.key; 75 | if (conn.status() == 'open' && ["0","1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#", "w"].includes(key)) { 76 | conn.sendDigits(key); 77 | popup.update_dialpad_input(key); 78 | } 79 | }; 80 | }); 81 | 82 | device.on("incoming", function (conn) { 83 | console.log("Incoming connection from " + conn.parameters.From); 84 | call_screen(conn); 85 | }); 86 | 87 | } 88 | }); 89 | } 90 | 91 | function dialer_screen() { 92 | frappe.phone_call.handler = (to_number, frm) => { 93 | let to_numbers; 94 | let outgoing_call_popup; 95 | 96 | if (Array.isArray(to_number)) { 97 | to_numbers = to_number; 98 | } else { 99 | to_numbers = to_number.split('\n'); 100 | } 101 | outgoing_call_popup = new OutgoingCallPopup(device, to_numbers); 102 | outgoing_call_popup.show(); 103 | } 104 | } 105 | 106 | function update_call_log(conn, status="Completed") { 107 | if (!conn.parameters.CallSid) return 108 | frappe.call({ 109 | "method": "twilio_integration.twilio_integration.api.update_call_log", 110 | "args": { 111 | "call_sid": conn.parameters.CallSid, 112 | "status": status 113 | } 114 | }) 115 | } 116 | 117 | function call_screen(conn) { 118 | frappe.call({ 119 | type: "GET", 120 | method: "twilio_integration.twilio_integration.api.get_contact_details", 121 | args: { 122 | 'phone': conn.parameters.From 123 | }, 124 | callback: (data) => { 125 | let incoming_call_popup = new IncomingCallPopup(device, conn); 126 | incoming_call_popup.show(data.message); 127 | } 128 | }); 129 | } 130 | } 131 | 132 | function get_status_indicator(status) { 133 | const indicator_map = { 134 | 'available': 'blue', 135 | 'completed': 'blue', 136 | 'failed': 'red', 137 | 'busy': 'yellow', 138 | 'no-answer': 'orange', 139 | 'queued': 'orange', 140 | 'ringing': 'green blink', 141 | 'in-progress': 'green blink' 142 | }; 143 | const indicator_class = `indicator ${indicator_map[status] || 'blue blink'}`; 144 | return indicator_class; 145 | } 146 | 147 | class TwilioCallPopup { 148 | constructor(twilio_device) { 149 | this.twilio_device = twilio_device; 150 | } 151 | 152 | hide_hangup_button() { 153 | this.dialog.get_secondary_btn().addClass('hide'); 154 | } 155 | 156 | set_header(status) { 157 | if (!this.dialog){ 158 | return; 159 | } 160 | this.dialog.set_title(frappe.model.unscrub(status)); 161 | const indicator_class = get_status_indicator(status); 162 | this.dialog.header.find('.indicator').attr('class', `indicator ${indicator_class}`); 163 | } 164 | 165 | setup_mute_button(twilio_conn) { 166 | let me = this; 167 | let mute_button = me.dialog.custom_actions.find('.btn-mute'); 168 | mute_button.removeClass('hide'); 169 | mute_button.on('click', function (event) { 170 | if ($(this).text().trim() == 'Mute') { 171 | twilio_conn.mute(true); 172 | $(this).html('Unmute'); 173 | } 174 | else { 175 | twilio_conn.mute(false); 176 | $(this).html('Mute'); 177 | } 178 | }); 179 | } 180 | 181 | hide_mute_button() { 182 | let mute_button = this.dialog.custom_actions.find('.btn-mute'); 183 | mute_button.addClass('hide'); 184 | } 185 | 186 | show_close_button() { 187 | this.dialog.get_close_btn().show(); 188 | } 189 | 190 | close() { 191 | this.dialog.cancel(); 192 | } 193 | 194 | setup_dialpad(conn) { 195 | let me = this; 196 | this.dialpad = new DialPad({ 197 | twilio_device: this.twilio_device, 198 | wrapper: me.dialog.$wrapper.find('.dialpad-section'), 199 | events: { 200 | dialpad_event: function($btn) { 201 | const button_value = $btn.attr('data-button-value'); 202 | conn.sendDigits(button_value); 203 | me.update_dialpad_input(button_value); 204 | } 205 | }, 206 | cols: 5, 207 | keys: [ 208 | [ 1, 2, 3 ], 209 | [ 4, 5, 6 ], 210 | [ 7, 8, 9 ], 211 | [ '*', 0, '#' ] 212 | ] 213 | }) 214 | } 215 | 216 | update_dialpad_input(key) { 217 | let dialpad_input = this.dialog.$wrapper.find('.dialpad-input')[0]; 218 | dialpad_input.value += key; 219 | } 220 | 221 | setup_dial_icon() { 222 | let me = this; 223 | let dialpad_icon = this.dialog.$wrapper.find('.dialpad-icon'); 224 | dialpad_icon.removeClass('hide'); 225 | dialpad_icon.on('click', function (event) { 226 | let dialpad_section = me.dialog.$wrapper.find('.dialpad-section'); 227 | if(dialpad_section.hasClass('hide')) { 228 | me.show_dialpad(); 229 | } 230 | else { 231 | me.hide_dialpad(); 232 | } 233 | }); 234 | } 235 | 236 | hide_dial_icon() { 237 | let dial_icon = this.dialog.$wrapper.find('.dialpad-icon'); 238 | dial_icon.addClass('hide'); 239 | } 240 | 241 | show_dialpad() { 242 | let dialpad_section = this.dialog.$wrapper.find('.dialpad-section'); 243 | dialpad_section.removeClass('hide'); 244 | } 245 | 246 | hide_dialpad() { 247 | let dialpad_section = this.dialog.$wrapper.find('.dialpad-section'); 248 | dialpad_section.addClass('hide'); 249 | } 250 | } 251 | 252 | class OutgoingCallPopup extends TwilioCallPopup { 253 | constructor(twilio_device, phone_numbers) { 254 | super(twilio_device); 255 | this.phone_numbers = phone_numbers; 256 | } 257 | 258 | show() { 259 | this.dialog = new frappe.ui.Dialog({ 260 | 'static': 1, 261 | 'title': __('Make a Call'), 262 | 'minimizable': true, 263 | 'fields': [ 264 | { 265 | 'fieldname': 'to_number', 266 | 'label': 'To Number', 267 | 'fieldtype': 'Data', 268 | 'ignore_validation': true, 269 | 'options': this.phone_numbers, 270 | 'default': this.phone_numbers[0], 271 | 'read_only': 0, 272 | 'reqd': 1 273 | } 274 | ], 275 | primary_action: () => { 276 | this.dialog.disable_primary_action(); 277 | 278 | var params = { 279 | To: this.dialog.get_value('to_number') 280 | }; 281 | 282 | if (this.twilio_device) { 283 | let me = this; 284 | let outgoingConnection = this.twilio_device.connect(params); 285 | frappe.twilio_conn_dialog_map[outgoingConnection] = this; 286 | outgoingConnection.on("ringing", function () { 287 | me.set_header('ringing'); 288 | }); 289 | } else { 290 | this.dialog.enable_primary_action(); 291 | } 292 | }, 293 | primary_action_label: __('Call'), 294 | secondary_action: () => { 295 | if (this.twilio_device) { 296 | this.twilio_device.disconnectAll(); 297 | } 298 | }, 299 | onhide: () => { 300 | if (this.twilio_device) { 301 | this.twilio_device.disconnectAll(); 302 | } 303 | } 304 | }); 305 | let to_number = this.dialog.$wrapper.find('[data-fieldname="to_number"]').find('[type="text"]'); 306 | 307 | $(` 308 | 309 | ${frappe.utils.icon('dialpad')} 310 | `).insertAfter(to_number); 311 | 312 | $(`
`) 313 | .insertAfter(this.dialog.$wrapper.find('.modal-content')); 314 | 315 | this.dialog.add_custom_action('Mute', null, 'btn-mute mr-2 hide'); 316 | this.dialog.get_secondary_btn().addClass('hide'); 317 | this.dialog.show(); 318 | this.dialog.get_close_btn().show(); 319 | } 320 | } 321 | 322 | class IncomingCallPopup extends TwilioCallPopup { 323 | constructor(twilio_device, conn) { 324 | super(twilio_device); 325 | this.conn = conn; 326 | frappe.twilio_conn_dialog_map[conn] = this; // CHECK: Is this the place? 327 | } 328 | 329 | get_title(caller_details) { 330 | let title; 331 | if (caller_details){ 332 | title = __('Incoming Call From {0}', [caller_details.first_name]); 333 | } else { 334 | title = __('Incoming Call From {0}', [this.conn.parameters.From]); 335 | } 336 | return title; 337 | } 338 | 339 | set_dialog_body(caller_details) { 340 | var caller_info = $(`
`); 341 | let caller_details_html = ''; 342 | if (caller_details) { 343 | for (const [key, value] of Object.entries(caller_details)) { 344 | caller_details_html += `
${key}: ${value}
`; 345 | } 346 | } else { 347 | caller_details_html += `
Phone Number: ${this.conn.parameters.From}
`; 348 | } 349 | $(`
${caller_details_html}
`).appendTo(this.dialog.modal_body); 350 | } 351 | 352 | show(caller_details) { 353 | this.dialog = new frappe.ui.Dialog({ 354 | 'static': 1, 355 | 'title': this.get_title(caller_details), 356 | 'minimizable': true, 357 | primary_action: () => { 358 | this.dialog.disable_primary_action(); 359 | this.conn.accept(); 360 | }, 361 | primary_action_label: __('Answer'), 362 | secondary_action: () => { 363 | if (this.twilio_device) { 364 | if (this.conn.status() == 'pending') { 365 | this.conn.reject(); 366 | this.close(); 367 | } 368 | this.twilio_device.disconnectAll(); 369 | } 370 | }, 371 | secondary_action_label: __('Hang Up'), 372 | onhide: () => { 373 | if (this.twilio_device) { 374 | if (this.conn.status() == 'pending') { 375 | this.conn.reject(); 376 | this.close(); 377 | } 378 | this.twilio_device.disconnectAll(); 379 | } 380 | } 381 | }); 382 | this.set_dialog_body(caller_details); 383 | this.show_close_button(); 384 | this.dialog.add_custom_action('Mute', null, 'btn-mute hide'); 385 | this.dialog.show(); 386 | } 387 | } 388 | 389 | class DialPad extends OutgoingCallPopup { 390 | constructor({ twilio_device, wrapper, events, cols, keys, css_classes, fieldnames_map }) { 391 | super(twilio_device); 392 | this.wrapper = wrapper; 393 | this.events = events; 394 | this.cols = cols; 395 | this.keys = keys; 396 | this.css_classes = css_classes || []; 397 | this.fieldnames = fieldnames_map || {}; 398 | 399 | this.init_component(); 400 | } 401 | 402 | init_component() { 403 | this.prepare_dom(); 404 | this.bind_events(); 405 | } 406 | 407 | prepare_dom() { 408 | const { cols, keys, css_classes, fieldnames } = this; 409 | 410 | function get_keys() { 411 | return keys.reduce((a, row, i) => { 412 | return a + row.reduce((a2, number, j) => { 413 | const class_to_append = css_classes && css_classes[i] ? css_classes[i][j] : ''; 414 | const fieldname = fieldnames && fieldnames[number] ? 415 | fieldnames[number] : typeof number === 'string' ? frappe.scrub(number) : number; 416 | 417 | return a2 + `
${number}
`; 418 | }, ''); 419 | }, ''); 420 | } 421 | 422 | this.wrapper.html( 423 | ` 424 |
425 | 426 |
427 | ${get_keys()} 428 |
429 |
` 430 | ) 431 | } 432 | 433 | bind_events() { 434 | const me = this; 435 | this.wrapper.on('click', '.dialpad-btn', function() { 436 | const $btn = $(this); 437 | me.events.dialpad_event($btn); 438 | }); 439 | } 440 | } 441 | 442 | var script = document.createElement('script'); 443 | document.head.appendChild(script); 444 | script.onload = onload_script; 445 | script.src = "https://sdk.twilio.com/js/client/releases/1.13.0/twilio.min.js"; 446 | --------------------------------------------------------------------------------