├── 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 = `
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 |
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 |
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 |
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 |
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 |
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 | $(``).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 += `