diff --git a/.gitignore b/.gitignore
new file mode 100644
index 000000000..79069af38
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,108 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# IDE
+.idea
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+
diff --git a/INCOMPLETE/membership_subscription/__init__.py b/INCOMPLETE/membership_subscription/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/__manifest__.py b/INCOMPLETE/membership_subscription/__manifest__.py
new file mode 100644
index 000000000..fbe092866
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/__manifest__.py
@@ -0,0 +1,27 @@
+{
+ 'name': "Membership Subscription",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'support': "steven@sythiltech.com.au",
+ 'summary':'Create membership programs that automatically manage subscriptions using payment tokens',
+ 'description':'Create membership programs that automatically manage subscriptions using payment tokens',
+ 'license':'LGPL-3',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'security/ir_rule.xml',
+ 'data/website.menu.csv',
+ 'views/payment_membership_views.xml',
+ 'views/membership_subscription_templates.xml',
+ ],
+ 'depends': ['payment_reoccuring'],
+ 'demo': [
+ 'demo/ir.module.category.csv',
+ 'demo/res.groups.xml',
+ 'demo/payment_membership.xml',
+ ],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/controllers/__init__.py b/INCOMPLETE/membership_subscription/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/controllers/main.py b/INCOMPLETE/membership_subscription/controllers/main.py
new file mode 100644
index 000000000..4aac04280
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/controllers/main.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+
+import logging
+_logger = logging.getLogger(__name__)
+import werkzeug
+import requests
+import json
+
+import odoo.http as http
+from odoo.http import request
+from odoo.addons.http_routing.models.ir_http import slug
+
+class MembershipSubscriptionController(http.Controller):
+
+ @http.route('/membership/form/load', website=True, type='json', auth="user")
+ def membership_form_load(self, **kw):
+
+ values = {}
+ for field_name, field_value in kw.items():
+ values[field_name] = field_value
+
+ membership_form = request.env['payment.membership'].browse(int(values['form_id']))
+
+ return {'form_id': membership_form.id, 'payment_acquirer': membership_form.subscription_id.payment_acquirer_id.id}
+
+ @http.route('/membership/cancel', type="http", auth="user", website=True)
+ def membership_cancel(self):
+
+ #Remove the membership assignment
+ request.env.user.partner_id.payment_membership_id = False
+
+ #Remove all permissions
+ request.env.user.groups_id = False
+
+ #Also add them to the portal group so they can access the website
+ group_portal = request.env['ir.model.data'].sudo().get_object('base','group_portal')
+ group_portal.users = [(4, request.env.user.id)]
+
+ return werkzeug.utils.redirect("/")
+
+ @http.route('/membership/management', type="http", auth="user", website=True)
+ def membership_management(self):
+ return request.render('membership_subscription.membership_management', {'current_membership': request.env.user.partner_id.payment_membership_id})
+
+ @http.route('/membership/signup/thank-you', type="http", auth="user", website=True)
+ def membership_signup_thankyou(self):
+ return request.render('membership_subscription.signup_thank_you', {})
+
+ @http.route('/membership/signup/', type="http", auth="public", website=True)
+ def membership_signup(self, membership):
+ return request.render('membership_subscription.signup_form', {'membership': membership})
+
+ @http.route('/membership/signup/process', type="http", auth="public", website=True)
+ def membership_signup_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ membership = request.env['payment.membership'].sudo().browse( int( values['membership_id'] ) )
+
+ # The user account is created now but access right are only given when payment has been received
+ if request.env['res.users'].sudo().search_count([('login', '=', values['email'])]) == 0:
+ new_user = request.env['res.users'].sudo().create({'name': values['name'], 'login': values['email'], 'email': values['email'], 'password': values['password'] })
+ else:
+ return "User account with this login already exists"
+
+ # Modify the users partner record only with the allowed fields
+ extra_fields_dict = {}
+ for extra_field in membership.extra_field_ids:
+ extra_fields_dict[extra_field.sudo().field_id.name] = values[extra_field.name]
+
+ new_user.partner_id.write(extra_fields_dict)
+
+ #Remove all permissions
+ new_user.groups_id = False
+
+ #Also add them to the portal group so they can access the website
+ group_portal = request.env['ir.model.data'].sudo().get_object('base','group_portal')
+ group_portal.users = [(4, new_user.id)]
+
+ # Add them to the membership
+ extra_fields_dict['payment_membership_id'] = membership.id
+
+ # Paid memberships get redirected to the payment gateway assigned to the subscription
+ if membership.subscription_id:
+ #Add the subscription product to the order
+ order = request.website.sale_get_order(force_create=1)
+ request.website.sale_reset()
+ order._cart_update(product_id=membership.subscription_id.product_id.id, set_qty=1)
+
+ return json.JSONEncoder().encode({'status': 'unpaid', 'acquirer': membership.subscription_id.payment_acquirer_id.id})
+ else:
+
+ # Users of free membership gain instant rights access
+ for user_group in membership.group_ids:
+ user_group.users = [(4, new_user.id)]
+
+ # Automatically sign the new user in
+ request.cr.commit() # as authenticate will use its own cursor we need to commit the current transaction
+ request.session.authenticate(request.env.cr.dbname, values['email'], values['password'])
+
+ # Redirect them to the thank you page
+ return json.JSONEncoder().encode({'status': 'paid', 'redirect_url': membership.redirect_url})
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/data/website.menu.csv b/INCOMPLETE/membership_subscription/data/website.menu.csv
new file mode 100644
index 000000000..5f19b693f
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/data/website.menu.csv
@@ -0,0 +1,2 @@
+"id","name","url","parent_id/id"
+"membership_website_menu","Membership","/membership/management","website.main_menu"
diff --git a/INCOMPLETE/membership_subscription/demo/ir.module.category.csv b/INCOMPLETE/membership_subscription/demo/ir.module.category.csv
new file mode 100644
index 000000000..63282e93b
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/demo/ir.module.category.csv
@@ -0,0 +1,2 @@
+"id","name"
+"demo_membership","Membership Levels"
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/demo/payment_membership.xml b/INCOMPLETE/membership_subscription/demo/payment_membership.xml
new file mode 100644
index 000000000..3b69a4eb3
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/demo/payment_membership.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ Bronze
+
+
+
+
+ Silver
+
+
+
+
+ Gold
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/demo/res.groups.xml b/INCOMPLETE/membership_subscription/demo/res.groups.xml
new file mode 100644
index 000000000..c620cef51
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/demo/res.groups.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Bronze
+
+ Teir 1 Membership
+
+
+
+ Silver
+
+
+ Teir 2 Membership
+
+
+
+ Gold
+
+
+ Teir 3 Membership
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/doc/changelog.rst b/INCOMPLETE/membership_subscription/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/models/__init__.py b/INCOMPLETE/membership_subscription/models/__init__.py
new file mode 100644
index 000000000..41304bfab
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import payment_membership
+from . import res_partner
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/models/payment_membership.py b/INCOMPLETE/membership_subscription/models/payment_membership.py
new file mode 100644
index 000000000..4287c52d8
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/models/payment_membership.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, tools
+
+class PaymentMembership(models.Model):
+
+ _name = "payment.membership"
+
+ name = fields.Char(string="Name", required=True)
+ subscription_id = fields.Many2one('payment.subscription', string="Subscription")
+ redirect_url = fields.Char(string="Redirect URL", default="/membership/signup/thank-you")
+ group_ids = fields.Many2many('res.groups', string="New User Groups", help="Determines what the user can access e.g. slide channels / support help groups")
+ extra_field_ids = fields.One2many('payment.membership.field', 'payment_membership_id', string="Extra Fields", help="More fields will appear on the website signup form")
+ member_ids = fields.One2many('res.partner', 'payment_membership_id', string="Members")
+
+ @api.multi
+ def view_singup_form(self):
+ self.ensure_one()
+
+ return {
+ 'type': 'ir.actions.act_url',
+ 'name': "Membership Signup Form",
+ 'target': 'self',
+ 'url': "/membership/signup/" + slug(self)
+ }
+
+class PaymentMembershipField(models.Model):
+
+ _name = "payment.membership.field"
+
+ payment_membership_id = fields.Many2one('payment.membership', string="Payment Membership")
+ field_id = fields.Many2one('ir.model.fields', string="Field", domain="[('model_id.model','=','res.partner')]", required=True)
+ name = fields.Char(string="Name")
+ field_type = fields.Selection([('textbox','Textbox')], string="Field Type", default="textbox", required=True)
+ field_label = fields.Char(string="Field Label", required=True)
+
+ @api.onchange('field_id')
+ def _onchange_field_id(self):
+ if self.field_id:
+ self.field_label = self.field_id.field_description
+ self.name = self.field_id.name
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/models/res_partner.py b/INCOMPLETE/membership_subscription/models/res_partner.py
new file mode 100644
index 000000000..92d1fa6e9
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/models/res_partner.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, tools
+
+class RespartnerMembership(models.Model):
+
+ _inherit = "res.partner"
+
+ payment_membership_id = fields.Many2one('payment.membership', string="Membership")
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/security/ir.model.access.csv b/INCOMPLETE/membership_subscription/security/ir.model.access.csv
new file mode 100644
index 000000000..20ded7200
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_payment_membership","access payment.membership","model_payment_membership","sales_team.group_sale_manager",1,1,1,1
+"public_access_payment_membership","public access payment.membership","model_payment_membership","",1,0,0,0
+"access_payment_membership_field","access payment.membership.field","model_payment_membership_field","sales_team.group_sale_manager",1,1,1,1
+"public_access_payment_membership_field","public access payment.membership.field","model_payment_membership_field","",1,0,0,0
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/security/ir_rule.xml b/INCOMPLETE/membership_subscription/security/ir_rule.xml
new file mode 100644
index 000000000..123d057f0
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/security/ir_rule.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Website Membership Menu Access
+
+ [('url','!=', '/membership/management')]
+
+
+
+
+
+
diff --git a/INCOMPLETE/membership_subscription/static/description/index.html b/INCOMPLETE/membership_subscription/static/description/index.html
new file mode 100644
index 000000000..aadd7274d
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/static/description/index.html
@@ -0,0 +1,6 @@
+
+
Description
+Create membership programs that automatically manage subscriptions using payment tokens
+
+Find a bug or need support? send an email to steven@sythiltech.com.au
+
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/static/src/img/ui/snippet_thumb_membership_form.jpg b/INCOMPLETE/membership_subscription/static/src/img/ui/snippet_thumb_membership_form.jpg
new file mode 100644
index 000000000..f8deea2ab
Binary files /dev/null and b/INCOMPLETE/membership_subscription/static/src/img/ui/snippet_thumb_membership_form.jpg differ
diff --git a/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.editor.js b/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.editor.js
new file mode 100644
index 000000000..f15ecb7d5
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.editor.js
@@ -0,0 +1,52 @@
+odoo.define('membership.form.editor', function (require) {
+'use strict';
+
+var base = require('web_editor.base');
+var options = require('web_editor.snippets.options');
+var core = require('web.core');
+var session = require('web.session');
+var website = require('website.website');
+var ajax = require('web.ajax');
+var qweb = core.qweb;
+var wUtils = require('website.utils');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+
+options.registry.membership_form = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ rpc.query({
+ model: 'payment.membership',
+ method: 'name_search',
+ args: [],
+ context: weContext.get()
+ }).then(function(form_ids){
+
+ wUtils.prompt({
+ id: "editor_new_membership_form",
+ window_title: "Choose Membership Form",
+ select: "Select Form",
+ init: function (field) {
+ return form_ids;
+ },
+ }).then(function (form_id) {
+
+ session.rpc('/membership/form/load', {'form_id': form_id}).then(function(result) {
+ self.$target.find("input[name='membership_id']").val(result.form_id);
+ });
+ });
+
+ });
+
+ },
+ cleanForSave: function () {
+ var self = this;
+ //Sometimes the form gets saved with the token in it
+ self.$target.find("input[name='csrf_token']").removeAttr("value");
+ self.$target.find("input[name='csrf_token']").attr("t-att-value","request.csrf_token()");
+ },
+
+});
+
+});
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.js b/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.js
new file mode 100644
index 000000000..f42c68139
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/static/src/js/membership.snippets.js
@@ -0,0 +1,68 @@
+odoo.define('membership.form.front', function (require) {
+'use strict';
+
+var base = require('web_editor.base');
+var core = require('web.core');
+var session = require('web.session');
+var website = require('website.website');
+var ajax = require('web.ajax');
+var qweb = core.qweb;
+var wUtils = require('website.utils');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+
+$(function() {
+
+ $(".btn-website-membership-signup").click(function(e) {
+
+ e.preventDefault(); // Prevent the default submit behavior
+
+ var my_form = $("form");
+
+ // Prepare form inputs
+ var form_data = my_form.serializeArray();
+
+ var form_values = {};
+ _.each(form_data, function(input) {
+ if (input.value != '') {
+ form_values[input.name] = input.value;
+ }
+ });
+
+ ajax.post('/membership/signup/process', form_values).then(function(result) {
+ if (result) {
+ var result_data = $.parseJSON(result);
+ if (result_data.status == "paid") {
+ window.location = result_data.redirect_url;
+ } else if (result_data.status == "unpaid") {
+ ajax.jsonRpc('/shop/payment/transaction', 'call', {
+ 'acquirer_id': result_data.acquirer,
+ 'save_token': true,
+ }).then(function (result) {
+ if (result) {
+ // if the server sent us the html form, we create a form element
+ var newForm = document.createElement('form');
+ newForm.setAttribute("method", "post"); // set it to post
+ //newForm.setAttribute("provider", checked_radio.dataset.provider);
+ newForm.hidden = true; // hide it
+ newForm.innerHTML = result; // put the html sent by the server inside the form
+ var action_url = $(newForm).find('input[name="data_set"]').data('actionUrl');
+ newForm.setAttribute("action", action_url); // set the action url
+ $(document.getElementsByTagName('body')[0]).append(newForm); // append the form to the body
+ $(newForm).find('input[data-remove-me]').remove(); // remove all the input that should be removed
+ if(action_url) {
+ newForm.submit(); // and finally submit the form
+ }
+ } else {
+ alert("We are not able to redirect you to the payment form.");
+ }
+ }).fail(function (message, data) {
+ alert("We are not able to redirect you to the payment form.
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/membership_subscription/views/payment_membership_views.xml b/INCOMPLETE/membership_subscription/views/payment_membership_views.xml
new file mode 100644
index 000000000..f2ab65bfd
--- /dev/null
+++ b/INCOMPLETE/membership_subscription/views/payment_membership_views.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ payment.membership form view
+ payment.membership
+
+
+
+
+
+
+ payment.membership tree view
+ payment.membership
+
+
+
+
+
+
+
+
+
+ Memberships
+ payment.membership
+ form
+ tree,form
+
+
Add Membership
+
+
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/__init__.py b/INCOMPLETE/payment_reoccuring/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/__manifest__.py b/INCOMPLETE/payment_reoccuring/__manifest__.py
new file mode 100644
index 000000000..feb47842a
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/__manifest__.py
@@ -0,0 +1,19 @@
+{
+ 'name': "Payment Reoccuring",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'support': "steven@sythiltech.com.au",
+ 'summary':'Charge a customer on a reoccuring basis using payment tokens',
+ 'description':'Charge a customer on a reoccuring basis using payment tokens',
+ 'license':'LGPL-3',
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'views/payment_subscription_views.xml',
+ ],
+ 'depends': ['website_sale', 'account_invoicing'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/data/ir.cron.csv b/INCOMPLETE/payment_reoccuring/data/ir.cron.csv
new file mode 100644
index 000000000..725c7a867
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/data/ir.cron.csv
@@ -0,0 +1,2 @@
+id,name,model,function,interval_number,interval_type,"active"
+"payment_subscription_check","Payment Subscription Check","payment.subscription.subscriber","subscription_check",24,hours,"1"
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/doc/changelog.rst b/INCOMPLETE/payment_reoccuring/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/models/__init__.py b/INCOMPLETE/payment_reoccuring/models/__init__.py
new file mode 100644
index 000000000..0f2466a94
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import payment_subscription
+from . import payment_token
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/models/payment_subscription.py b/INCOMPLETE/payment_reoccuring/models/payment_subscription.py
new file mode 100644
index 000000000..cdbabda1f
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/models/payment_subscription.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime, timedelta
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, tools
+from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
+
+class PaymentSubscription(models.Model):
+
+ _name = "payment.subscription"
+
+ name = fields.Char(string="Name", required=True)
+ product_id = fields.Many2one('product.template', string="Product", required=True)
+ initial_amount = fields.Float(string="Initial Amount", related="product_id.list_price")
+ payment_acquirer_id = fields.Many2one('payment.acquirer', string="Payment Acquirer", domain="[('token_implemented','=',True)]", required=True)
+ period_interval = fields.Selection([('days','Days')], default="days", string="Period Interval", required=True)
+ period_amount = fields.Float(default="7.0", string="Period Amount", required=True)
+ subscription_ids = fields.One2many('payment.subscription.subscriber', 'subscription_id', string="Subscribers")
+
+class PaymentSubscriptionSubscriber(models.Model):
+
+ _name = "payment.subscription.subscriber"
+
+ subscription_id = fields.Many2one("payment.subscription", string="Subscription")
+ partner_id = fields.Many2one('res.partner', string="Contact")
+ status = fields.Selection([('inactive', 'Inactive'), ('active','Active')], string="Status")
+ amount = fields.Float(string="Subscription Amount", help="The amount the subscriber agreed to when they subscribed")
+ payment_token_id = fields.Many2one('payment.token', string="Payment Token")
+ next_payment_date = fields.Datetime(string="Next Payment Date")
+
+ @api.model
+ def subscription_check(self):
+ #Find all subscriptions where payment is due
+ for subscriber in self.env['payment.subscription.subscriber'].search([('status', '=', 'active'), ('next_payment_date','<=',datetime.strftime( fields.datetime.now() , tools.DEFAULT_SERVER_DATETIME_FORMAT)) ]):
+
+ if subscriber.payment_token_id.make_charge(subscriber.subscription_id.name + " Subscripotion Payment", subscriber.amount, {}):
+ # If success then move the next payment one peroid forward
+ subscriber.next_payment_date = datetime.strptime(subscriber.next_payment_date, DEFAULT_SERVER_DATETIME_FORMAT) + relativedelta(days=subscriber.subscription_id.period_amount)
+ else:
+ # On fail cancel the subscription
+ subscriber.status = "inactive"
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/models/payment_token.py b/INCOMPLETE/payment_reoccuring/models/payment_token.py
new file mode 100644
index 000000000..e8c980804
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/models/payment_token.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models
+
+class PaymentToken(models.Model):
+
+ _inherit = "payment.token"
+
+ def make_charge(self, reference, amount, **kwargs):
+ _logger.error("Make Charge")
+
+ currency = self.partner_id.currency_id
+
+ tx = self.env['payment.transaction'].sudo().create({
+ 'amount': amount,
+ 'acquirer_id': self.acquirer_id.id,
+ 'type': 'server2server',
+ 'currency_id': currency.id,
+ 'reference': reference,
+ 'payment_token_id': self.id,
+ 'partner_id': self.partner_id.id,
+ 'partner_country_id': self.partner_id.country_id.id,
+ })
+
+ try:
+ tx.s2s_do_transaction(**kwargs)
+ return True
+ except:
+ _logger.error('Error while making an automated payment')
+ return False
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/security/ir.model.access.csv b/INCOMPLETE/payment_reoccuring/security/ir.model.access.csv
new file mode 100644
index 000000000..4c3b32fa0
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_payment_subscription","access payment.subscription","model_payment_subscription","sales_team.group_sale_manager",1,1,1,1
+"access_payment.subscription_subscriber","access payment.subscription.subscriber","model_payment_subscription_subscriber","sales_team.group_sale_manager",1,1,1,1
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/static/description/index.html b/INCOMPLETE/payment_reoccuring/static/description/index.html
new file mode 100644
index 000000000..23e30f256
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/static/description/index.html
@@ -0,0 +1,8 @@
+
+
Description
+Charge a customer on a reoccuring basis using payment tokens
+
+You will need a paymetn acquirer that is set to always capture card details for this to work.
+
+Find a bug or need support? send an email to steven@sythiltech.com.au
+
\ No newline at end of file
diff --git a/INCOMPLETE/payment_reoccuring/views/payment_subscription_views.xml b/INCOMPLETE/payment_reoccuring/views/payment_subscription_views.xml
new file mode 100644
index 000000000..fc3e46c6f
--- /dev/null
+++ b/INCOMPLETE/payment_reoccuring/views/payment_subscription_views.xml
@@ -0,0 +1,54 @@
+
+
+
+
+ payment.subscription form view
+ payment.subscription
+
+
+
+
+
+
+ payment.subscription tree view
+ payment.subscription
+
+
+
+
+
+
+
+
+
+
+
+ Payment Subscriptions
+ payment.subscription
+ form
+ tree,form
+
+
Add Subscription
+
+
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/__init__.py b/INCOMPLETE/voip_sip_webrtc_voice/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/__manifest__.py b/INCOMPLETE/voip_sip_webrtc_voice/__manifest__.py
new file mode 100644
index 000000000..5e8f084ca
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/__manifest__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Voip Communication - Voice Synthesis",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'support': "steven@sythiltech.com.au",
+ 'summary': "Text to speech phone calls with dynamic placeholders",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/voip_settings_views.xml',
+ 'views/voip_voice_message_views.xml',
+ 'views/voip_account_action_views.xml',
+ 'data/voip.voice.csv',
+ 'data/voip.account.action.type.csv',
+ ],
+ 'demo': [],
+ 'depends': ['voip_sip_webrtc'],
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/data/voip.account.action.type.csv b/INCOMPLETE/voip_sip_webrtc_voice/data/voip.account.action.type.csv
new file mode 100644
index 000000000..150fe7a9e
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/data/voip.account.action.type.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"voice","Voice Message","voice_message"
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/data/voip.voice.csv b/INCOMPLETE/voip_sip_webrtc_voice/data/voip.voice.csv
new file mode 100644
index 000000000..9700a20a9
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/data/voip.voice.csv
@@ -0,0 +1,3 @@
+"id","name","internal_code"
+"synth_espeak","eSpeak","espeak"
+"synth_bing","Microsoft Bing","bing"
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/doc/changelog.rst b/INCOMPLETE/voip_sip_webrtc_voice/doc/changelog.rst
new file mode 100644
index 000000000..6ae2d0913
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/models/__init__.py b/INCOMPLETE/voip_sip_webrtc_voice/models/__init__.py
new file mode 100644
index 000000000..75003f124
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/models/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+from . import voip_voice
+from . import voip_settings
+from . import voip_voice_message
+from . import voip_account_action
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/models/voip_account_action.py b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_account_action.py
new file mode 100644
index 000000000..5e059f4c5
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_account_action.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+import socket
+import logging
+from openerp.exceptions import UserError
+_logger = logging.getLogger(__name__)
+from openerp.http import request
+import re
+import hashlib
+import random
+from openerp import api, fields, models
+import threading
+import time
+import datetime
+import struct
+import base64
+from random import randint
+
+class VoipAccountActionInheritVoice(models.Model):
+
+ _inherit = "voip.account.action"
+
+ voip_voice_message_id = fields.Many2one('voip.voice.message', string="Voice Message")
+
+ def _voip_action_initialize_voice_message(self, voip_call_client):
+ _logger.error("Change Action Voice Synth")
+ codec_id = self.env['ir.model.data'].sudo().get_object('voip_sip_webrtc', 'pcmu')
+ audio_stream = self.voip_voice_message_id.synth_message(codec_id, voip_call_client.id)
+ return audio_stream
+
+ def _voip_action_sender_voice_message(self, media_data, media_index, payload_size):
+ rtp_payload_data = media_data[media_index * payload_size : media_index * payload_size + payload_size]
+ new_media_index = media_index + 1
+ return rtp_payload_data, media_data, new_media_index
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/models/voip_settings.py b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_settings.py
new file mode 100644
index 000000000..2e924449c
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_settings.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class VoipSettingsVoice(models.TransientModel):
+
+ _inherit = 'voip.settings'
+
+ bing_tts_api_key = fields.Char(string="Bing TTS API Key")
+
+ @api.multi
+ def set_values(self):
+ super(VoipSettingsVoice, self).set_values()
+ self.env['ir.default'].set('voip.settings', 'bing_tts_api_key', self.bing_tts_api_key)
+
+ @api.model
+ def get_values(self):
+ res = super(VoipSettingsVoice, self).get_values()
+ res.update(
+ bing_tts_api_key=self.env['ir.default'].get('voip.settings', 'bing_tts_api_key'),
+ )
+ return res
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice.py b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice.py
new file mode 100644
index 000000000..5daa833ac
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import requests
+import subprocess
+import shutil
+import tempfile
+
+from openerp import api, fields, models, tools
+
+class VoipVoice(models.Model):
+
+ _name = "voip.voice"
+ _description = "Voice Synthesizer"
+
+ name = fields.Char(string="Name")
+ internal_code = fields.Char(string="Internal Code")
+
+ def voice_synth(self, rendered_text, codec_id):
+
+ #each field type has it's own function that way we can make plugin modules with voice synth types
+ method = '_synth_%s' % (self.internal_code,)
+ action = getattr(self, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self))
+
+ return action(rendered_text, codec_id,)
+
+ def _synth_espeak(self, rendered_text, codec_id):
+
+ voice_synth_file_path = "/odoo/voice.wav"
+ subprocess.call(["espeak", "-w" + voice_synth_file_path, rendered_text])
+
+ #Transcode the file
+ output_filepath = tempfile.gettempdir() + "/output.raw"
+ subprocess.call(['sox', voice_synth_file_path, "--rate", str(codec_id.sample_rate), "--channels", "1", "--encoding", codec_id.encoding, "--type","raw", output_filepath])
+
+ #Read the transcoded file
+ return open(output_filepath, 'rb').read()
+
+ def _synth_bing(self, rendered_text, codec_id):
+
+ api_key = self.env['ir.values'].get_default('voip.settings', 'bing_tts_api_key')
+
+ #Get access token
+ headers = {"Ocp-Apim-Subscription-Key": api_key}
+ auth_response = requests.post("https://api.cognitive.microsoft.com/sts/v1.0/issueToken", headers=headers)
+ access_token = auth_response.text
+
+ headers = {}
+ headers['Content-type'] = "application/ssml+xml"
+ headers['X-Microsoft-OutputFormat'] = "riff-16khz-16bit-mono-pcm"
+ headers['Authorization'] = "Bearer " + access_token
+ headers['X-Search-AppId'] = "07D3234E49CE426DAA29772419F436CA"
+ headers['X-Search-ClientID'] = "1ECFAE91408841A480F00935DC390960"
+ headers['User-Agent'] = "TTSForOdooVoipSIPWebrtcModule"
+
+ synth_string = "" + rendered_text + ""
+ synth_response = requests.post("https://speech.platform.bing.com/synthesize", data=synth_string, headers=headers, stream=True)
+
+ with open("/odoo/ms.wav", "wb") as out_file:
+ shutil.copyfileobj(synth_response.raw, out_file)
+
+ output_filepath = tempfile.gettempdir() + "/output.raw"
+ subprocess.call(['sox', "/odoo/ms.wav", "--rate", str(codec_id.sample_rate), "--channels", "1", "--encoding", codec_id.encoding, "--type","raw", output_filepath])
+
+ #Read the transcoded file
+ return open(output_filepath, 'rb').read()
+
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice_message.py b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice_message.py
new file mode 100644
index 000000000..5b8981988
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/models/voip_voice_message.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+from openerp import api, fields, models, tools
+from werkzeug import urls
+from datetime import datetime
+import functools
+
+try:
+ # We use a jinja2 sandboxed environment to render mako templates.
+ # Note that the rendering does not cover all the mako syntax, in particular
+ # arbitrary Python statements are not accepted, and not all expressions are
+ # allowed: only "public" attributes (not starting with '_') of objects may
+ # be accessed.
+ # This is done on purpose: it prevents incidental or malicious execution of
+ # Python code that may break the security of the server.
+ from jinja2.sandbox import SandboxedEnvironment
+ mako_template_env = SandboxedEnvironment(
+ block_start_string="<%",
+ block_end_string="%>",
+ variable_start_string="${",
+ variable_end_string="}",
+ comment_start_string="<%doc>",
+ comment_end_string="%doc>",
+ line_statement_prefix="%",
+ line_comment_prefix="##",
+ trim_blocks=True, # do not output newline after blocks
+ autoescape=True, # XML/HTML automatic escaping
+ )
+ mako_template_env.globals.update({
+ 'str': str,
+ 'quote': urls.url_quote,
+ 'urlencode': urls.url_encode,
+ 'datetime': datetime,
+ 'len': len,
+ 'abs': abs,
+ 'min': min,
+ 'max': max,
+ 'sum': sum,
+ 'filter': filter,
+ 'reduce': functools.reduce,
+ 'map': map,
+ 'round': round,
+
+ # dateutil.relativedelta is an old-style class and cannot be directly
+ # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
+ # is needed, apparently.
+ 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
+ })
+except ImportError:
+ _logger.warning("jinja2 not available, templating features will not work!")
+
+class VoipVoiceMessage(models.Model):
+
+ _name = "voip.voice.message"
+ _description = "Voip Voice Message"
+
+ name = fields.Char(string="Name")
+ voice_synth_id = fields.Many2one('voip.voice', string="Voice Synthesizer")
+ synth_text = fields.Text(string="Synth Text")
+ model_id = fields.Many2one('ir.model', string="Model", help="Used during dynamic placeholder generation")
+ model = fields.Char(related="model_id.model", string='Related Document Model', store=True, readonly=True)
+ model_object_field_id = fields.Many2one('ir.model.fields', string="Field", help="Select target field from the related document model.\nIf it is a relationship field you will be able to select a target field at the destination of the relationship.")
+ sub_object_id = fields.Many2one('ir.model', string='Sub-model', readonly=True, help="When a relationship field is selected as first field, this field shows the document model the relationship goes to.")
+ sub_model_object_field_id = fields.Many2one('ir.model.fields', string='Sub-field', help="When a relationship field is selected as first field, this field lets you select the target field within the destination document model (sub-model).")
+ null_value = fields.Char(string='Default Value', help="Optional value to use if the target field is empty")
+ copyvalue = fields.Char(string='Placeholder Expression', help="Final placeholder expression, to be copy-pasted in the desired template field.")
+
+ @api.onchange('model_object_field_id')
+ def _onchange_model_object_field_id(self):
+ if self.model_object_field_id.relation:
+ self.sub_object_id = self.env['ir.model'].search([('model','=',self.model_object_field_id.relation)])[0].id
+ else:
+ self.sub_object_id = False
+
+ if self.model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ @api.onchange('sub_model_object_field_id')
+ def _onchange_sub_model_object_field_id(self):
+ if self.sub_model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ def render_template(self, template, model, res_id):
+ """Render the given template text, replace mako expressions ``${expr}``
+ with the result of evaluating these expressions with
+ an evaluation context containing:
+ * ``user``: browse_record of the current user
+ * ``object``: browse_record of the document record this mail is
+ related to
+ * ``context``: the context passed to the mail composition wizard
+ :param str template: the template text to render
+ :param str model: model name of the document record this mail is related to.
+ :param int res_id: id of document records those mails are related to.
+ """
+
+ # try to load the template
+ #try:
+ template = mako_template_env.from_string(tools.ustr(template))
+ #except Exception:
+ # _logger.error("Failed to load template %r", template)
+ # return False
+
+ # prepare template variables
+ user = self.env.user
+ record = self.env[model].browse(res_id)
+
+ variables = {
+ 'user': user
+ }
+
+
+
+ variables['object'] = record
+ try:
+ render_result = template.render(variables)
+ except Exception:
+ _logger.error("Failed to render template %r using values %r" % (template, variables))
+ render_result = u""
+ if render_result == u"False":
+ render_result = u""
+
+ return render_result
+
+ @api.model
+ def build_expression(self, field_name, sub_field_name, null_value):
+ """Returns a placeholder expression for use in a template field,
+ based on the values provided in the placeholder assistant.
+ :param field_name: main field name
+ :param sub_field_name: sub field name (M2O)
+ :param null_value: default value if the target value is empty
+ :return: final placeholder expression
+ """
+ expression = ''
+ if field_name:
+ expression = "${object." + field_name
+ if sub_field_name:
+ expression += "." + sub_field_name
+ if null_value:
+ expression += " or '''%s'''" % null_value
+ expression += "}"
+ return expression
+
+ def synth_message(self, codec_id, record_id):
+ rendered_synth_text = self.render_template(self.synth_text, self.model, record_id)
+ _logger.error(rendered_synth_text)
+ return self.voice_synth_id.voice_synth(rendered_synth_text, codec_id)
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/static/description/icon.png b/INCOMPLETE/voip_sip_webrtc_voice/static/description/icon.png
new file mode 100644
index 000000000..eb680ef42
Binary files /dev/null and b/INCOMPLETE/voip_sip_webrtc_voice/static/description/icon.png differ
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/static/description/index.html b/INCOMPLETE/voip_sip_webrtc_voice/static/description/index.html
new file mode 100644
index 000000000..fe0210f74
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/static/description/index.html
@@ -0,0 +1,9 @@
+
+
Description
+Text to speech phone calls with dynamic placeholders
+
+*NOTE* This module requires that the SoX software is installed
+sudo apt-get install sox
+
+Find a bug or need support? send an email to steven@sythiltech.com.au
+
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/views/voip_account_action_views.xml b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_account_action_views.xml
new file mode 100644
index 000000000..ef0e3817d
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_account_action_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ voip.account.action form view inherit voice
+ voip.account.action
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/views/voip_settings_views.xml b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_settings_views.xml
new file mode 100644
index 000000000..c3b7ed939
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_settings_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ voip.settings.view.form inherit voice
+ voip.settings
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/INCOMPLETE/voip_sip_webrtc_voice/views/voip_voice_message_views.xml b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_voice_message_views.xml
new file mode 100644
index 000000000..7d9997b9d
--- /dev/null
+++ b/INCOMPLETE/voip_sip_webrtc_voice/views/voip_voice_message_views.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ voip.voice.message form view
+ voip.voice.message
+
+
+
+
+
+
+ voip.voice.message tree view
+ voip.voice.message
+
+
+
+
+
+
+
+
+
+ Voip Voice Message
+ voip.voice.message
+ tree,form
+
+
+ A Voice Message is text that will be spoken by a voice synthesis engine during a call, it can use dynamic placeholders to speak customer specific pharses
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/__init__.py b/OTHER/voip_sip_webrtc_twilio_self/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/__manifest__.py b/OTHER/voip_sip_webrtc_twilio_self/__manifest__.py
new file mode 100644
index 000000000..951cc60d5
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/__manifest__.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Voip Communication - Twilio",
+ 'version': "1.0.25",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'support': "steven@sythiltech.com.au",
+ 'summary': "Add support for Twilio XML",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/voip_number_views.xml',
+ 'views/voip_call(core)_views.xml',
+ 'views/voip_call_views.xml',
+ 'views/voip_twilio_views.xml',
+ 'views/voip_twilio_invoice_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_users(core)_views.xml',
+ 'views/res_users_views.xml',
+ 'views/crm_lead_views.xml',
+ 'views/voip_call_wizard_views.xml',
+ 'views/voip_account_action(core)_views.xml',
+ 'views/voip_account_action_views.xml',
+ 'views/voip_sip_webrtc_twilio_templates.xml',
+ 'views/voip_call_comment_views.xml',
+ 'views/mail_activity_views.xml',
+ 'views/menus.xml',
+ #'data/voip.account.action.type.csv',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [],
+ 'depends': ['crm','account', 'account_invoicing'],
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/controllers/__init__.py b/OTHER/voip_sip_webrtc_twilio_self/controllers/__init__.py
new file mode 100644
index 000000000..afffdb590
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import main
+from . import bus
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/controllers/bus.py b/OTHER/voip_sip_webrtc_twilio_self/controllers/bus.py
new file mode 100644
index 000000000..7514f5e7a
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/controllers/bus.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*
+
+from odoo.addons.bus.controllers.main import BusController
+from odoo.http import request
+
+class VoipTwilioBusController(BusController):
+ # --------------------------
+ # Extends BUS Controller Poll
+ # --------------------------
+ def _poll(self, dbname, channels, last, options):
+ if request.session.uid:
+
+ #Triggers the voip javascript client to start the call
+ channels.append((request.db, 'voip.twilio.start', request.env.user.partner_id.id))
+
+ return super(VoipTwilioBusController, self)._poll(dbname, channels, last, options)
diff --git a/OTHER/voip_sip_webrtc_twilio_self/controllers/main.py b/OTHER/voip_sip_webrtc_twilio_self/controllers/main.py
new file mode 100644
index 000000000..82694c707
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/controllers/main.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+import openerp.http as http
+import werkzeug
+from odoo.http import request
+from odoo.exceptions import UserError
+import json
+import base64
+import time
+import urllib.parse
+import jwt
+import hmac
+import hashlib
+
+import logging
+_logger = logging.getLogger(__name__)
+
+class TwilioVoiceController(http.Controller):
+
+ @http.route('/voip/ringtone.mp3', type="http", auth="user")
+ def voip_ringtone_mp3(self):
+ """Return the ringtone file to be used by javascript"""
+
+ voip_ringtone_id = request.env['ir.default'].get('voip.settings', 'ringtone_id')
+ voip_ringtone = request.env['voip.ringtone'].browse( voip_ringtone_id )
+ ringtone_media = voip_ringtone.media
+
+ headers = []
+ ringtone_base64 = base64.b64decode(ringtone_media)
+ headers.append(('Content-Length', len(ringtone_base64)))
+ response = request.make_response(ringtone_base64, headers)
+
+ return response
+
+ @http.route('/twilio/voice', type='http', auth="public", csrf=False)
+ def twilio_voice(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ from_sip = values['From']
+ to_sip = values['To']
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += " \n"
+ twilio_xml += " " + to_sip + ";region=gll\n"
+ twilio_xml += " \n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/voice/route', type='http', auth="public", csrf=False)
+ def twilio_voice_route(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ from_number = values['From']
+ to_number = values['To']
+
+ to_stored_number = request.env['voip.number'].sudo().search([('number','=',to_number)])
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += ' ' + "\n"
+
+ #Call all the users assigned to this number
+ for call_route in to_stored_number.call_routing_ids:
+ twilio_xml += " " + call_route.twilio_client_name + "\n"
+
+ twilio_xml += " \n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/client-voice', type='http', auth="public", csrf=False)
+ def twilio_client_voice(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ _logger.error(field_name)
+ _logger.error(field_value)
+ values[field_name] = field_value
+
+ from_number = values['From']
+ to_number = values['To']
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += ' " + to_number + "\n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/capability-token/', type='http', auth="user", csrf=False)
+ def twilio_capability_token(self, stored_number_id, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ stored_number = request.env['voip.number'].browse( int(stored_number_id) )
+
+ # Find these values at twilio.com/console
+ account_sid = stored_number.account_id.twilio_account_sid
+ auth_token = stored_number.account_id.twilio_auth_token
+
+ application_sid = stored_number.twilio_app_id
+
+ #Automatically create a Twilio client name for the user if one has not been manually set up
+ if not request.env.user.twilio_client_name:
+ request.env.user.twilio_client_name = request.env.cr.dbname + "_user_" + str(request.env.user.id)
+
+ header = '{"typ":"JWT",' + "\r\n"
+ header += ' "alg":"HS256"}'
+ base64_header = base64.b64encode(header.encode("utf-8"))
+
+ payload = '{"exp":' + str(time.time() + 60) + ',' + "\r\n"
+ payload += ' "iss":"' + account_sid + '",' + "\r\n"
+ payload += ' "scope":"scope:client:outgoing?appSid=' + application_sid + '&clientName=' + request.env.user.twilio_client_name + ' scope:client:incoming?clientName=' + request.env.user.twilio_client_name + '"}'
+ base64_payload = base64.b64encode(payload.encode("utf-8"))
+ base64_payload = base64_payload.decode("utf-8").replace("+","-").replace("/","_").replace("=","").encode("utf-8")
+
+ signing_input = base64_header + b"." + base64_payload
+ secret = bytes(auth_token.encode('utf-8'))
+ signature = base64.b64encode(hmac.new(secret, signing_input, digestmod=hashlib.sha256).digest())
+ signature = signature.decode("utf-8").replace("+","-").replace("/","_").replace("=","").encode("utf-8")
+
+ token = base64_header + b"." + base64_payload + b"." + signature
+ return json.dumps({'indentity': request.env.user.twilio_client_name, 'token': token.decode("utf-8")})
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/data/voip.account.action.type.csv b/OTHER/voip_sip_webrtc_twilio_self/data/voip.account.action.type.csv
new file mode 100644
index 000000000..b76ab0d81
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/data/voip.account.action.type.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"call_users","Call Users","call_users"
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/doc/changelog.rst b/OTHER/voip_sip_webrtc_twilio_self/doc/changelog.rst
new file mode 100644
index 000000000..5512075f6
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/doc/changelog.rst
@@ -0,0 +1,94 @@
+v1.0.25
+=======
+* Fix an upgrade issue
+
+v1.0.24
+=======
+* Redirect to record after making call comment
+
+v1.0.23
+=======
+* Add 'Make Twilio Call' button to activity screen to optimise workflow
+
+v1.0.22
+=======
+* Call log report now has total cost / total duration
+* Invoices created by the Create Invoice button will generate a unique pdf more tailored to calls (e.g. no quantity column)
+
+v1.0.21
+=======
+* Fix Call History report and add ability to skip report (can be time consuming)
+
+v1.0.20
+=======
+* Fix JWT renewal for incoming calls
+
+v1.0.19
+=======
+* Feature to assign a number to a user to speed up calling
+
+v1.0.18
+=======
+* Display user friendly error if call fails for any reason e.g. no media access
+
+v1.0.17
+=======
+* Smart button on Twilio account screen that shows calls for that account
+
+v1.0.16
+=======
+* Code out the need to have the Twilio Python library installed for capabilty token generation
+
+v1.0.15
+=======
+* Fix call history import
+
+v1.0.14
+=======
+* Fix cability token url using http in https systems
+
+v1.0.13
+=======
+* Fix mySound undefined bug on outgoing calls
+
+v1.0.12
+=======
+* Automatically generate Twilio client name
+
+v1.0.11
+=======
+* Ability to call leads
+
+v1.0.10
+=======
+* Can now write a post call comment, you can also listen to call recording using a link inside the chatter
+* Call recordings are no longer downloaded, instead only the url is keep (prevents post call hang due to waiting for download + increased privacy / security not having a copy inside Odoo)
+
+v1.0.9
+======
+* Update Import call history to also import call recordings
+* Calls are now added to the Odoo call log with their recordings
+
+v1.0.8
+======
+* Bug fix to also record outgoing SIP calls not just the Twilio javascript client ones.
+
+v1.0.7
+======
+* Twilio capability token generation (easy setup)
+
+v1.0.6
+======
+* Fix incorrect voice URL
+
+v1.0.5
+======
+* Answer calls from within your browser
+
+v1.0.4
+======
+* Merge with twilio bill and introduce manual call functionality
+
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/__init__.py b/OTHER/voip_sip_webrtc_twilio_self/models/__init__.py
new file mode 100644
index 000000000..7b66a661e
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/__init__.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+
+from . import voip_twilio
+from . import voip_call_core
+from . import voip_call
+from . import voip_call_wizard
+from . import res_users
+from . import res_partner_core
+from . import res_partner
+from . import voip_number
+from . import voip_account_action_core
+from . import voip_account_action
+from . import crm_lead
+from . import mail_activity
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/crm_lead.py b/OTHER/voip_sip_webrtc_twilio_self/models/crm_lead.py
new file mode 100644
index 000000000..8154d523d
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/crm_lead.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+import logging
+_logger = logging.getLogger(__name__)
+
+class CRMLeadTwilioVoip(models.Model):
+
+ _inherit = "crm.lead"
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ my_context = {'default_to_number': self.mobile, 'default_record_model': 'crm.lead', 'default_record_id': self.id}
+
+ #Use the first number you find
+ default_number = self.env['voip.number'].search([])
+ if default_number:
+ my_context['default_from_number'] = default_number[0].id
+ else:
+ raise UserError("No numbers found, can not make call")
+
+ if self.env.user.twilio_assigned_number_id:
+ self.env['voip.call.wizard'].create({'to_number': self.mobile, 'record_model': 'crm.lead', 'record_id': self.id, 'from_number': self.env.user.twilio_assigned_number_id.id}).start_call()
+ #my_context['default_from_number'] = self.env.user.twilio_assigned_number_id.id
+ return True
+
+ return {
+ 'name': 'Voip Call Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.call.wizard',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/mail_activity.py b/OTHER/voip_sip_webrtc_twilio_self/models/mail_activity.py
new file mode 100644
index 000000000..07876cd15
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/mail_activity.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class MailActivityTwilioVoip(models.Model):
+
+ _inherit = "mail.activity"
+
+ activity_type_id_ref = fields.Char(string="Activity Type External Reference", compute="_compute_activity_type_id_ref")
+
+ @api.depends('activity_type_id_ref')
+ def _compute_activity_type_id_ref(self):
+ if self.activity_type_id:
+ external_id = self.env['ir.model.data'].sudo().search([('model', '=', 'mail.activity.type'), ('res_id', '=', self.activity_type_id.id)])
+ if external_id:
+ self.activity_type_id_ref = external_id.complete_name
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ #Call the mobile action of whatever record the activity is assigned to (this will fail if it is not crm.lead of res.partner)
+ assigned_record = self.env[self.res_model].browse(self.res_id)
+ return assigned_record.twilio_mobile_action()
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/res_partner.py b/OTHER/voip_sip_webrtc_twilio_self/models/res_partner.py
new file mode 100644
index 000000000..f8f3dc9ee
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/res_partner.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+import logging
+_logger = logging.getLogger(__name__)
+import datetime
+
+class ResPartnerTwilioVoip(models.Model):
+
+ _inherit = "res.partner"
+
+ call_routing_ids = fields.Many2many('voip.number', string="Call Routing")
+ twilio_client_name = fields.Char(string="Twilio Client Name")
+ twilio_assigned_number_id = fields.Many2one('voip.number', string="Assigned Number")
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ my_context = {'default_to_number': self.mobile, 'default_record_model': 'res.partner', 'default_record_id': self.id, 'default_partner_id': self.id}
+
+ #Use the first number you find
+ default_number = self.env['voip.number'].search([])
+ if default_number:
+ my_context['default_from_number'] = default_number[0].id
+ else:
+ raise UserError("No numbers found, can not make call")
+
+ if self.env.user.twilio_assigned_number_id:
+ self.env['voip.call.wizard'].create({'to_number': self.mobile, 'record_model': 'res.partner', 'record_id': self.id, 'partner_id': self.id, 'from_number': self.env.user.twilio_assigned_number_id.id}).start_call()
+ #my_context['default_from_number'] = self.env.user.twilio_assigned_number_id.id
+ return True
+
+ return {
+ 'name': 'Voip Call Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.call.wizard',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
+
+class ResUsersTwilioVoip(models.Model):
+
+ _inherit = "res.users"
+
+ call_routing_ids = fields.Many2many('voip.number', string="Call Routing")
+ twilio_client_name = fields.Char(string="Twilio Client Name")
+ twilio_assigned_number_id = fields.Many2one('voip.number', string="Assigned Number")
+
+ def get_call_details(self, conn):
+ twilio_from_mobile = conn['parameters']['From']
+
+ #conn['parameters']['AccountSid']
+
+ call_from_partner = self.env['res.partner'].sudo().search([('mobile','=',conn['parameters']['From'])])
+ caller_partner_id = False
+
+ if call_from_partner:
+ from_name = call_from_partner.name + " (" + twilio_from_mobile + ")"
+ caller_partner_id = call_from_partner.id
+ else:
+ from_name = twilio_from_mobile
+
+ ringtone = "/voip/ringtone.mp3"
+ ring_duration = self.env['ir.default'].get('voip.settings', 'ring_duration')
+
+ #Create the call now so we can record even missed calls
+ #I have no idea why we don't get the To address, maybe it provided during call accept / reject or timeout?!?
+ voip_call = self.env['voip.call'].create({'status': 'pending', 'from_address': twilio_from_mobile, 'from_partner_id': call_from_partner.id, 'ring_time': datetime.datetime.now(), 'record_model': 'res.partner', 'record_id': call_from_partner.id or False})
+
+ return {'from_name': from_name, 'caller_partner_id': caller_partner_id, 'ringtone': ringtone, 'ring_duration': ring_duration, 'voip_call_id': voip_call.id}
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/res_partner_core.py b/OTHER/voip_sip_webrtc_twilio_self/models/res_partner_core.py
new file mode 100644
index 000000000..a19874174
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/res_partner_core.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+
+class ResPartnerVoip(models.Model):
+
+ _inherit = "res.partner"
+
+ sip_address = fields.Char(string="SIP Address")
+ xmpp_address = fields.Char(string="XMPP Address")
+
+ @api.onchange('country_id','mobile')
+ def _onchange_mobile(self):
+ """Tries to convert a local number to e.164 format based on the partners country, don't change if already in e164 format"""
+ if self.mobile:
+
+ if self.country_id and self.country_id.phone_code:
+ if self.mobile.startswith("0"):
+ self.mobile = "+" + str(self.country_id.phone_code) + self.mobile[1:].replace(" ","")
+ elif self.mobile.startswith("+"):
+ self.mobile = self.mobile.replace(" ","")
+ else:
+ self.mobile = "+" + str(self.country_id.phone_code) + self.mobile.replace(" ","")
+ else:
+ self.mobile = self.mobile.replace(" ","")
+
+
+ @api.multi
+ def sip_action(self):
+ self.ensure_one()
+
+ my_context = {'default_type': 'sip', 'default_model':'res.partner', 'default_record_id':self.id, 'default_to_address': self.sip_address}
+
+ #Use the first SIP account you find
+ default_voip_account = self.env['voip.account'].search([])
+ if default_voip_account:
+ my_context['default_sip_account_id'] = default_voip_account[0].id
+ else:
+ raise UserError("No SIP accounts found, can not send message")
+
+ return {
+ 'name': 'SIP Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.message.compose',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/res_users.py b/OTHER/voip_sip_webrtc_twilio_self/models/res_users.py
new file mode 100644
index 000000000..8f3903d4f
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/res_users.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime, timedelta
+from openerp.http import request
+import socket
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models
+
+class ResUsersVoip(models.Model):
+
+ _inherit = "res.users"
+
+ voip_presence_status = fields.Char(string="Voip Presence Status", help="Used for both Webrtc and SIP")
+ last_web_client_activity_datetime = fields.Datetime(string="Last Activity Datetime")
+ voip_ringtone = fields.Binary(string="Ringtone")
+ voip_account_id = fields.Many2one('voip.account', string="SIP Account")
+ voip_ringtone_filename = fields.Char(string="Ringtone Filename")
+ voip_missed_call = fields.Binary(string="Missed Call Message")
+ voip_missed_call_filename = fields.Char(string="Missed Call Message Filename")
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/sdp.py b/OTHER/voip_sip_webrtc_twilio_self/models/sdp.py
new file mode 100644
index 000000000..ef5d7f54d
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/sdp.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+import time
+
+def generate_sdp(self, ip, audio_port, rtp_profiles, session_description=" "):
+
+ sdp = ""
+
+ #Protocol Version ("v=") https://tools.ietf.org/html/rfc4566#section-5.1 (always 0 for us)
+ sdp += "v=0\r\n"
+
+ #Origin ("o=") https://tools.ietf.org/html/rfc4566#section-5.2
+ username = "-"
+ sess_id = int(time.time())
+ sess_version = 0
+ nettype = "IN"
+ addrtype = "IP4"
+ sdp += "o=" + username + " " + str(sess_id) + " " + str(sess_version) + " " + nettype + " " + addrtype + " " + ip + "\r\n"
+
+ #Session Name ("s=") https://tools.ietf.org/html/rfc4566#section-5.3
+ sdp += "s=" + session_description + "\r\n"
+
+ #Connection Information ("c=") https://tools.ietf.org/html/rfc4566#section-5.7
+ sdp += "c=" + nettype + " " + addrtype + " " + ip + "\r\n"
+
+ #Timing ("t=") https://tools.ietf.org/html/rfc4566#section-5.9
+ sdp += "t=0 0\r\n"
+
+ #Media Descriptions ("m=") https://tools.ietf.org/html/rfc4566#section-5.14
+ sdp += "m=audio " + str(audio_port) + " RTP/AVP"
+ for rtp_profile in rtp_profiles:
+ sdp += " " + str(rtp_profile)
+ sdp += "\r\n"
+
+ sdp += "a=sendrecv\r\n"
+
+ return sdp
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action.py
new file mode 100644
index 000000000..ddabbdfc5
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+import logging
+_logger = logging.getLogger(__name__)
+
+class VoipAccountActionInheritTwilio(models.Model):
+
+ _inherit = "voip.account.action"
+
+ call_user_ids = fields.Many2many('res.users', string="Call Users")
+
+ def _voip_action_call_users(self, session, data):
+ for call_user in self.call_user_ids:
+ _logger.error("Call User " + call_user.name)
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action_core.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action_core.py
new file mode 100644
index 000000000..c83368672
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_account_action_core.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+import socket
+import logging
+from openerp.exceptions import UserError
+_logger = logging.getLogger(__name__)
+from openerp.http import request
+import re
+import hashlib
+import random
+from openerp import api, fields, models
+import threading
+from . import sdp
+import time
+import datetime
+import struct
+import base64
+from random import randint
+import queue
+
+class VoipAccountAction(models.Model):
+
+ _name = "voip.account.action"
+ _description = "VOIP Account Action"
+
+ voip_dialog_id = fields.Many2one('voip.dialog', string="Voip Dialog")
+ name = fields.Char(string="Name")
+ start = fields.Boolean(string="Start Action")
+ account_id = fields.Many2one('voip.account', string="VOIP Account")
+ action_type_id = fields.Many2one('voip.account.action.type', string="Call Action", required="True")
+ action_type_internal_name = fields.Char(related="action_type_id.internal_name", string="Action Type Internal Name")
+ recorded_media_id = fields.Many2one('voip.media', string="Recorded Message")
+ user_id = fields.Many2one('res.users', string="Call User")
+ from_transition_ids = fields.One2many('voip.account.action.transition', 'action_to_id', string="Source Transitions")
+ to_transition_ids = fields.One2many('voip.account.action.transition', 'action_from_id', string="Destination Transitions")
+
+ def _voip_action_initialize_recorded_message(self, voip_call_client):
+ _logger.error("Change Action Recorded Message")
+ media_data = base64.decodestring(self.recorded_media_id.media)
+ return media_data
+
+ def _voip_action_sender_recorded_message(self, media_data, media_index, payload_size):
+ rtp_payload_data = media_data[media_index * payload_size : media_index * payload_size + payload_size]
+ new_media_index = media_index + 1
+ return rtp_payload_data, media_data, new_media_index
+
+class VoipAccountActionTransition(models.Model):
+
+ _name = "voip.account.action.transition"
+ _description = "VOIP Call Action Transition"
+
+ name = fields.Char(string="Name")
+ trigger = fields.Selection([('dtmf','DTMF Input'), ('auto','Automatic')], default="dtmf", string="Trigger")
+ dtmf_input = fields.Selection([('0','0'), ('1','1'), ('2','2'), ('3','3'), ('4','4'), ('5','5'), ('6','6'), ('7','7'), ('8','8'), ('9','9'), ('*','*'), ('#','#')], string="DTMF Input")
+ action_from_id = fields.Many2one('voip.account.action', string="From Voip Action")
+ action_to_id = fields.Many2one('voip.account.action', string="To Voip Action")
+
+class VoipAccountActionType(models.Model):
+
+ _name = "voip.account.action.type"
+ _description = "VOIP Account Action Type"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Internal Name", help="function name of code")
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_call.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call.py
new file mode 100644
index 000000000..8578cfc5f
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import requests
+import base64
+import json
+import datetime
+import time
+from email import utils
+from openerp.http import request
+from odoo.exceptions import UserError
+
+from openerp import api, fields, models
+
+class VoipCallInheritTWilio(models.Model):
+
+ _inherit = "voip.call"
+
+ twilio_sid = fields.Char(string="Twilio SID")
+ twilio_account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ currency_id = fields.Many2one('res.currency', string="Currency")
+ price = fields.Float(string="Price")
+ margin = fields.Float(string="Margin")
+ twilio_number_id = fields.Many2one('voip.number', string="Twilio Number")
+ twilio_call_recording_uri = fields.Char(string="Twilio Call Recording URI")
+ twilio_call_recording = fields.Binary(string="Twilio Call Recording")
+ twilio_call_recording_filename = fields.Char(string="Twilio Call Recording Filename")
+ record_model = fields.Char(string="Record Model", help="Name of the model this call was to e.g. res.partner / crm.lead")
+ record_id = fields.Char(string="Record ID", help="ID of the record the call was to")
+
+ @api.multi
+ def add_twilio_call(self, call_sid):
+ self.ensure_one()
+
+ if call_sid is None:
+ raise UserError('Call Failed')
+
+ self.twilio_sid = call_sid
+
+ #Fetch the recording for this call
+ twilio_account = self.twilio_account_id
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + twilio_account.twilio_account_sid + "/Calls/" + call_sid + ".json", auth=(str(twilio_account.twilio_account_sid), str(twilio_account.twilio_auth_token)))
+
+ call = json.loads(response_string.text)
+
+ #Fetch the recording if it exists
+ if 'subresource_uris' in call:
+ if 'recordings' in call['subresource_uris']:
+ if call['subresource_uris']['recordings'] != '':
+ recording_response = requests.get("https://api.twilio.com" + call['subresource_uris']['recordings'], auth=(str(twilio_account.twilio_account_sid), str(twilio_account.twilio_auth_token)))
+ recording_json = json.loads(recording_response.text)
+ for recording in recording_json['recordings']:
+ self.twilio_call_recording_uri = "https://api.twilio.com" + recording['uri'].replace(".json",".mp3")
+
+ if 'price' in call:
+ if call['price'] is not None:
+ if float(call['price']) != 0.0:
+ self.currency_id = self.env['res.currency'].search([('name','=', call['price_unit'])])[0].id
+ self.price = -1.0 * float(call['price'])
+
+ #Have to map the Twilio call status to the one in the core module
+ twilio_status = call['status']
+ if twilio_status == "queued":
+ self.status = "pending"
+ elif twilio_status == "ringing":
+ self.status = "pending"
+ elif twilio_status == "in-progress":
+ self.status = "active"
+ elif twilio_status == "canceled":
+ self.status = "cancelled"
+ elif twilio_status == "completed":
+ self.status = "over"
+ elif twilio_status == "failed":
+ self.status = "failed"
+ elif twilio_status == "busy":
+ self.status = "busy"
+ elif twilio_status == "no-answer":
+ self.status = "failed"
+
+ self.start_time = datetime.datetime.strptime(call['start_time'], '%a, %d %b %Y %H:%M:%S %z').strftime('%Y-%m-%d %H:%M:%S')
+ self.end_time = datetime.datetime.strptime(call['end_time'], '%a, %d %b %Y %H:%M:%S %z').strftime('%Y-%m-%d %H:%M:%S')
+
+ #Duration includes the ring time
+ self.duration = call['duration']
+
+ #Post to the chatter about the call
+ #callee = self.env[voip_call.record_model].browse( int(voip_call.record_id) )
+ #message_body = "A call was made using " + voip_call.twilio_number_id.name + " it lasted " + str(voip_call.duration) + " seconds"
+ #my_message = callee.message_post(body=message_body, subject="Twilio Outbound Call")
+
+
+class VoipCallComment(models.TransientModel):
+
+ _name = "voip.call.comment"
+
+ call_id = fields.Many2one('voip.call', string="Voip Call")
+ note = fields.Html(string="Note")
+
+ @api.multi
+ def post_feedback(self):
+ self.ensure_one()
+
+ message = self.env['mail.message']
+
+ if self.call_id.record_model and self.call_id.record_id:
+ record = self.env[self.call_id.record_model].browse(self.call_id.record_id)
+
+ call_activity = self.env['ir.model.data'].get_object('mail','mail_activity_data_call')
+ record_model = self.env['ir.model'].search([('model','=', self.call_id.record_model)])
+ #Create an activity then mark it as done
+ note = self.note + "From Number: " + self.call_id.twilio_number_id.name
+
+ #Chatter will sanitise html5 audo so instead place a url
+ setting_record_calls = self.env['ir.default'].get('voip.settings','record_calls')
+ if setting_record_calls:
+ note += " Recording: " + 'Play Online'
+
+ mail_activity = self.env['mail.activity'].create({'res_model_id': record_model.id, 'res_id': self.call_id.record_id, 'activity_type_id': call_activity.id, 'note': note})
+ mail_activity.action_feedback()
+
+
+ return {'type': 'ir.actions.act_window',
+ 'res_model': self.call_id.record_model,
+ 'view_mode': 'form',
+ 'res_id': int(self.call_id.record_id)}
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_core.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_core.py
new file mode 100644
index 000000000..ac1a90852
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_core.py
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request
+import datetime
+import logging
+import socket
+import threading
+_logger = logging.getLogger(__name__)
+import time
+from random import randint
+from hashlib import sha1
+#import ssl
+#from dtls import do_patch
+#from dtls.sslconnection import SSLConnection
+import hmac
+import hashlib
+import random
+import string
+import passlib
+import struct
+import zlib
+import re
+from openerp.exceptions import UserError
+import binascii
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
+from openerp import api, fields, models
+
+class VoipCall(models.Model):
+
+ _name = "voip.call"
+ _order = 'create_date desc'
+
+ from_address = fields.Char(string="From Address")
+ from_partner_id = fields.Many2one('res.partner', string="From Partner", help="From can be blank if the call comes from outside of the system")
+ from_partner_sdp = fields.Text(string="From Partner SDP")
+ partner_id = fields.Many2one('res.partner', string="(OBSOLETE)To Partner")
+ to_address = fields.Char(string="To Address")
+ to_partner_id = fields.Many2one('res.partner', string="To Partner", help="To partner can be blank if the source is external and no record with mobile or sip is found")
+ status = fields.Selection([('pending','Pending'), ('missed','Missed'), ('accepted','Accepted'), ('rejected','Rejected'), ('active','Active'), ('over','Complete'), ('failed','Failed'), ('busy','Busy'), ('cancelled','Cancelled')], string='Status', default="pending", help="Pending = Calling person\nActive = currently talking\nMissed = Call timed out\nOver = Someone hit end call\nRejected = Someone didn't want to answer the call")
+ ring_time = fields.Datetime(string="Ring Time", help="Time the call starts dialing")
+ start_time = fields.Datetime(string="Start Time", help="Time the call was answered (if answered)")
+ end_time = fields.Datetime(string="End Time", help="Time the call end")
+ duration = fields.Char(string="Duration", help="Length of the call")
+ transcription = fields.Text(string="Transcription", help="Automatic transcription of the call")
+ notes = fields.Text(string="(OBSOLETE)Notes", help="Additional comments outside the transcription (use the chatter instead of this field)")
+ client_ids = fields.One2many('voip.call.client', 'vc_id', string="Client List")
+ type = fields.Selection([('internal','Internal'),('external','External')], string="Type")
+ mode = fields.Selection([('videocall','video call'), ('audiocall','audio call'), ('screensharing','screen sharing call')], string="Mode", help="This is only how the call starts, i.e a video call can turn into a screen sharing call mid way")
+ sip_tag = fields.Char(string="SIP Tag")
+ voip_account = fields.Many2one('voip.account', string="VOIP Account")
+ to_audio = fields.Binary(string="Audio")
+ to_audio_filename = fields.Char(string="(OBSOLETE)Audio Filename")
+ media = fields.Binary(string="Media")
+ media_filename = fields.Char(string="Media Filename")
+ server_stream_data = fields.Binary(string="Server Stream Data", help="Stream data sent by the server, e.g. automated call")
+ media_url = fields.Char(string="Media URL", compute="_compute_media_url")
+ codec_id = fields.Many2one('voip.codec', string="Codec")
+ direction = fields.Selection([('internal','Internal'), ('incoming','Incoming'), ('outgoing','Outgoing')], string="Direction")
+ sip_call_id = fields.Char(string="SIP Call ID")
+ ice_username = fields.Char(string="ICE Username")
+ ice_password = fields.Char(string="ICE Password")
+ call_dialog_id = fields.Many2one('voip.codec', string="Call Dialog")
+
+ @api.one
+ def _compute_media_url(self):
+ if self.server_stream_data:
+ self.media_url = "/voip/messagebank/" + str(self.id)
+ else:
+ self.media_url = ""
+
+ @api.model
+ def clear_messagebank(self):
+ """ Delete recorded phone call to clear up space """
+
+ for voip_call in self.env['voip.call'].search([('to_audio','!=', False)]):
+ #TODO remove to_audio
+ voip_call.to_audio = False
+ voip_call.to_audio_filename = False
+
+ voip_call.server_stream_data = False
+
+ voip_call.media = False
+ voip_call.media_filename = False
+
+ #Also remove the media attached to the client
+ for voip_client in self.env['voip.call.client'].search([('audio_stream','!=', False)]):
+ voip_client.audio_stream = False
+
+ def start_call(self):
+ """ Process the ICE queue now """
+
+ #Notify caller and callee that the call was rejected
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.start', voip_client.partner_id.id), notification)
+
+ def accept_call(self):
+ """ Mark the call as accepted and send response to close the notification window and open the VOIP window """
+
+ if self.status == "pending":
+ self.status = "accepted"
+
+ #Notify caller and callee that the call was accepted
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id, 'status': 'accepted', 'type': self.type}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.response', voip_client.partner_id.id), notification)
+
+ def reject_call(self):
+ """ Mark the call as rejected and send the response so the notification window is closed on both ends """
+
+ if self.status == "pending":
+ self.status = "rejected"
+
+ #Notify caller and callee that the call was rejected
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id, 'status': 'rejected'}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.response', voip_client.partner_id.id), notification)
+
+ def miss_call(self):
+ """ Mark the call as missed, both caller and callee will close there notification window due to the timeout """
+
+ if self.status == "pending":
+ self.status = "missed"
+
+ def begin_call(self):
+ """ Mark the call as active, we start recording the call duration at this point """
+
+ if self.status == "accepted":
+ self.status = "active"
+
+ self.start_time = datetime.datetime.now()
+
+ def end_call(self):
+ """ Mark the call as over, we can calculate the call duration based on the start time, also send notification to both sides to close there VOIP windows """
+
+ if self.status == "active":
+ self.status = "over"
+
+ self.end_time = datetime.datetime.now()
+ diff_time = datetime.datetime.strptime(self.end_time, DEFAULT_SERVER_DATETIME_FORMAT) - datetime.datetime.strptime(self.start_time, DEFAULT_SERVER_DATETIME_FORMAT)
+ self.duration = str(diff_time.seconds) + " Seconds"
+
+ #Notify both caller and callee that the call is ended
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.end', voip_client.partner_id.id), notification)
+
+ def voip_call_sdp(self, sdp):
+ """Store the description and send it to everyone else"""
+
+ if self.type == "internal":
+ for voip_client in self.client_ids:
+ if voip_client.partner_id.id == self.env.user.partner_id.id:
+ voip_client.sdp = sdp
+ else:
+ notification = {'call_id': self.id, 'sdp': sdp }
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.sdp', voip_client.partner_id.id), notification)
+
+ def generate_call_sdp(self):
+
+ sdp_response = ""
+
+ #Protocol Version ("v=") https://tools.ietf.org/html/rfc4566#section-5.1 (always 0 for us)
+ sdp_response += "v=0\r\n"
+
+ #Origin ("o=") https://tools.ietf.org/html/rfc4566#section-5.2 (Should come up with a better session id...)
+ sess_id = int(time.time()) #Not perfect but I don't expect more then one call a second
+ sess_version = 0 #Will always start at 0
+ sdp_response += "o=- " + str(sess_id) + " " + str(sess_version) + " IN IP4 0.0.0.0\r\n"
+
+ #Session Name ("s=") https://tools.ietf.org/html/rfc4566#section-5.3 (We don't need a session name, information about the call is all displayed in the UI)
+ sdp_response += "s= \r\n"
+
+ #Timing ("t=") https://tools.ietf.org/html/rfc4566#section-5.9 (For now sessions are infinite but we may use this if for example a company charges a price for a fixed 30 minute consultation)
+ sdp_response += "t=0 0\r\n"
+
+ #In later versions we might send the missed call mp3 via rtp
+ sdp_response += "a=sendrecv\r\n"
+
+ #TODO generate cert/fingerprint within module
+ fignerprint = self.env['ir.default'].get('voip.settings', 'fingerprint')
+ sdp_response += "a=fingerprint:sha-256 " + fignerprint + "\r\n"
+ sdp_response += "a=setup:passive\r\n"
+
+ #Sure why not
+ sdp_response += "a=ice-options:trickle\r\n"
+
+ #Sigh no idea
+ sdp_response += "a=msid-semantic:WMS *\r\n"
+
+ #Random stuff, left here so I don't have get it a second time if needed
+ #example supported audio profiles: 109 9 0 8 101
+ #sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF 109 101\r\n"
+
+ #Media Descriptions ("m=") https://tools.ietf.org/html/rfc4566#section-5.14 (Message bank is audio only for now)
+ audio_codec = "9" #Use G722 Audio Profile
+ sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF " + audio_codec + "\r\n"
+
+ #Connection Data ("c=") https://tools.ietf.org/html/rfc4566#section-5.7 (always seems to be 0.0.0.0?)
+ sdp_response += "c=IN IP4 0.0.0.0\r\n"
+
+ #ICE creds (https://tools.ietf.org/html/rfc5245#page-76)
+ ice_ufrag = ''.join(random.choice('123456789abcdef') for _ in range(4))
+ ice_pwd = ''.join(random.choice('123456789abcdef') for _ in range(22))
+ self.ice_password = ice_pwd
+ sdp_response += "a=ice-ufrag:" + str(ice_ufrag) + "\r\n"
+ sdp_response += "a=ice-pwd:" + str(ice_pwd) + "\r\n"
+
+ #Ummm naming each media?!?
+ sdp_response += "a=mid:sdparta_0\r\n"
+
+ return {"type":"answer","sdp": sdp_response}
+
+ def voip_call_ice(self, ice):
+ """Forward ICE to everyone else"""
+
+ for voip_client in self.client_ids:
+
+ #Don't send ICE back to yourself
+ if voip_client.partner_id.id != self.env.user.partner_id.id:
+ notification = {'call_id': self.id, 'ice': ice }
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.ice', voip_client.partner_id.id), notification)
+
+class VoipCallClient(models.Model):
+
+ _name = "voip.call.client"
+
+ vc_id = fields.Many2one('voip.call', string="VOIP Call")
+ partner_id = fields.Many2one('res.partner', string="Partner")
+ sip_address = fields.Char(string="SIP Address")
+ name = fields.Char(string="Name", help="Can be a number if the client is from outside the system")
+ model = fields.Char(string="Model")
+ record_id = fields.Integer(string="Record ID")
+ state = fields.Selection([('invited','Invited'),('joined','joined'),('media_access','Media Access')], string="State", default="invited")
+ sdp = fields.Char(string="SDP")
+ sip_invite = fields.Char(string="SIP INVITE Message")
+ sip_addr = fields.Char(string="Address")
+ sip_addr_host = fields.Char(string="SIP Address Host")
+ sip_addr_port = fields.Char(string="SIP Address Port")
+ audio_media_port = fields.Integer(string="Audio Media Port")
+ audio_stream = fields.Binary(string="Audio Stream")
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_wizard.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_wizard.py
new file mode 100644
index 000000000..6a35276ea
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_call_wizard.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import datetime
+
+from odoo import api, fields, models
+
+class VoipCallWizard(models.Model):
+
+ _name = "voip.call.wizard"
+ _description = "Twilio Call Wizard"
+
+ record_model = fields.Char(string="Record Model")
+ record_id = fields.Integer(string="Record ID")
+ partner_id = fields.Many2one('res.partner')
+ from_number = fields.Many2one('voip.number', string="From Number")
+ to_number = fields.Char(string="To Number", readonly="True")
+
+ def start_call(self):
+
+ #Create the call record now
+ voip_call = self.env['voip.call'].create({'status': 'pending', 'twilio_number_id': self.from_number.id, 'twilio_account_id': self.from_number.account_id.id, 'from_address': self.from_number.number, 'from_partner_id': self.env.user.partner_id.id, 'to_address': self.to_number, 'to_partner_id': self.partner_id.id, 'ring_time': datetime.datetime.now(), 'record_model': self.record_model, 'record_id': self.record_id})
+
+ #Send notification to self to start the Twilio javascript client
+ notification = {'from_number': self.from_number.number, 'to_number': self.to_number, 'capability_token_url': self.from_number.capability_token_url, 'call_id': voip_call.id}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.twilio.start', self.env.user.partner_id.id), notification)
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_number.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_number.py
new file mode 100644
index 000000000..da8447192
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_number.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+from openerp import api, fields, models
+import json
+
+import requests
+from openerp.http import request
+
+class VoipNumber(models.Model):
+
+ _name = "voip.number"
+
+ name = fields.Char(string="Name")
+ number = fields.Char(string="Number")
+ account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ capability_token_url = fields.Char(string="Capability Token URL")
+ twilio_app_id = fields.Char(string="Twilio App ID")
+ call_routing_ids = fields.Many2many('res.users', string="Called Users", help="The users that will get called when an incoming call is made to this number")
+
+ @api.model
+ def get_numbers(self, **kw):
+ """ Get the numbers that the user can receive calls from """
+
+ call_routes = []
+ for call_route in self.env.user.call_routing_ids:
+ call_routes.append({'capability_token_url': call_route.capability_token_url})
+
+ return call_routes
+
+ def create_twilio_app(self):
+
+ #Create the application for the number and point it back to the server
+ data = {'FriendlyName': 'Auto Setup Application for ' + str(self.name), 'VoiceUrl': request.httprequest.host_url + 'twilio/client-voice'}
+ response_string = requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.account_id.twilio_account_sid + "/Applications.json", data=data, auth=(str(self.account_id.twilio_account_sid), str(self.account_id.twilio_auth_token)))
+ response_string_json = json.loads(response_string.content.decode('utf-8'))
+
+ self.twilio_app_id = response_string_json['sid']
+
+ self.capability_token_url = request.httprequest.host_url + 'twilio/capability-token/' + str(self.id)
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/models/voip_twilio.py b/OTHER/voip_sip_webrtc_twilio_self/models/voip_twilio.py
new file mode 100644
index 000000000..3dda592a7
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/models/voip_twilio.py
@@ -0,0 +1,426 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import json
+import requests
+from datetime import datetime
+import re
+from lxml import etree
+from dateutil import parser
+from openerp.http import request
+import base64
+
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+
+class VoipTwilio(models.Model):
+
+ _name = "voip.twilio"
+ _description = "Twilio Account"
+
+ name = fields.Char(string="Name")
+ twilio_account_sid = fields.Char(string="Account SID")
+ twilio_auth_token = fields.Char(string="Auth Token")
+ twilio_last_check_date = fields.Datetime(string="Last Check Date")
+ resell_account = fields.Boolean(string="Resell Account")
+ margin = fields.Float(string="Margin", default="1.1", help="Multiply the call price by this figure 0.7 * 1.1 = 0.77")
+ partner_id = fields.Many2one('res.partner', string="Customer")
+ twilio_call_number = fields.Integer(string="Calls", compute="_compute_twilio_call_number")
+
+ @api.one
+ def _compute_twilio_call_number(self):
+ self.twilio_call_number = self.env['voip.call'].search_count([('twilio_account_id','=',self.id)])
+
+ @api.multi
+ def create_invoice(self):
+ self.ensure_one()
+
+ return {
+ 'name': 'Twilio Create Invoice',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.twilio.invoice',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': {'default_twilio_account_id': self.id, 'default_margin': self.margin}
+ }
+
+ @api.multi
+ def setup_numbers(self):
+ """Adds mobile numbers to the system"""
+ self.ensure_one()
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ if response_string.status_code == 200:
+ response_string_twilio_numbers = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ _logger.error(response_string_twilio_numbers.text.encode("utf-8"))
+
+ root = etree.fromstring(response_string_twilio_numbers.text.encode("utf-8"))
+ my_from_number_list = root.xpath('//IncomingPhoneNumber')
+ for my_from_number in my_from_number_list:
+ friendly_name = my_from_number.xpath('//FriendlyName')[0].text
+ twilio_number = my_from_number.xpath('//PhoneNumber')[0].text
+ sid = my_from_number.xpath('//Sid')[0].text
+
+ #Create a new mobile number
+ if self.env['voip.number'].search_count([('number','=',twilio_number)]) == 0:
+ voip_number = self.env['voip.number'].create({'name': friendly_name, 'number': twilio_number,'account_id':self.id})
+
+ #Create the application for the number and point it back to the server
+ data = {'FriendlyName': 'Auto Setup Application for ' + str(voip_number.name), 'VoiceUrl': request.httprequest.host_url + 'twilio/client-voice'}
+ response_string = requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Applications.json", data=data, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ response_string_json = json.loads(response_string.content.decode('utf-8'))
+
+ voip_number.twilio_app_id = response_string_json['sid']
+
+ voip_number.capability_token_url = request.httprequest.host_url.replace("http://","//") + 'twilio/capability-token/' + str(voip_number.id)
+
+ #Setup the Voice URL
+ payload = {'VoiceUrl': str(request.httprequest.host_url + "twilio/voice/route")}
+ requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers/" + sid, data=payload, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ return {
+ 'name': 'Twilio Numbers',
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'voip.number',
+ 'type': 'ir.actions.act_window'
+ }
+
+ else:
+ raise UserError("Bad Credentials")
+
+ @api.multi
+ def fetch_call_history(self):
+ self.ensure_one()
+
+ payload = {}
+ if self.twilio_last_check_date:
+ my_time = datetime.strptime(self.twilio_last_check_date,'%Y-%m-%d %H:%M:%S')
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json?StartTime%3E=" + my_time.strftime('%Y-%m-%d'), auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ else:
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ #Loop through all pages until you have reached the last page
+ while True:
+
+ json_call_list = json.loads(response_string.text)
+
+ if 'calls' not in json_call_list:
+ raise UserError("No calls to import")
+
+ for call in json_call_list['calls']:
+
+ #Don't reimport the same record
+ if self.env['voip.call'].search([('twilio_sid','=',call['sid'])]):
+ continue
+
+ from_partner = False
+ from_address = call['from']
+ to_address = call['to']
+ to_partner = False
+ create_dict = {}
+
+ create_dict['twilio_account_id'] = self.id
+
+ if 'price' in call:
+ if call['price'] is not None:
+ if float(call['price']) != 0.0:
+ create_dict['currency_id'] = self.env['res.currency'].search([('name','=', call['price_unit'])])[0].id
+ create_dict['price'] = -1.0 * float(call['price'])
+
+ #Format the from address and find the from partner
+ if from_address is not None:
+ from_address = from_address.replace(";region=gll","")
+ from_address = from_address.replace(":5060","")
+ from_address = from_address.replace("sip:","")
+
+ if "+" in from_address:
+ #Mobiles should conform to E.164
+ from_partner = self.env['res.partner'].search([('mobile','=',from_address)])
+ else:
+ if "@" not in from_address and "@" in to_address:
+ #Get the full aor based on the domain of the to address
+ domain = re.findall(r'@(.*?)', to_address)[0].replace(":5060","")
+ from_address = from_address + "@" + domain
+
+ from_partner = self.env['res.partner'].search([('sip_address','=', from_address)])
+
+ if from_partner:
+ #Use the first found partner
+ create_dict['from_partner_id'] = from_partner[0].id
+ create_dict['from_address'] = from_address
+
+ #Format the to address and find the to partner
+ if to_address is not None:
+ to_address = to_address.replace(";region=gll","")
+ to_address = to_address.replace(":5060","")
+ to_address = to_address.replace("sip:","")
+
+ if "+" in to_address:
+ #Mobiles should conform to E.164
+ to_partner = self.env['res.partner'].search([('mobile','=',to_address)])
+ else:
+
+ if "@" not in to_address and "@" in from_address:
+ #Get the full aor based on the domain of the from address
+ domain = re.findall(r'@(.*?)', from_address)[0].replace(":5060","")
+ to_address = to_address + "@" + domain
+
+ to_partner = self.env['res.partner'].search([('sip_address','=', to_address)])
+
+ if to_partner:
+ #Use the first found partner
+ create_dict['to_partner_id'] = to_partner[0].id
+ create_dict['to_address'] = to_address
+
+ #Have to map the Twilio call status to the one in the core module
+ twilio_status = call['status']
+ if twilio_status == "queued":
+ create_dict['status'] = "pending"
+ elif twilio_status == "ringing":
+ create_dict['status'] = "pending"
+ elif twilio_status == "in-progress":
+ create_dict['status'] = "active"
+ elif twilio_status == "canceled":
+ create_dict['status'] = "cancelled"
+ elif twilio_status == "completed":
+ create_dict['status'] = "over"
+ elif twilio_status == "failed":
+ create_dict['status'] = "failed"
+ elif twilio_status == "busy":
+ create_dict['status'] = "busy"
+ elif twilio_status == "no-answer":
+ create_dict['status'] = "failed"
+
+ create_dict['start_time'] = call['start_time']
+ create_dict['end_time'] = call['end_time']
+
+ create_dict['twilio_sid'] = call['sid']
+ #Duration includes the ring time
+ create_dict['duration'] = call['duration']
+
+ #Fetch the recording if it exists
+ #if 'subresource_uris' in call:
+ # if 'recordings' in call['subresource_uris']:
+ # if call['subresource_uris']['recordings'] != '':
+ # recording_response = requests.get("https://api.twilio.com" + call['subresource_uris']['recordings'], auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ # recording_json = json.loads(recording_response.text)
+ # for recording in recording_json['recordings']:
+ # create_dict['twilio_call_recording_uri'] = "https://api.twilio.com" + recording['uri'].replace(".json",".mp3")
+
+ self.env['voip.call'].create(create_dict)
+
+ # Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ _logger.error(next_page_uri)
+ if next_page_uri is not None:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, data=payload, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ else:
+ break;
+
+ #After finish looping all paage then set the last check date so we only get new messages next time
+ self.twilio_last_check_date = datetime.utcnow()
+
+ return {
+ 'name': 'Twilio Call History',
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'voip.call',
+ 'context': {'search_default_twilio_account_id': self.id},
+ 'type': 'ir.actions.act_window',
+ }
+
+ @api.multi
+ def generate_invoice_previous_month(self):
+ self.ensure_one()
+
+ if self.partner_id.id == False:
+ raise UserError("Please select a contact before creating the invoice")
+ return False
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Usage/Records/LastMonth.json", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_usage_list = json.loads(response_string.text)
+
+ invoice = self.env['account.invoice'].create({
+ 'partner_id': self.partner_id.id,
+ 'account_id': self.partner_id.property_account_receivable_id.id,
+ 'fiscal_position_id': self.partner_id.property_account_position_id.id,
+ })
+
+ _logger.error( response_string.text )
+ start_date_string = json_usage_list['usage_records'][0]['start_date']
+ end_date_string = json_usage_list['usage_records'][0]['end_date']
+ invoice.comment = "Twilio Bill " + start_date_string + " - " + end_date_string
+
+ while True:
+
+ for usage_record in json_usage_list['usage_records']:
+ category = usage_record['category']
+
+ #Exclude the umbrella categories otherwise the pricing will overlap
+ if float(usage_record['price']) > 0 and category != "calls" and category != "sms" and category != "phonenumbers" and category != "recordings" and category != "transcriptions" and category != "trunking-origination" and category != "totalprice":
+
+ line_values = {
+ 'name': usage_record['description'],
+ 'price_unit': float(usage_record['price']) * self.margin,
+ 'invoice_id': invoice.id,
+ 'account_id': invoice.journal_id.default_credit_account_id.id
+ }
+
+ invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
+
+ invoice.compute_taxes()
+
+ #For debugging
+ if category == "totalprice":
+ invoice.comment = invoice.comment + " (Total $" + usage_record['price'] + " USD) (Marign: $" + str(float(usage_record['price']) * self.margin) + " USD)"
+ _logger.error(usage_record['price'])
+
+ #Get the next page if there is one
+ next_page_uri = json_usage_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_usage_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ #Also generate a call log report
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json?StartTime%3E=" + start_date_string + "&EndTime%3C=" + end_date_string, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+
+ #Loop through all pages until you have reached the end
+ call_total = 0
+ while True:
+
+ for call in json_call_list['calls']:
+ if call['price']:
+ if float(call['price']) != 0:
+ #Format the date depending on the language of the contact
+ call_start = parser.parse(call['start_time'])
+ call_cost = -1.0 * float(call['price']) * self.margin
+ call_total += call_cost
+
+ m, s = divmod( int(call['duration']) , 60)
+ h, m = divmod(m, 60)
+ self.env['account.invoice.voip.history'].create({'invoice_id': invoice.id, 'start_time': call_start, 'duration': "%d:%02d:%02d" % (h, m, s), 'cost': call_cost, 'to_address': call['to'] })
+
+ #Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ return {
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice',
+ 'type': 'ir.actions.act_window',
+ 'res_id': invoice.id,
+ 'view_id': self.env['ir.model.data'].get_object('account', 'invoice_form').id
+ }
+
+class VoipTwilioInvoice(models.Model):
+
+ _name = "voip.twilio.invoice"
+ _description = "Twilio Account Invoice"
+
+ twilio_account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ start_date = fields.Date(string="Start Date")
+ end_date = fields.Date(string="End Date")
+ margin = fields.Float(string="Margin")
+ generate_call_report = fields.Boolean(string="Generate Call Report", default=True)
+
+ @api.multi
+ def generate_invoice(self):
+ self.ensure_one()
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_id.twilio_account_sid + "/Calls.json?StartTime%3E=" + self.start_date + "&EndTime%3C=" + self.end_date, auth=(str(self.twilio_account_id.twilio_account_sid), str(self.twilio_account_id.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+
+ invoice = self.env['account.invoice'].create({
+ 'partner_id': self.twilio_account_id.partner_id.id,
+ 'account_id': self.twilio_account_id.partner_id.property_account_receivable_id.id,
+ 'fiscal_position_id': self.twilio_account_id.partner_id.property_account_position_id.id,
+ })
+
+ #Loop through all pages until you have reached the end
+ call_total = 0
+ duration_total = 0
+ while True:
+
+ for call in json_call_list['calls']:
+ if call['price']:
+ if float(call['price']) != 0:
+ #Format the date depending on the language of the contact
+ call_start = parser.parse(call['start_time'])
+ call_cost = -1.0 * float(call['price']) * self.margin
+ call_total += call_cost
+ duration_total += float(call['duration'])
+
+ if self.generate_call_report:
+ m, s = divmod( int(call['duration']) , 60)
+ h, m = divmod(m, 60)
+ self.env['account.invoice.voip.history'].create({'invoice_id': invoice.id, 'start_time': call_start, 'duration': "%d:%02d:%02d" % (h, m, s), 'cost': call_cost, 'to_address': call['to'] })
+
+ #Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_id.twilio_account_sid), str(self.twilio_account_id.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ #Put Total call time and cost into invoice for the report or pdf attachment
+ invoice.voip_total_call_cost = round(call_total,2)
+ m, s = divmod( int(duration_total) , 60)
+ h, m = divmod(m, 60)
+ invoice.voip_total_call_time = "%d:%02d:%02d" % (h, m, s)
+ invoice.twilio_invoice = True
+
+ line_values = {
+ 'name': "VOIP Calls " + self.start_date + " - " + self.end_date,
+ 'price_unit': call_total,
+ 'invoice_id': invoice.id,
+ 'account_id': invoice.journal_id.default_credit_account_id.id
+ }
+
+ invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
+
+ invoice.compute_taxes()
+
+ return {
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice',
+ 'type': 'ir.actions.act_window',
+ 'res_id': invoice.id,
+ 'view_id': self.env['ir.model.data'].get_object('account', 'invoice_form').id
+ }
+
+class AccountInvoiceVoip(models.Model):
+
+ _inherit = "account.invoice"
+
+ voip_history_ids = fields.One2many('account.invoice.voip.history', 'invoice_id', string="VOIP Call History")
+ twilio_invoice = fields.Boolean(string="Is Twilio Invoice", help="Allows Twilio specific changes to invoice template")
+ voip_total_call_time = fields.Char(string="Voip Total Call Time")
+ voip_total_call_cost = fields.Float(string="Voip Total Call Cost")
+
+class AccountInvoiceVoipHistory(models.Model):
+
+ _name = "account.invoice.voip.history"
+ _description = "Twilio Account Invoice History"
+
+ invoice_id = fields.Many2one('account.invoice', string="Invoice")
+ start_time = fields.Datetime(string="Start Time")
+ duration = fields.Char(string="Duration")
+ to_address = fields.Char(string="To Address")
+ cost = fields.Float(string="Cost")
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/security/ir.model.access.csv b/OTHER/voip_sip_webrtc_twilio_self/security/ir.model.access.csv
new file mode 100644
index 000000000..f27512e77
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/security/ir.model.access.csv
@@ -0,0 +1,6 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_voip_call_wizard","access voip.call.wizard","model_voip_call_wizard","base.group_user",1,1,1,0
+"access_voip_number","access voip.number","model_voip_number","base.group_user",1,1,1,1
+"access_voip_twilio","access voip.twilio","model_voip_twilio","base.group_user",1,1,1,1
+"access_voip_twilio_invoice","access voip.twilio.invoice","model_voip_twilio_invoice","base.group_user",1,1,1,1
+"access_account_invoice_voip_history","access account.invoice.voip.history","model_account_invoice_voip_history","base.group_user",1,1,1,1
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/description/1.jpg b/OTHER/voip_sip_webrtc_twilio_self/static/description/1.jpg
new file mode 100644
index 000000000..485194bb6
Binary files /dev/null and b/OTHER/voip_sip_webrtc_twilio_self/static/description/1.jpg differ
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/description/2.jpg b/OTHER/voip_sip_webrtc_twilio_self/static/description/2.jpg
new file mode 100644
index 000000000..639dc93d3
Binary files /dev/null and b/OTHER/voip_sip_webrtc_twilio_self/static/description/2.jpg differ
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/description/3.jpg b/OTHER/voip_sip_webrtc_twilio_self/static/description/3.jpg
new file mode 100644
index 000000000..bd4f8fa04
Binary files /dev/null and b/OTHER/voip_sip_webrtc_twilio_self/static/description/3.jpg differ
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/description/icon.png b/OTHER/voip_sip_webrtc_twilio_self/static/description/icon.png
new file mode 100644
index 000000000..eb680ef42
Binary files /dev/null and b/OTHER/voip_sip_webrtc_twilio_self/static/description/icon.png differ
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/description/index.html b/OTHER/voip_sip_webrtc_twilio_self/static/description/index.html
new file mode 100644
index 000000000..34df92bee
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/static/description/index.html
@@ -0,0 +1,40 @@
+
+
Description
+Provides real time call functionality as well Twilio XML for automated calls
+
+
Import Call Log
+
+Import log of calls made using external SIP clients
+Access the import button under CRM->VOIP->Twilio Accounts
+
+
Call mobiles from your browser
+
+Manually call mobile phones and talk in real time.
+
Setup
+1. Configure your environment so Twilio can access your database from the public internet
+2. Enter Twilio account details under CRM->Voip->Twilio Accounts
+3. Click "Setup Numbers" button
+4. Go to any contact and select Twilio Call Mobile from the action menu at the top
+
+If your database can not be directly accessed by Twilio you can create a Twilio app for handling capability token generation by following this guide
+https://www.twilio.com/docs/voice/client/javascript/quickstart
+
+
Answer calls from your browser
+
+Go to CRM->Voip->Twilio Accounts and hit the setup numbers button this will automatically point the number to your server.
+Then go into CRM->Voip->Stored Number and assign users to a number, when a call is made they can answer it within there browser.
+IMPORTANT In order for this feature to work you will need your database to have a publicly accessable url.
+
+
Serve Twilio XML for SIP Calls
+
+Serves out Twilio XML which directs calls to the callee
+
Setup
+1. Add "[domain]/twilio/voice" under the request URL
+2. Setup your Twilio SIP account
+
+Known Issues
+Twilio Apps points to http url on Odoo systems behind a reverse proxy
+Does not work in Google Chrome at all
+
+Find a bug or need support? send an email to steven@sythiltech.com.au
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/src/js/quickstart.js b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/quickstart.js
new file mode 100644
index 000000000..83655d86e
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/quickstart.js
@@ -0,0 +1,146 @@
+$(function () {
+ var speakerDevices = document.getElementById('speaker-devices');
+ var ringtoneDevices = document.getElementById('ringtone-devices');
+ var outputVolumeBar = document.getElementById('output-volume');
+ var inputVolumeBar = document.getElementById('input-volume');
+ var volumeIndicators = document.getElementById('volume-indicators');
+
+ console.log('Requesting Capability Token...');
+ $.getJSON('https://cute-land-1506.twil.io/capability-token')
+ .done(function (data) {
+ console.log('Got a token.');
+ console.log('Token: ' + data.token);
+
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+
+ Twilio.Device.ready(function (device) {
+ console.log('Twilio.Device Ready!');
+ //document.getElementById('call-controls').style.display = 'block';
+ });
+
+
+
+
+
+
+
+
+
+ setClientNameUI(data.identity);
+
+ Twilio.Device.audio.on('deviceChange', updateAllDevices);
+
+ // Show audio selection UI if it is supported by the browser.
+ if (Twilio.Device.audio.isSelectionSupported) {
+ document.getElementById('output-selection').style.display = 'block';
+ }
+ })
+ .fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ // Bind button to make call
+ button_call = document.getElementById('button-call');
+ if (typeof(button_call) != 'undefined' && button_call != null)
+ {
+ button_call.onclick = function () {
+ // get the phone number to connect the call to
+ var params = {
+ To: document.getElementById('phone-number').value
+ };
+
+ console.log('Calling ' + params.To + '...');
+ Twilio.Device.connect(params);
+ };
+ }
+
+
+
+
+ get_devices = document.getElementById('get-devices');
+ if (typeof(get_devices) != 'undefined' && get_devices != null)
+ {
+ get_devices.onclick = function() {
+ navigator.mediaDevices.getUserMedia({ audio: true })
+ .then(updateAllDevices);
+ };
+ }
+
+/*
+ speakerDevices.addEventListener('change', function() {
+ var selectedDevices = [].slice.call(speakerDevices.children)
+ .filter(function(node) { return node.selected; })
+ .map(function(node) { return node.getAttribute('data-id'); });
+
+ Twilio.Device.audio.speakerDevices.set(selectedDevices);
+ });
+*/
+
+/*
+ ringtoneDevices.addEventListener('change', function() {
+ var selectedDevices = [].slice.call(ringtoneDevices.children)
+ .filter(function(node) { return node.selected; })
+ .map(function(node) { return node.getAttribute('data-id'); });
+
+ Twilio.Device.audio.ringtoneDevices.set(selectedDevices);
+ });
+*/
+
+ function bindVolumeIndicators(connection) {
+ connection.volume(function(inputVolume, outputVolume) {
+ var inputColor = 'red';
+ if (inputVolume < .50) {
+ inputColor = 'green';
+ } else if (inputVolume < .75) {
+ inputColor = 'yellow';
+ }
+
+ //inputVolumeBar.style.width = Math.floor(inputVolume * 300) + 'px';
+ //inputVolumeBar.style.background = inputColor;
+
+ var outputColor = 'red';
+ if (outputVolume < .50) {
+ outputColor = 'green';
+ } else if (outputVolume < .75) {
+ outputColor = 'yellow';
+ }
+
+ //outputVolumeBar.style.width = Math.floor(outputVolume * 300) + 'px';
+ //outputVolumeBar.style.background = outputColor;
+ });
+ }
+
+ function updateAllDevices() {
+ console.log("Update Devices");
+ //updateDevices(speakerDevices, Twilio.Device.audio.speakerDevices.get());
+ //updateDevices(ringtoneDevices, Twilio.Device.audio.ringtoneDevices.get());
+ }
+});
+
+// Update the available ringtone and speaker devices
+function updateDevices(selectEl, selectedDevices) {
+ selectEl.innerHTML = '';
+ Twilio.Device.audio.availableOutputDevices.forEach(function(device, id) {
+ var isActive = (selectedDevices.size === 0 && id === 'default');
+ selectedDevices.forEach(function(device) {
+ if (device.deviceId === id) { isActive = true; }
+ });
+
+ var option = document.createElement('option');
+ option.label = device.label;
+ option.setAttribute('data-id', id);
+ if (isActive) {
+ option.setAttribute('selected', 'selected');
+ }
+ selectEl.appendChild(option);
+ });
+}
+
+
+// Set the client name in the UI
+function setClientNameUI(clientName) {
+ console.log("Set Client Name: " + clientName);
+ //var div = document.getElementById('client-name');
+ //div.innerHTML = 'Your client name: ' + clientName + '';
+}
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/src/js/twilio.min.js b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/twilio.min.js
new file mode 100644
index 000000000..9b79e0aad
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/twilio.min.js
@@ -0,0 +1,55 @@
+/*! twilio-client.js 1.4.32
+
+The following license applies to all parts of this software except as
+documented below.
+
+ Copyright 2015 Twilio, inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+This software includes rtcpeerconnection-shim under the following (BSD 3-Clause) license.
+
+ Copyright (c) 2017 Philipp Hancke. All rights reserved.
+
+ Copyright (c) 2014, The WebRTC project authors. All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Philipp Hancke nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ */
+(function(root){var bundle=function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i=METRICS_BATCH_SIZE){publishMetrics()}});function formatPayloadForEA(warningData){var payloadData={threshold:warningData.threshold.value};if(warningData.values){payloadData.values=warningData.values.map(function(value){if(typeof value==="number"){return Math.round(value*100)/100}return value})}else if(warningData.value){payloadData.value=warningData.value}return{data:payloadData}}function reemitWarning(wasCleared,warningData){var groupPrefix=/^audio/.test(warningData.name)?"audio-level-":"network-quality-";var groupSuffix=wasCleared?"-cleared":"-raised";var groupName=groupPrefix+"warning"+groupSuffix;var warningPrefix=WARNING_PREFIXES[warningData.threshold.name];var warningName=warningPrefix+WARNING_NAMES[warningData.name];if(warningName==="constant-audio-input-level"&&self.isMuted()){return}var level=wasCleared?"info":"warning";if(warningName==="constant-audio-output-level"){level="info"}publisher.post(level,groupName,warningName,formatPayloadForEA(warningData),self);if(warningName!=="constant-audio-output-level"){var emitName=wasCleared?"warning-cleared":"warning";self.emit(emitName,warningName)}}monitor.on("warning-cleared",reemitWarning.bind(null,true));monitor.on("warning",reemitWarning.bind(null,false));this.mediaStream=new this.options.MediaStream(device,getUserMedia);this.on("volume",function(inputVolume,outputVolume){self._latestInputVolume=inputVolume;self._latestOutputVolume=outputVolume});this.mediaStream.onvolume=this.emit.bind(this,"volume");this.mediaStream.oniceconnectionstatechange=function(state){var level=state==="failed"?"error":"debug";publisher.post(level,"ice-connection-state",state,null,self)};this.mediaStream.onicegatheringstatechange=function(state){publisher.debug("signaling-state",state,null,self)};this.mediaStream.onsignalingstatechange=function(state){publisher.debug("signaling-state",state,null,self)};this.mediaStream.ondisconnect=function(msg){self.log(msg);publisher.warn("network-quality-warning-raised","ice-connectivity-lost",{message:msg},self);self.emit("warning","ice-connectivity-lost")};this.mediaStream.onreconnect=function(msg){self.log(msg);publisher.info("network-quality-warning-cleared","ice-connectivity-lost",{message:msg},self);self.emit("warning-cleared","ice-connectivity-lost")};this.mediaStream.onerror=function(e){if(e.disconnect===true){self._disconnect(e.info&&e.info.message)}var error={code:e.info.code,message:e.info.message||"Error with mediastream",info:e.info,connection:self};self.log("Received an error from MediaStream:",e);self.emit("error",error)};this.mediaStream.onopen=function(){if(self._status==="open"){return}else if(self._status==="ringing"||self._status==="connecting"){self.mute(false);self._maybeTransitionToOpen()}else{self.mediaStream.close()}};this.mediaStream.onclose=function(){self._status="closed";if(device.sounds.__dict__.disconnect){device.soundcache.get("disconnect").play()}monitor.disable();publishMetrics();self.emit("disconnect",self)};this.outboundConnectionId=twutil.generateConnectionUUID();this.pstream=device.stream;this._onCancel=function(payload){var callsid=payload.callsid;if(self.parameters.CallSid===callsid){self._status="closed";self.emit("cancel");self.pstream.removeListener("cancel",self._onCancel)}};if(this.pstream){this.pstream.on("cancel",this._onCancel);this.pstream.on("ringing",this._onRinging)}this.on("error",function(error){publisher.error("connection","error",{code:error.code,message:error.message},self);if(self.pstream&&self.pstream.status==="disconnected"){cleanupEventListeners(self)}});this.on("disconnect",function(){cleanupEventListeners(self)});return this}util.inherits(Connection,EventEmitter);Connection.toString=function(){return"[Twilio.Connection class]"};Connection.prototype.toString=function(){return"[Twilio.Connection instance]"};Connection.prototype.sendDigits=function(digits){if(digits.match(/[^0-9*#w]/)){throw new Exception("Illegal character passed into sendDigits")}var sequence=[];digits.split("").forEach(function(digit){var dtmf=digit!=="w"?"dtmf"+digit:"";if(dtmf==="dtmf*")dtmf="dtmfs";if(dtmf==="dtmf#")dtmf="dtmfh";sequence.push(dtmf)});(function playNextDigit(soundCache){var digit=sequence.shift();soundCache.get(digit).play();if(sequence.length){setTimeout(playNextDigit.bind(null,soundCache),200)}})(this._soundcache);var dtmfSender=this.mediaStream.getOrCreateDTMFSender();function insertDTMF(dtmfs){if(!dtmfs.length){return}var dtmf=dtmfs.shift();if(dtmf.length){dtmfSender.insertDTMF(dtmf,DTMF_TONE_DURATION,DTMF_INTER_TONE_GAP)}setTimeout(insertDTMF.bind(null,dtmfs),DTMF_PAUSE_DURATION)}if(dtmfSender){if(!("canInsertDTMF"in dtmfSender)||dtmfSender.canInsertDTMF){this.log("Sending digits using RTCDTMFSender");insertDTMF(digits.split("w"));return}this.log("RTCDTMFSender cannot insert DTMF")}this.log("Sending digits over PStream");var payload;if(this.pstream!==null&&this.pstream.status!=="disconnected"){payload={dtmf:digits,callsid:this.parameters.CallSid};this.pstream.publish("dtmf",payload)}else{payload={error:{}};var error={code:payload.error.code||31e3,message:payload.error.message||"Could not send DTMF: Signaling channel is disconnected",connection:this};this.emit("error",error)}};Connection.prototype.status=function(){return this._status};Connection.prototype.mute=function(shouldMute){if(typeof shouldMute==="undefined"){shouldMute=true;this.log.deprecated(".mute() is deprecated. Please use .mute(true) or .mute(false) "+"to mute or unmute a call instead.")}else if(typeof shouldMute==="function"){this.addListener("mute",shouldMute);return}if(this.isMuted()===shouldMute){return}this.mediaStream.mute(shouldMute);var isMuted=this.isMuted();this._publisher.info("connection",isMuted?"muted":"unmuted",null,this);this.emit("mute",isMuted,this)};Connection.prototype.isMuted=function(){return this.mediaStream.isMuted};Connection.prototype.unmute=function(){this.log.deprecated(".unmute() is deprecated. Please use .mute(false) to unmute a call instead.");this.mute(false)};Connection.prototype.accept=function(handler){if(typeof handler==="function"){this.addListener("accept",handler);return}if(this._status!=="pending"){return}var audioConstraints=handler||this.options.audioConstraints;var self=this;this._status="connecting";function connect_(){if(self._status!=="connecting"){cleanupEventListeners(self);self.mediaStream.close();return}var pairs=[];for(var key in self.message){pairs.push(encodeURIComponent(key)+"="+encodeURIComponent(self.message[key]))}function onLocalAnswer(pc){self._publisher.info("connection","accepted-by-local",null,self);self._monitor.enable(pc)}function onRemoteAnswer(pc){self._publisher.info("connection","accepted-by-remote",null,self);self._monitor.enable(pc)}var sinkIds=typeof self.options.getSinkIds==="function"&&self.options.getSinkIds();if(Array.isArray(sinkIds)){self.mediaStream._setSinkIds(sinkIds).catch(function(){})}var params=pairs.join("&");if(self._direction==="INCOMING"){self._isAnswered=true;self.mediaStream.answerIncomingCall(self.parameters.CallSid,self.options.offerSdp,self.options.rtcConstraints,self.options.iceServers,onLocalAnswer)}else{self.pstream.once("answer",self._onAnswer.bind(self));self.mediaStream.makeOutgoingCall(self.pstream.token,params,self.outboundConnectionId,self.options.rtcConstraints,self.options.iceServers,onRemoteAnswer)}self._onHangup=function(payload){if(payload.callsid&&(self.parameters.CallSid||self.outboundConnectionId)){if(payload.callsid!==self.parameters.CallSid&&payload.callsid!==self.outboundConnectionId){return}}else if(payload.callsid){return}self.log("Received HANGUP from gateway");if(payload.error){var error={code:payload.error.code||31e3,message:payload.error.message||"Error sent from gateway in HANGUP",connection:self};self.log("Received an error from the gateway:",error);self.emit("error",error)}self.sendHangup=false;self._publisher.info("connection","disconnected-by-remote",null,self);self._disconnect(null,true);cleanupEventListeners(self)};self.pstream.addListener("hangup",self._onHangup)}var inputStream=typeof this.options.getInputStream==="function"&&this.options.getInputStream();var promise=inputStream?this.mediaStream.setInputTracksFromStream(inputStream):this.mediaStream.openWithConstraints(audioConstraints);promise.then(function(){self._publisher.info("get-user-media","succeeded",{data:{audioConstraints:audioConstraints}},self);connect_()},function(error){var message;var code;if(error.code&&error.code===error.PERMISSION_DENIED||error.name&&error.name==="PermissionDeniedError"){code=31208;message="User denied access to microphone, or the web browser did not allow microphone "+"access at this address.";self._publisher.error("get-user-media","denied",{data:{audioConstraints:audioConstraints,error:error}},self)}else{code=31201;message="Error occurred while accessing microphone: "+error.name+(error.message?" ("+error.message+")":"");self._publisher.error("get-user-media","failed",{data:{audioConstraints:audioConstraints,error:error}},self)}return self._die(message,code)})};Connection.prototype.reject=function(handler){if(typeof handler==="function"){this.addListener("reject",handler);return}if(this._status!=="pending"){return}var payload={callsid:this.parameters.CallSid};this.pstream.publish("reject",payload);this.emit("reject");this.mediaStream.reject(this.parameters.CallSid);this._publisher.info("connection","rejected-by-local",null,this)};Connection.prototype.ignore=function(handler){if(typeof handler==="function"){this.addListener("cancel",handler);return}if(this._status!=="pending"){return}this._status="closed";this.emit("cancel");this.mediaStream.ignore(this.parameters.CallSid);this._publisher.info("connection","ignored-by-local",null,this)};Connection.prototype.cancel=function(handler){this.log.deprecated(".cancel() is deprecated. Please use .ignore() instead.");this.ignore(handler)};Connection.prototype.disconnect=function(handler){if(typeof handler==="function"){this.addListener("disconnect",handler);return}this._disconnect()};Connection.prototype._disconnect=function(message,remote){message=typeof message==="string"?message:null;if(this._status!=="open"&&this._status!=="connecting"&&this._status!=="ringing"){return}this.log("Disconnecting...");if(this.pstream!==null&&this.pstream.status!=="disconnected"&&this.sendHangup){var callId=this.parameters.CallSid||this.outboundConnectionId;if(callId){var payload={callsid:callId};if(message){payload.message=message}this.pstream.publish("hangup",payload)}}cleanupEventListeners(this);this.mediaStream.close();if(!remote){this._publisher.info("connection","disconnected-by-local",null,this)}};Connection.prototype.error=function(handler){if(typeof handler==="function"){this.addListener("error",handler);return}};Connection.prototype._die=function(message,code){this._disconnect();this.emit("error",{message:message,code:code})};Connection.prototype._setCallSid=function _setCallSid(payload){var callSid=payload.callsid;if(!callSid){return}this.parameters.CallSid=callSid;this.mediaStream.callSid=callSid};Connection.prototype._setSinkIds=function _setSinkIds(sinkIds){return this.mediaStream._setSinkIds(sinkIds)};Connection.prototype._setInputTracksFromStream=function _setInputTracksFromStream(stream){return this.mediaStream.setInputTracksFromStream(stream)};Connection.prototype._onRinging=function(payload){this._setCallSid(payload);if(this._status!=="connecting"&&this._status!=="ringing"){return}var hasEarlyMedia=!!payload.sdp;if(this.options.enableRingingState){this._status="ringing";this._publisher.info("connection","outgoing-ringing",{hasEarlyMedia:hasEarlyMedia},this);this.emit("ringing",hasEarlyMedia)}else if(hasEarlyMedia){this._onAnswer(payload)}};Connection.prototype._onAnswer=function(payload){if(this._isAnswered){return}this._setCallSid(payload);this._isAnswered=true;this._maybeTransitionToOpen()};Connection.prototype._maybeTransitionToOpen=function(){if(this.mediaStream&&this.mediaStream.status==="open"&&this._isAnswered){this._status="open";this.emit("accept",this)}};Connection.prototype.volume=function(handler){if(!window||!window.AudioContext&&!window.webkitAudioContext){console.warn("This browser does not support Connection.volume")}else if(typeof handler==="function"){this.on("volume",handler)}};Connection.prototype.getLocalStream=function getLocalStream(){return this.mediaStream&&this.mediaStream.stream};Connection.prototype.getRemoteStream=function getRemoteStream(){return this.mediaStream&&this.mediaStream._remoteStream};Connection.prototype.postFeedback=function(score,issue){if(typeof score==="undefined"||score===null){return this._postFeedbackDeclined()}if(FEEDBACK_SCORES.indexOf(score)===-1){throw new Error("Feedback score must be one of: "+FEEDBACK_SCORES)}if(typeof issue!=="undefined"&&issue!==null&&FEEDBACK_ISSUES.indexOf(issue)===-1){throw new Error("Feedback issue must be one of: "+FEEDBACK_ISSUES)}return this._publisher.post("info","feedback","received",{quality_score:score,issue_name:issue},this,true)};Connection.prototype._postFeedbackDeclined=function(){return this._publisher.post("info","feedback","received-none",null,this,true)};Connection.prototype._getTempCallSid=function(){return this.outboundConnectionId};Connection.prototype._getRealCallSid=function(){return/^TJ/.test(this.parameters.CallSid)?null:this.parameters.CallSid};function cleanupEventListeners(connection){function cleanup(){if(!connection.pstream){return}connection.pstream.removeListener("answer",connection._onAnswer);connection.pstream.removeListener("cancel",connection._onCancel);connection.pstream.removeListener("hangup",connection._onHangup);connection.pstream.removeListener("ringing",connection._onRinging)}cleanup();setTimeout(cleanup,0)}exports.Connection=Connection},{"./log":8,"./rtc":14,"./rtc/monitor":16,"./util":28,events:43,util:55}],6:[function(require,module,exports){var AudioHelper=require("./audiohelper");var EventEmitter=require("events").EventEmitter;var util=require("util");var log=require("./log");var twutil=require("./util");var rtc=require("./rtc");var Publisher=require("./eventpublisher");var Options=require("./options").Options;var Sound=require("./sound");var Connection=require("./connection").Connection;var getUserMedia=require("./rtc/getusermedia");var PStream=require("./pstream").PStream;var REG_INTERVAL=3e4;var RINGTONE_PLAY_TIMEOUT=2e3;function Device(token,options){if(!Device.isSupported){throw new twutil.Exception("twilio.js 1.3+ SDKs require WebRTC/ORTC browser support. "+"For more information, see . "+"If you have any questions about this announcement, please contact "+"Twilio Support at .")}if(!(this instanceof Device)){return new Device(token,options)}twutil.monitorEventEmitter("Twilio.Device",this);if(!token){throw new twutil.Exception("Capability token is not valid or missing.")}options=options||{};var origOptions={};for(var i in options){origOptions[i]=options[i]}var DefaultSound=options.soundFactory||Sound;var defaults={logPrefix:"[Device]",eventgw:"eventgw.twilio.com",Sound:DefaultSound,connectionFactory:Connection,pStreamFactory:PStream,noRegister:false,closeProtection:false,secureSignaling:true,warnings:true,audioConstraints:true,iceServers:[],region:"gll",dscp:true,sounds:{}};options=options||{};for(var prop in defaults){if(prop in options)continue;options[prop]=defaults[prop]}if(options.dscp){options.rtcConstraints={optional:[{googDscp:true}]}}else{options.rtcConstraints={}}this.options=options;this.token=token;this._status="offline";this._region="offline";this._connectionSinkIds=["default"];this._connectionInputStream=null;this.connections=[];this._activeConnection=null;this.sounds=new Options({incoming:true,outgoing:true,disconnect:true});log.mixinLog(this,this.options.logPrefix);this.log.enabled=this.options.debug;var regions={gll:"chunderw-vpc-gll.twilio.com",au1:"chunderw-vpc-gll-au1.twilio.com",br1:"chunderw-vpc-gll-br1.twilio.com",de1:"chunderw-vpc-gll-de1.twilio.com",ie1:"chunderw-vpc-gll-ie1.twilio.com",jp1:"chunderw-vpc-gll-jp1.twilio.com",sg1:"chunderw-vpc-gll-sg1.twilio.com",us1:"chunderw-vpc-gll-us1.twilio.com","us1-tnx":"chunderw-vpc-gll-us1-tnx.twilio.com","us2-tnx":"chunderw-vpc-gll-us2-tnx.twilio.com","ie1-tnx":"chunderw-vpc-gll-ie1-tnx.twilio.com","us1-ix":"chunderw-vpc-gll-us1-ix.twilio.com","us2-ix":"chunderw-vpc-gll-us2-ix.twilio.com","ie1-ix":"chunderw-vpc-gll-ie1-ix.twilio.com"};var deprecatedRegions={au:"au1",br:"br1",ie:"ie1",jp:"jp1",sg:"sg1","us-va":"us1","us-or":"us1"};var region=options.region.toLowerCase();if(region in deprecatedRegions){this.log.deprecated("Region "+region+" is deprecated, please use "+deprecatedRegions[region]+".");region=deprecatedRegions[region]}if(!(region in regions)){throw new twutil.Exception("Region "+options.region+" is invalid. Valid values are: "+Object.keys(regions).join(", "))}options.chunderw="wss://"+(options.chunderw||regions[region||"gll"])+"/signal";this.soundcache=new Map;var a=typeof document!=="undefined"?document.createElement("audio"):{};var canPlayMp3;try{canPlayMp3=a.canPlayType&&!!a.canPlayType("audio/mpeg").replace(/no/,"")}catch(e){canPlayMp3=false}var canPlayVorbis;try{canPlayVorbis=a.canPlayType&&!!a.canPlayType("audio/ogg;codecs='vorbis'").replace(/no/,"")}catch(e){canPlayVorbis=false}var ext="mp3";if(canPlayVorbis&&!canPlayMp3){ext="ogg"}var defaultSounds={incoming:{filename:"incoming",shouldLoop:true},outgoing:{filename:"outgoing",maxDuration:3e3},disconnect:{filename:"disconnect",maxDuration:3e3},dtmf1:{filename:"dtmf-1",maxDuration:1e3},dtmf2:{filename:"dtmf-2",maxDuration:1e3},dtmf3:{filename:"dtmf-3",maxDuration:1e3},dtmf4:{filename:"dtmf-4",maxDuration:1e3},dtmf5:{filename:"dtmf-5",maxDuration:1e3},dtmf6:{filename:"dtmf-6",maxDuration:1e3},dtmf7:{filename:"dtmf-7",maxDuration:1e3},dtmf8:{filename:"dtmf-8",maxDuration:1e3},dtmf9:{filename:"dtmf-9",maxDuration:1e3},dtmf0:{filename:"dtmf-0",maxDuration:1e3},dtmfs:{filename:"dtmf-star",maxDuration:1e3},dtmfh:{filename:"dtmf-hash",maxDuration:1e3}};var base=twutil.getTwilioRoot()+"sounds/releases/"+twutil.getSoundVersion()+"/";for(var name_1 in defaultSounds){var soundDef=defaultSounds[name_1];var defaultUrl=base+soundDef.filename+"."+ext+"?cache=1_4_23";var soundUrl=options.sounds[name_1]||defaultUrl;var sound=new this.options.Sound(name_1,soundUrl,{maxDuration:soundDef.maxDuration,minDuration:soundDef.minDuration,shouldLoop:soundDef.shouldLoop,audioContext:this.options.disableAudioContextSounds?null:Device.audioContext});this.soundcache.set(name_1,sound)}var self=this;function createDefaultPayload(connection){var payload={client_name:self._clientName,platform:rtc.getMediaEngine(),sdk_version:twutil.getReleaseVersion(),selected_region:self.options.region};function setIfDefined(propertyName,value){if(value){payload[propertyName]=value}}if(connection){setIfDefined("call_sid",connection._getRealCallSid());setIfDefined("temp_call_sid",connection._getTempCallSid());payload.direction=connection._direction}var stream=self.stream;if(stream){setIfDefined("gateway",stream.gateway);setIfDefined("region",stream.region)}return payload}var publisher=this._publisher=new Publisher("twilio-js-sdk",this.token,{host:this.options.eventgw,defaultPayload:createDefaultPayload});if(options.publishEvents===false){publisher.disable()}function updateSinkIds(type,sinkIds){var promise=type==="ringtone"?updateRingtoneSinkIds(sinkIds):updateSpeakerSinkIds(sinkIds);return promise.then(function(){publisher.info("audio",type+"-devices-set",{audio_device_ids:sinkIds},self._activeConnection)},function(error){publisher.error("audio",type+"-devices-set-failed",{audio_device_ids:sinkIds,message:error.message},self._activeConnection);throw error})}function updateSpeakerSinkIds(sinkIds){sinkIds=sinkIds.forEach?sinkIds:[sinkIds];Array.from(self.soundcache.entries()).forEach(function(entry){if(entry[0]!=="incoming"){entry[1].setSinkIds(sinkIds)}});self._connectionSinkIds=sinkIds;var connection=self._activeConnection;return connection?connection._setSinkIds(sinkIds):Promise.resolve()}function updateRingtoneSinkIds(sinkIds){return Promise.resolve(self.soundcache.get("incoming").setSinkIds(sinkIds))}function updateInputStream(inputStream){var connection=self._activeConnection;if(connection&&!inputStream){return Promise.reject(new Error("Cannot unset input device while a call is in progress."))}self._connectionInputStream=inputStream;return connection?connection._setInputTracksFromStream(inputStream):Promise.resolve()}var audio=this.audio=new AudioHelper(updateSinkIds,updateInputStream,getUserMedia,{audioContext:Device.audioContext,logEnabled:!!this.options.debug,logWarnings:!!this.options.warnings,soundOptions:this.sounds});audio.on("deviceChange",function(lostActiveDevices){var activeConnection=self._activeConnection;var deviceIds=lostActiveDevices.map(function(device){return device.deviceId});publisher.info("audio","device-change",{lost_active_device_ids:deviceIds},activeConnection);if(activeConnection){activeConnection.mediaStream._onInputDevicesChanged()}});this.mediaPresence={audio:!this.options.noRegister};this.register(this.token);var closeProtection=this.options.closeProtection;function confirmClose(event){if(self._activeConnection){var defaultMsg="A call is currently in-progress. "+"Leaving or reloading this page will end the call.";var confirmationMsg=closeProtection===true?defaultMsg:closeProtection;(event||window.event).returnValue=confirmationMsg;return confirmationMsg}}if(closeProtection){if(typeof window!=="undefined"){if(window.addEventListener){window.addEventListener("beforeunload",confirmClose)}else if(window.attachEvent){window.attachEvent("onbeforeunload",confirmClose)}}}function onClose(){self.disconnectAll()}if(typeof window!=="undefined"){if(window.addEventListener){window.addEventListener("unload",onClose)}else if(window.attachEvent){window.attachEvent("onunload",onClose)}}this.on("error",function(){});return this}util.inherits(Device,EventEmitter);function makeConnection(device,params,options){var defaults={getSinkIds:function(){return device._connectionSinkIds},getInputStream:function(){return device._connectionInputStream},debug:device.options.debug,warnings:device.options.warnings,publisher:device._publisher,enableRingingState:device.options.enableRingingState};options=options||{};for(var prop in defaults){if(prop in options)continue;options[prop]=defaults[prop]}var connection=device.options.connectionFactory(device,params,getUserMedia,options);connection.once("accept",function(){device._activeConnection=connection;device._removeConnection(connection);device.audio._maybeStartPollingVolume();device.emit("connect",connection)});connection.addListener("error",function(error){if(connection.status()==="closed"){device._removeConnection(connection)}device.audio._maybeStopPollingVolume();device.emit("error",error)});connection.once("cancel",function(){device.log("Canceled: "+connection.parameters.CallSid);device._removeConnection(connection);device.audio._maybeStopPollingVolume();device.emit("cancel",connection)});connection.once("disconnect",function(){device.audio._maybeStopPollingVolume();device._removeConnection(connection);if(device._activeConnection===connection){device._activeConnection=null}device.emit("disconnect",connection)});connection.once("reject",function(){device.log("Rejected: "+connection.parameters.CallSid);device.audio._maybeStopPollingVolume();device._removeConnection(connection)});return connection}Object.defineProperties(Device,{isSupported:{get:function(){return rtc.enabled()}}});Device.toString=function(){return"[Twilio.Device class]"};Device.prototype.toString=function(){return"[Twilio.Device instance]"};Device.prototype.register=function(token){var objectized=twutil.objectize(token);this._accountSid=objectized.iss;this._clientName=objectized.scope["client:incoming"]?objectized.scope["client:incoming"].params.clientName:null;if(this.stream){this.stream.setToken(token);this._publisher.setToken(token)}else{this._setupStream(token)}};Device.prototype.registerPresence=function(){if(!this.token){return}var tokenIncomingObject=twutil.objectize(this.token).scope["client:incoming"];if(tokenIncomingObject){this.mediaPresence.audio=true}this._sendPresence()};Device.prototype.unregisterPresence=function(){this.mediaPresence.audio=false;this._sendPresence()};Device.prototype.connect=function(params,audioConstraints){if(typeof params==="function"){return this.addListener("connect",params)}if(this._activeConnection){throw new Error("A Connection is already active")}params=params||{};audioConstraints=audioConstraints||this.options.audioConstraints;var connection=this._activeConnection=makeConnection(this,params);this.connections.splice(0).forEach(function(conn){conn.ignore()});this.soundcache.get("incoming").stop();if(this.sounds.__dict__.outgoing){var self_1=this;connection.accept(function(){self_1.soundcache.get("outgoing").play()})}connection.accept(audioConstraints);return connection};Device.prototype.disconnectAll=function(){var connections=[].concat(this.connections);for(var i=0;i0){this.log("Connections left pending: "+this.connections.length)}};Device.prototype.destroy=function(){this._stopRegistrationTimer();this.audio._unbind();if(this.stream){this.stream.destroy();this.stream=null}};Device.prototype.disconnect=function(handler){this.addListener("disconnect",handler)};Device.prototype.incoming=function(handler){this.addListener("incoming",handler)};Device.prototype.offline=function(handler){this.addListener("offline",handler)};Device.prototype.ready=function(handler){this.addListener("ready",handler)};Device.prototype.error=function(handler){this.addListener("error",handler)};Device.prototype.status=function(){return this._activeConnection?"busy":this._status};Device.prototype.activeConnection=function(){return this._activeConnection||this.connections[0]};Device.prototype.region=function(){return this._region};Device.prototype._sendPresence=function(){if(!this.stream){return}this.stream.register(this.mediaPresence);if(this.mediaPresence.audio){this._startRegistrationTimer()}else{this._stopRegistrationTimer()}};Device.prototype._startRegistrationTimer=function(){clearTimeout(this.regTimer);var self=this;this.regTimer=setTimeout(function(){self._sendPresence()},REG_INTERVAL)};Device.prototype._stopRegistrationTimer=function(){clearTimeout(this.regTimer)};Device.prototype._setupStream=function(token){var self=this;this.log("Setting up PStream");var streamOptions={debug:this.options.debug,secureSignaling:this.options.secureSignaling};this.stream=this.options.pStreamFactory(token,this.options.chunderw,streamOptions);this.stream.addListener("connected",function(payload){var regions={US_EAST_VIRGINIA:"us1",US_WEST_OREGON:"us2",ASIAPAC_SYDNEY:"au1",SOUTH_AMERICA_SAO_PAULO:"br1",EU_IRELAND:"ie1",ASIAPAC_TOKYO:"jp1",ASIAPAC_SINGAPORE:"sg1"};self._region=regions[payload.region]||payload.region;self._sendPresence()});this.stream.addListener("close",function(){self.stream=null});this.stream.addListener("ready",function(){self.log("Stream is ready");if(self._status==="offline"){self._status="ready"}self.emit("ready",self)});this.stream.addListener("offline",function(){self.log("Stream is offline");self._status="offline";self._region="offline";self.emit("offline",self)});this.stream.addListener("error",function(payload){var error=payload.error;if(error){if(payload.callsid){error.connection=self._findConnection(payload.callsid)}if(error.code===31205){self._stopRegistrationTimer()}self.log("Received error: ",error);self.emit("error",error)}});this.stream.addListener("invite",function(payload){if(self._activeConnection){self.log("Device busy; ignoring incoming invite");return}if(!payload.callsid||!payload.sdp){self.emit("error",{message:"Malformed invite from gateway"});return}var params=payload.parameters||{};params.CallSid=params.CallSid||payload.callsid;function maybeStopIncomingSound(){if(!self.connections.length){self.soundcache.get("incoming").stop()}}var connection=makeConnection(self,{},{offerSdp:payload.sdp,callParameters:params});self.connections.push(connection);connection.once("accept",function(){self.soundcache.get("incoming").stop()});["cancel","error","reject"].forEach(function(event){connection.once(event,maybeStopIncomingSound)});var play=self.sounds.__dict__.incoming?function(){return self.soundcache.get("incoming").play()}:function(){return Promise.resolve()};self._showIncomingConnection(connection,play)})};Device.prototype._showIncomingConnection=function(connection,play){var self=this;var timeout;return Promise.race([play(),new Promise(function(resolve,reject){timeout=setTimeout(function(){reject(new Error("Playing incoming ringtone took too long; it might not play. Continuing execution..."))},RINGTONE_PLAY_TIMEOUT)})]).catch(function(reason){console.warn(reason.message)}).then(function(){clearTimeout(timeout);self.emit("incoming",connection)})};Device.prototype._removeConnection=function(connection){for(var i=this.connections.length-1;i>=0;i--){if(connection===this.connections[i]){this.connections.splice(i,1)}}};Device.prototype._findConnection=function(callsid){for(var i=0;i1){return}cls.instance.log(errorMessage)}throw new twutil.Exception(errorMessage)}var members={instance:null,setup:function(token,options){if(!cls.audioContext){if(typeof AudioContext!=="undefined"){cls.audioContext=new AudioContext}else if(typeof webkitAudioContext!=="undefined"){cls.audioContext=new webkitAudioContext}}var i;if(cls.instance){cls.instance.log("Found existing Device; using new token but ignoring options");cls.instance.token=token;cls.instance.register(token)}else{cls.instance=new Device(token,options);cls.error(defaultErrorHandler);cls.sounds=cls.instance.sounds;for(i=0;i0){cls.instance.emit("error",{message:"A connection is currently active"});return null}return cls.instance.connect(parameters,audioConstraints)},disconnectAll:function(){enqueue(function(){cls.instance.disconnectAll()});return cls},disconnect:function(handler){enqueue(function(){cls.instance.addListener("disconnect",handler)});return cls},status:function(){if(!cls.instance){throw new twutil.Exception("Run Twilio.Device.setup()")}return cls.instance.status()},region:function(){if(!cls.instance){throw new twutil.Exception("Run Twilio.Device.setup()")}return cls.instance.region()},ready:function(handler){enqueue(function(){cls.instance.addListener("ready",handler)});return cls},error:function(handler){enqueue(function(){if(handler!==defaultErrorHandler){cls.instance.removeListener("error",defaultErrorHandler)}cls.instance.addListener("error",handler)});return cls},offline:function(handler){enqueue(function(){cls.instance.addListener("offline",handler)});return cls},incoming:function(handler){enqueue(function(){cls.instance.addListener("incoming",handler)});return cls},destroy:function(){if(cls.instance){cls.instance.destroy()}return cls},cancel:function(handler){enqueue(function(){cls.instance.addListener("cancel",handler)});return cls},activeConnection:function(){if(!cls.instance){return null}return cls.instance.activeConnection()}};for(var method in members){cls[method]=members[method]}Object.defineProperties(cls,{audio:{get:function(){return cls.instance.audio}}});return cls}exports.Device=singletonwrapper(Device)},{"./audiohelper":4,"./connection":5,"./eventpublisher":7,"./log":8,"./options":9,"./pstream":11,"./rtc":14,"./rtc/getusermedia":13,"./sound":24,"./util":28,events:43,util:55}],7:[function(require,module,exports){var request=require("./request");function EventPublisher(productName,token,options){if(!(this instanceof EventPublisher)){return new EventPublisher(productName,token,options)}options=Object.assign({defaultPayload:function(){return{}},host:"eventgw.twilio.com"},options);var defaultPayload=options.defaultPayload;if(typeof defaultPayload!=="function"){defaultPayload=function(){return Object.assign({},options.defaultPayload)}}var isEnabled=true;Object.defineProperties(this,{_defaultPayload:{value:defaultPayload},_isEnabled:{get:function(){return isEnabled},set:function(_isEnabled){isEnabled=_isEnabled}},_host:{value:options.host},_request:{value:options.request||request},_token:{value:token,writable:true},isEnabled:{enumerable:true,get:function(){return isEnabled}},productName:{enumerable:true,value:productName},token:{enumerable:true,get:function(){return this._token}}})}EventPublisher.prototype._post=function _post(endpointName,level,group,name,payload,connection,force){if(!this.isEnabled&&!force){return Promise.resolve()}var event={publisher:this.productName,group:group,name:name,timestamp:(new Date).toISOString(),level:level.toUpperCase(),payload_type:"application/json",private:false,payload:payload&&payload.forEach?payload.slice(0):Object.assign(this._defaultPayload(connection),payload)};var requestParams={url:"https://"+this._host+"/v2/"+endpointName,body:event,headers:{"Content-Type":"application/json","X-Twilio-Token":this.token}};var self=this;return new Promise(function(resolve,reject){self._request.post(requestParams,function(err){if(err){reject(err)}else{resolve()}})})};EventPublisher.prototype.post=function post(level,group,name,payload,connection,force){return this._post("EndpointEvents",level,group,name,payload,connection,force)};EventPublisher.prototype.debug=function debug(group,name,payload,connection){return this.post("debug",group,name,payload,connection)};EventPublisher.prototype.info=function info(group,name,payload,connection){return this.post("info",group,name,payload,connection)};EventPublisher.prototype.warn=function warn(group,name,payload,connection){return this.post("warning",group,name,payload,connection)};EventPublisher.prototype.error=function error(group,name,payload,connection){return this.post("error",group,name,payload,connection)};EventPublisher.prototype.postMetrics=function postMetrics(group,name,metrics,customFields){var self=this;return new Promise(function(resolve){var samples=metrics.map(formatMetric).map(function(sample){return Object.assign(sample,customFields)});resolve(self._post("EndpointMetrics","info",group,name,samples))})};EventPublisher.prototype.setToken=function setToken(token){this._token=token};EventPublisher.prototype.enable=function enable(){this._isEnabled=true};EventPublisher.prototype.disable=function disable(){this._isEnabled=false};function formatMetric(sample){return{timestamp:new Date(sample.timestamp).toISOString(),total_packets_received:sample.totals.packetsReceived,total_packets_lost:sample.totals.packetsLost,total_packets_sent:sample.totals.packetsSent,total_bytes_received:sample.totals.bytesReceived,total_bytes_sent:sample.totals.bytesSent,packets_received:sample.packetsReceived,packets_lost:sample.packetsLost,packets_lost_fraction:sample.packetsLostFraction&&Math.round(sample.packetsLostFraction*100)/100,audio_level_in:sample.audioInputLevel,audio_level_out:sample.audioOutputLevel,call_volume_input:sample.inputVolume,call_volume_output:sample.outputVolume,jitter:sample.jitter,rtt:sample.rtt,mos:sample.mos&&Math.round(sample.mos*100)/100}}module.exports=EventPublisher},{"./request":12}],8:[function(require,module,exports){function mixinLog(object,prefix){function log(){var args=[];for(var _i=0;_i0?currentPacketsLost/currentInboundPackets*100:0;var totalInboundPackets=stats.packetsReceived+stats.packetsLost;var totalPacketsLostFraction=totalInboundPackets>0?stats.packetsLost/totalInboundPackets*100:100;return{timestamp:stats.timestamp,totals:{packetsReceived:stats.packetsReceived,packetsLost:stats.packetsLost,packetsSent:stats.packetsSent,packetsLostFraction:totalPacketsLostFraction,bytesReceived:stats.bytesReceived,bytesSent:stats.bytesSent},packetsSent:currentPacketsSent,packetsReceived:currentPacketsReceived,packetsLost:currentPacketsLost,packetsLostFraction:currentPacketsLostFraction,audioInputLevel:stats.audioInputLevel,audioOutputLevel:stats.audioOutputLevel,jitter:stats.jitter,rtt:stats.rtt,mos:Mos.calculate(stats,previousSample&¤tPacketsLostFraction)}};RTCMonitor.prototype.enable=function enable(peerConnection){if(peerConnection){if(this._peerConnection&&peerConnection!==this._peerConnection){throw new Error("Attempted to replace an existing PeerConnection in RTCMonitor.enable")}this._peerConnection=peerConnection}if(!this._peerConnection){throw new Error("Can not enable RTCMonitor without a PeerConnection")}this._sampleInterval=this._sampleInterval||setInterval(this._fetchSample.bind(this),SAMPLE_INTERVAL);return this};RTCMonitor.prototype.disable=function disable(){clearInterval(this._sampleInterval);this._sampleInterval=null;return this};RTCMonitor.prototype.getSample=function getSample(){var pc=this._peerConnection;var self=this;return getStatistics(pc).then(function(stats){var previousSample=self._sampleBuffer.length&&self._sampleBuffer[self._sampleBuffer.length-1];return RTCMonitor.createSample(stats,previousSample)})};RTCMonitor.prototype._fetchSample=function _fetchSample(){var self=this;return this.getSample().then(function addSample(sample){self._addSample(sample);self._raiseWarnings();self.emit("sample",sample);return sample},function getSampleFailed(error){self.disable();self.emit("error",error)})};RTCMonitor.prototype._addSample=function _addSample(sample){var samples=this._sampleBuffer;samples.push(sample);if(samples.length>SAMPLE_COUNT_METRICS){samples.splice(0,samples.length-SAMPLE_COUNT_METRICS)}};RTCMonitor.prototype._raiseWarnings=function _raiseWarnings(){if(!this._warningsEnabled){return}for(var name_1 in this._thresholds){this._raiseWarningsForStat(name_1)}};RTCMonitor.prototype.enableWarnings=function enableWarnings(){this._warningsEnabled=true;return this};RTCMonitor.prototype.disableWarnings=function disableWarnings(){if(this._warningsEnabled){this._activeWarnings.clear()}this._warningsEnabled=false;return this};RTCMonitor.prototype._raiseWarningsForStat=function _raiseWarningsForStat(statName){var samples=this._sampleBuffer;var limits=this._thresholds[statName];var relevantSamples=samples.slice(-SAMPLE_COUNT_METRICS);var values=relevantSamples.map(function(sample){return sample[statName]});var containsNull=values.some(function(value){return typeof value==="undefined"||value===null});if(containsNull){return}var count;if(typeof limits.max==="number"){count=countHigh(limits.max,values);if(count>=SAMPLE_COUNT_RAISE){this._raiseWarning(statName,"max",{values:values})}else if(count<=SAMPLE_COUNT_CLEAR){this._clearWarning(statName,"max",{values:values})}}if(typeof limits.min==="number"){count=countLow(limits.min,values);if(count>=SAMPLE_COUNT_RAISE){this._raiseWarning(statName,"min",{values:values})}else if(count<=SAMPLE_COUNT_CLEAR){this._clearWarning(statName,"min",{values:values})}}if(typeof limits.maxDuration==="number"&&samples.length>1){relevantSamples=samples.slice(-2);var prevValue=relevantSamples[0][statName];var curValue=relevantSamples[1][statName];var prevStreak=this._currentStreaks.get(statName)||0;var streak=prevValue===curValue?prevStreak+1:0;this._currentStreaks.set(statName,streak);if(streak>=limits.maxDuration){this._raiseWarning(statName,"maxDuration",{value:streak})}else if(streak===0){this._clearWarning(statName,"maxDuration",{value:prevStreak})}}};function countLow(min,values){return values.reduce(function(lowCount,value){return lowCount+=valuemax?1:0},0)}RTCMonitor.prototype._clearWarning=function _clearWarning(statName,thresholdName,data){var warningId=statName+":"+thresholdName;var activeWarning=this._activeWarnings.get(warningId);if(!activeWarning||Date.now()-activeWarning.timeRaised=1&&mos<4.6;return isValid?mos:null}function calculateRFactor(rtt,jitter,fractionLost){var effectiveLatency=rtt+jitter*2+10;var rFactor=0;switch(true){case effectiveLatency<160:rFactor=rfactorConstants.r0-effectiveLatency/40;break;case effectiveLatency<1e3:rFactor=rfactorConstants.r0-(effectiveLatency-120)/10;break;case effectiveLatency>=1e3:rFactor=rfactorConstants.r0-effectiveLatency/100;break}var multiplier=.01;switch(true){case fractionLost===-1:multiplier=0;rFactor=0;break;case fractionLost<=rFactor/2.5:multiplier=2.5;break;case fractionLost>rFactor/2.5&&fractionLost<100:multiplier=.25;break}rFactor-=fractionLost*multiplier;return rFactor}function isPositiveNumber(n){return typeof n==="number"&&!isNaN(n)&&isFinite(n)&&n>=0}module.exports={calculate:calcMos}},{}],18:[function(require,module,exports){var Log=require("../log");var StateMachine=require("../statemachine");var util=require("../util");var RTCPC=require("./rtcpc");var ICE_CONNECTION_STATES={new:["checking","closed"],checking:["new","connected","failed","closed","completed"],connected:["new","disconnected","completed","closed"],completed:["new","disconnected","closed","completed"],failed:["new","disconnected","closed"],disconnected:["connected","completed","failed","closed"],closed:[]};var INITIAL_ICE_CONNECTION_STATE="new";var SIGNALING_STATES={stable:["have-local-offer","have-remote-offer","closed"],"have-local-offer":["stable","closed"],"have-remote-offer":["stable","closed"],closed:[]};var INITIAL_SIGNALING_STATE="stable";function PeerConnection(device,getUserMedia,options){if(!device||!getUserMedia){throw new Error("Device and getUserMedia are required arguments")}if(!(this instanceof PeerConnection)){return new PeerConnection(device,getUserMedia,options)}function noop(){}this.onopen=noop;this.onerror=noop;this.onclose=noop;this.ondisconnect=noop;this.onreconnect=noop;this.onsignalingstatechange=noop;this.oniceconnectionstatechange=noop;this.onicecandidate=noop;this.onvolume=noop;this.version=null;this.pstream=device.stream;this.stream=null;this.sinkIds=new Set(["default"]);this.outputs=new Map;this.status="connecting";this.callSid=null;this.isMuted=false;this.getUserMedia=getUserMedia;var AudioContext=typeof window!=="undefined"&&(window.AudioContext||window.webkitAudioContext);this._isSinkSupported=!!AudioContext&&typeof HTMLAudioElement!=="undefined"&&HTMLAudioElement.prototype.setSinkId;this._audioContext=AudioContext&&device.audio._audioContext;this._masterAudio=null;this._masterAudioDeviceId=null;this._mediaStreamSource=null;this._dtmfSender=null;this._dtmfSenderUnsupported=false;this._callEvents=[];this._nextTimeToPublish=Date.now();this._onAnswerOrRinging=noop;this._remoteStream=null;this._shouldManageStream=true;Log.mixinLog(this,"[Twilio.PeerConnection]");this.log.enabled=device.options.debug;this.log.warnings=device.options.warnings;this._iceConnectionStateMachine=new StateMachine(ICE_CONNECTION_STATES,INITIAL_ICE_CONNECTION_STATE);this._signalingStateMachine=new StateMachine(SIGNALING_STATES,INITIAL_SIGNALING_STATE);this.options=options=options||{};this.navigator=options.navigator||(typeof navigator!=="undefined"?navigator:null);this.util=options.util||util;return this}PeerConnection.prototype.uri=function(){return this._uri};PeerConnection.prototype.openWithConstraints=function(constraints){return this.getUserMedia({audio:constraints}).then(this._setInputTracksFromStream.bind(this,false))};PeerConnection.prototype.setInputTracksFromStream=function(stream){var self=this;return this._setInputTracksFromStream(true,stream).then(function(){self._shouldManageStream=false})};PeerConnection.prototype._createAnalyser=function(stream,audioContext){var analyser=audioContext.createAnalyser();analyser.fftSize=32;analyser.smoothingTimeConstant=.3;var streamSource=audioContext.createMediaStreamSource(stream);streamSource.connect(analyser);return analyser};PeerConnection.prototype._setVolumeHandler=function(handler){this.onvolume=handler};PeerConnection.prototype._startPollingVolume=function(){if(!this._audioContext||!this.stream||!this._remoteStream){return}var audioContext=this._audioContext;var inputAnalyser=this._inputAnalyser=this._createAnalyser(this.stream,audioContext);var inputBufferLength=inputAnalyser.frequencyBinCount;var inputDataArray=new Uint8Array(inputBufferLength);var outputAnalyser=this._outputAnalyser=this._createAnalyser(this._remoteStream,audioContext);var outputBufferLength=outputAnalyser.frequencyBinCount;var outputDataArray=new Uint8Array(outputBufferLength);var self=this;requestAnimationFrame(function emitVolume(){if(!self._audioContext){return}else if(self.status==="closed"){self._inputAnalyser.disconnect();self._outputAnalyser.disconnect();return}self._inputAnalyser.getByteFrequencyData(inputDataArray);var inputVolume=self.util.average(inputDataArray);self._outputAnalyser.getByteFrequencyData(outputDataArray);var outputVolume=self.util.average(outputDataArray);self.onvolume(inputVolume/255,outputVolume/255);requestAnimationFrame(emitVolume)})};PeerConnection.prototype._stopStream=function _stopStream(stream){if(!this._shouldManageStream){return}if(typeof MediaStreamTrack.prototype.stop==="function"){var audioTracks=typeof stream.getAudioTracks==="function"?stream.getAudioTracks():stream.audioTracks;audioTracks.forEach(function(track){track.stop()})}else{stream.stop()}};PeerConnection.prototype._setInputTracksFromStream=function(shouldClone,newStream){var self=this;if(!newStream){return Promise.reject(new Error("Can not set input stream to null while in a call"))}if(!newStream.getAudioTracks().length){return Promise.reject(new Error("Supplied input stream has no audio tracks"))}var localStream=this.stream;if(!localStream){this.stream=shouldClone?cloneStream(newStream):newStream}else{this._stopStream(localStream);removeStream(this.version.pc,localStream);localStream.getAudioTracks().forEach(localStream.removeTrack,localStream);newStream.getAudioTracks().forEach(localStream.addTrack,localStream);addStream(this.version.pc,newStream)}this.mute(this.isMuted);if(!this.version){return Promise.resolve(this.stream)}return new Promise(function(resolve,reject){self.version.createOffer({audio:true},function onOfferSuccess(){self.version.processAnswer(self._answerSdp,function(){if(self._audioContext){self._inputAnalyser=self._createAnalyser(self.stream,self._audioContext)}resolve(self.stream)},reject)},reject)})};PeerConnection.prototype._onInputDevicesChanged=function(){if(!this.stream){return}var activeInputWasLost=this.stream.getAudioTracks().every(function(track){return track.readyState==="ended"});if(activeInputWasLost&&this._shouldManageStream){this.openWithConstraints(true)}};PeerConnection.prototype._setSinkIds=function(sinkIds){if(!this._isSinkSupported){return Promise.reject(new Error("Audio output selection is not supported by this browser"))}this.sinkIds=new Set(sinkIds.forEach?sinkIds:[sinkIds]);return this.version?this._updateAudioOutputs():Promise.resolve()};PeerConnection.prototype._updateAudioOutputs=function updateAudioOutputs(){var addedOutputIds=Array.from(this.sinkIds).filter(function(id){return!this.outputs.has(id)},this);var removedOutputIds=Array.from(this.outputs.keys()).filter(function(id){return!this.sinkIds.has(id)},this);var self=this;var createOutputPromises=addedOutputIds.map(this._createAudioOutput,this);return Promise.all(createOutputPromises).then(function(){return Promise.all(removedOutputIds.map(self._removeAudioOutput,self))})};PeerConnection.prototype._createAudio=function createAudio(arr){return new Audio(arr)};PeerConnection.prototype._createAudioOutput=function createAudioOutput(id){var dest=this._audioContext.createMediaStreamDestination();this._mediaStreamSource.connect(dest);var audio=this._createAudio();setAudioSource(audio,dest.stream);var self=this;return audio.setSinkId(id).then(function(){return audio.play()}).then(function(){self.outputs.set(id,{audio:audio,dest:dest})})};PeerConnection.prototype._removeAudioOutputs=function removeAudioOutputs(){return Array.from(this.outputs.keys()).map(this._removeAudioOutput,this)};PeerConnection.prototype._disableOutput=function disableOutput(pc,id){var output=pc.outputs.get(id);if(!output){return}if(output.audio){output.audio.pause();output.audio.src=""}if(output.dest){output.dest.disconnect()}};PeerConnection.prototype._reassignMasterOutput=function reassignMasterOutput(pc,masterId){var masterOutput=pc.outputs.get(masterId);pc.outputs.delete(masterId);var self=this;var idToReplace=Array.from(pc.outputs.keys())[0]||"default";return masterOutput.audio.setSinkId(idToReplace).then(function(){self._disableOutput(pc,idToReplace);pc.outputs.set(idToReplace,masterOutput);pc._masterAudioDeviceId=idToReplace}).catch(function rollback(reason){pc.outputs.set(masterId,masterOutput);throw reason})};PeerConnection.prototype._removeAudioOutput=function removeAudioOutput(id){if(this._masterAudioDeviceId===id){return this._reassignMasterOutput(this,id)}this._disableOutput(this,id);this.outputs.delete(id);return Promise.resolve()};PeerConnection.prototype._onAddTrack=function onAddTrack(pc,stream){var audio=pc._masterAudio=this._createAudio();setAudioSource(audio,stream);audio.play();var deviceId=Array.from(pc.outputs.keys())[0]||"default";pc._masterAudioDeviceId=deviceId;pc.outputs.set(deviceId,{audio:audio});pc._mediaStreamSource=pc._audioContext.createMediaStreamSource(stream);pc.pcStream=stream;pc._updateAudioOutputs()};PeerConnection.prototype._fallbackOnAddTrack=function fallbackOnAddTrack(pc,stream){var audio=document&&document.createElement("audio");audio.autoplay=true;if(!setAudioSource(audio,stream)){pc.log("Error attaching stream to element.")}pc.outputs.set("default",{audio:audio})};PeerConnection.prototype._setupPeerConnection=function(rtcConstraints,iceServers){var self=this;var version=this._getProtocol();version.create(this.log,rtcConstraints,iceServers);addStream(version.pc,this.stream);var eventName="ontrack"in version.pc?"ontrack":"onaddstream";version.pc[eventName]=function(event){var stream=self._remoteStream=event.stream||event.streams[0];if(self._isSinkSupported){self._onAddTrack(self,stream)}else{self._fallbackOnAddTrack(self,stream)}self._startPollingVolume()};return version};PeerConnection.prototype._setupChannel=function(){var self=this;var pc=this.version.pc;self.version.pc.onopen=function(){self.status="open";self.onopen()};self.version.pc.onstatechange=function(){if(self.version.pc&&self.version.pc.readyState==="stable"){self.status="open";self.onopen()}};self.version.pc.onsignalingstatechange=function(){var state=pc.signalingState;self.log('signalingState is "'+state+'"');try{self._signalingStateMachine.transition(state)}catch(error){self.log("Failed to transition to signaling state "+state+": "+error)}if(self.version.pc&&self.version.pc.signalingState==="stable"){self.status="open";self.onopen()}self.onsignalingstatechange(pc.signalingState)};pc.onicecandidate=function onicecandidate(event){self.onicecandidate(event.candidate)};pc.oniceconnectionstatechange=function(){var state=pc.iceConnectionState;var previousState=self._iceConnectionStateMachine.currentState;try{self._iceConnectionStateMachine.transition(state)}catch(error){self.log("Failed to transition to ice connection state "+state+": "+error)}var message;switch(state){case"connected":if(previousState==="disconnected"){message="ICE liveliness check succeeded. Connection with Twilio restored";self.log(message);self.onreconnect(message)}break;case"disconnected":message="ICE liveliness check failed. May be having trouble connecting to Twilio";self.log(message);self.ondisconnect(message);break;case"failed":message=(previousState==="checking"?"ICE negotiation with Twilio failed.":"Connection with Twilio was interrupted.")+" Call will terminate.";self.log(message);self.onerror({info:{code:31003,message:message},disconnect:true});break;default:self.log('iceConnectionState is "'+state+'"')}self.oniceconnectionstatechange(state)}};PeerConnection.prototype._initializeMediaStream=function(rtcConstraints,iceServers){if(this.status==="open"){return false}if(this.pstream.status==="disconnected"){this.onerror({info:{code:31e3,message:"Cannot establish connection. Client is disconnected"}});this.close();return false}this.version=this._setupPeerConnection(rtcConstraints,iceServers);this._setupChannel();return true};PeerConnection.prototype.makeOutgoingCall=function(token,params,callsid,rtcConstraints,iceServers,onMediaStarted){if(!this._initializeMediaStream(rtcConstraints,iceServers)){return}var self=this;this.callSid=callsid;function onAnswerSuccess(){onMediaStarted(self.version.pc)}function onAnswerError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error processing answer: "+errMsg}})}this._onAnswerOrRinging=function(payload){if(!payload.sdp){return}self._answerSdp=payload.sdp;if(self.status!=="closed"){self.version.processAnswer(payload.sdp,onAnswerSuccess,onAnswerError)}self.pstream.removeListener("answer",self._onAnswerOrRinging);self.pstream.removeListener("ringing",self._onAnswerOrRinging)};this.pstream.on("answer",this._onAnswerOrRinging);this.pstream.on("ringing",this._onAnswerOrRinging);function onOfferSuccess(){if(self.status!=="closed"){self.pstream.publish("invite",{sdp:self.version.getSDP(),callsid:self.callSid,twilio:{accountsid:token?self.util.objectize(token).iss:null,params:params}})}}function onOfferError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error creating the offer: "+errMsg}})}this.version.createOffer({audio:true},onOfferSuccess,onOfferError)};PeerConnection.prototype.answerIncomingCall=function(callSid,sdp,rtcConstraints,iceServers,onMediaStarted){if(!this._initializeMediaStream(rtcConstraints,iceServers)){return}this._answerSdp=sdp.replace(/^a=setup:actpass$/gm,"a=setup:passive");this.callSid=callSid;var self=this;function onAnswerSuccess(){if(self.status!=="closed"){self.pstream.publish("answer",{callsid:callSid,sdp:self.version.getSDP()});onMediaStarted(self.version.pc)}}function onAnswerError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error creating the answer: "+errMsg}})}this.version.processSDP(sdp,{audio:true},onAnswerSuccess,onAnswerError)};PeerConnection.prototype.close=function(){if(this.version&&this.version.pc){if(this.version.pc.signalingState!=="closed"){this.version.pc.close()}this.version.pc=null}if(this.stream){this.mute(false);this._stopStream(this.stream)}this.stream=null;if(this.pstream){this.pstream.removeListener("answer",this._onAnswerOrRinging)}this._removeAudioOutputs();if(this._mediaStreamSource){this._mediaStreamSource.disconnect()}if(this._inputAnalyser){this._inputAnalyser.disconnect()}if(this._outputAnalyser){this._outputAnalyser.disconnect()}this.status="closed";this.onclose()};PeerConnection.prototype.reject=function(callSid){this.callSid=callSid};PeerConnection.prototype.ignore=function(callSid){this.callSid=callSid};PeerConnection.prototype.mute=function(shouldMute){this.isMuted=shouldMute;if(!this.stream){return}var audioTracks=typeof this.stream.getAudioTracks==="function"?this.stream.getAudioTracks():this.stream.audioTracks;audioTracks.forEach(function(track){track.enabled=!shouldMute})};PeerConnection.prototype.getOrCreateDTMFSender=function getOrCreateDTMFSender(){if(this._dtmfSender||this._dtmfSenderUnsupported){return this._dtmfSender||null}var self=this;var pc=this.version.pc;if(!pc){this.log("No RTCPeerConnection available to call createDTMFSender on");return null}if(typeof pc.getSenders==="function"&&(typeof RTCDTMFSender==="function"||typeof RTCDtmfSender==="function")){var chosenSender=pc.getSenders().find(function(sender){return sender.dtmf});if(chosenSender){this.log("Using RTCRtpSender#dtmf");this._dtmfSender=chosenSender.dtmf;return this._dtmfSender}}if(typeof pc.createDTMFSender==="function"&&typeof pc.getLocalStreams==="function"){var track=pc.getLocalStreams().map(function(stream){var tracks=self._getAudioTracks(stream);return tracks&&tracks[0]})[0];if(!track){this.log("No local audio MediaStreamTrack available on the RTCPeerConnection to pass to createDTMFSender");return null}this.log("Creating RTCDTMFSender");this._dtmfSender=pc.createDTMFSender(track);return this._dtmfSender}this.log("RTCPeerConnection does not support RTCDTMFSender");this._dtmfSenderUnsupported=true;return null};PeerConnection.prototype._canStopMediaStreamTrack=function(){return typeof MediaStreamTrack.prototype.stop==="function"};PeerConnection.prototype._getAudioTracks=function(stream){return typeof stream.getAudioTracks==="function"?stream.getAudioTracks():stream.audioTracks};PeerConnection.prototype._getProtocol=function(){return PeerConnection.protocol};PeerConnection.protocol=function(){return RTCPC.test()?new RTCPC:null}();function addStream(pc,stream){if(typeof pc.addTrack==="function"){stream.getAudioTracks().forEach(function(track){pc.addTrack(track,stream)})}else{pc.addStream(stream)}}function cloneStream(oldStream){var newStream=typeof MediaStream!=="undefined"?new MediaStream:new webkitMediaStream;oldStream.getAudioTracks().forEach(newStream.addTrack,newStream);return newStream}function removeStream(pc,stream){if(typeof pc.removeTrack==="function"){pc.getSenders().forEach(function(sender){pc.removeTrack(sender)})}else{pc.removeStream(stream)}}function setAudioSource(audio,stream){if(typeof audio.srcObject!=="undefined"){audio.srcObject=stream}else if(typeof audio.mozSrcObject!=="undefined"){audio.mozSrcObject=stream}else if(typeof audio.src!=="undefined"){var _window=audio.options.window||window;audio.src=(_window.URL||_window.webkitURL).createObjectURL(stream)}else{return false}return true}PeerConnection.enabled=!!PeerConnection.protocol;module.exports=PeerConnection},{"../log":8,"../statemachine":25,"../util":28,"./rtcpc":19}],19:[function(require,module,exports){(function(global){var RTCPeerConnectionShim=require("rtcpeerconnection-shim");var util=require("../util");function RTCPC(){if(typeof window==="undefined"){this.log("No RTCPeerConnection implementation available. The window object was not found.");return}if(util.isEdge()){this.RTCPeerConnection=new RTCPeerConnectionShim(typeof window!=="undefined"?window:global)}else if(typeof window.RTCPeerConnection==="function"){this.RTCPeerConnection=window.RTCPeerConnection}else if(typeof window.webkitRTCPeerConnection==="function"){this.RTCPeerConnection=webkitRTCPeerConnection}else if(typeof window.mozRTCPeerConnection==="function"){this.RTCPeerConnection=mozRTCPeerConnection;window.RTCSessionDescription=mozRTCSessionDescription;window.RTCIceCandidate=mozRTCIceCandidate}else{this.log("No RTCPeerConnection implementation available")}}RTCPC.prototype.create=function(log,rtcConstraints,iceServers){this.log=log;this.pc=new this.RTCPeerConnection({iceServers:iceServers},rtcConstraints)};RTCPC.prototype.createModernConstraints=function(c){if(typeof c==="undefined"){return null}var nc={};if(typeof webkitRTCPeerConnection!=="undefined"&&!util.isEdge()){nc.mandatory={};if(typeof c.audio!=="undefined"){nc.mandatory.OfferToReceiveAudio=c.audio}if(typeof c.video!=="undefined"){nc.mandatory.OfferToReceiveVideo=c.video}}else{if(typeof c.audio!=="undefined"){nc.offerToReceiveAudio=c.audio}if(typeof c.video!=="undefined"){nc.offerToReceiveVideo=c.video}}return nc};RTCPC.prototype.createOffer=function(constraints,onSuccess,onError){var self=this;constraints=this.createModernConstraints(constraints);promisifyCreate(this.pc.createOffer,this.pc)(constraints).then(function(sd){return self.pc&&promisifySet(self.pc.setLocalDescription,self.pc)(new RTCSessionDescription(sd))}).then(onSuccess,onError)};RTCPC.prototype.createAnswer=function(constraints,onSuccess,onError){var self=this;constraints=this.createModernConstraints(constraints);promisifyCreate(this.pc.createAnswer,this.pc)(constraints).then(function(sd){return self.pc&&promisifySet(self.pc.setLocalDescription,self.pc)(new RTCSessionDescription(sd))}).then(onSuccess,onError)};RTCPC.prototype.processSDP=function(sdp,constraints,onSuccess,onError){var self=this;var desc=new RTCSessionDescription({sdp:sdp,type:"offer"});promisifySet(this.pc.setRemoteDescription,this.pc)(desc).then(function(){self.createAnswer(constraints,onSuccess,onError)})};RTCPC.prototype.getSDP=function(){return this.pc.localDescription.sdp};RTCPC.prototype.processAnswer=function(sdp,onSuccess,onError){if(!this.pc){return}promisifySet(this.pc.setRemoteDescription,this.pc)(new RTCSessionDescription({sdp:sdp,type:"answer"})).then(onSuccess,onError)};RTCPC.test=function(){if(typeof navigator==="object"){var getUserMedia=navigator.mediaDevices&&navigator.mediaDevices.getUserMedia||navigator.webkitGetUserMedia||navigator.mozGetUserMedia||navigator.getUserMedia;if(getUserMedia&&typeof window.RTCPeerConnection==="function"){return true}else if(getUserMedia&&typeof window.webkitRTCPeerConnection==="function"){return true}else if(getUserMedia&&typeof window.mozRTCPeerConnection==="function"){try{var test_1=new window.mozRTCPeerConnection;if(typeof test_1.getLocalStreams!=="function")return false}catch(e){return false}return true}else if(typeof RTCIceGatherer!=="undefined"){return true}}return false};function promisify(fn,ctx,areCallbacksFirst){return function(){var args=Array.prototype.slice.call(arguments);return new Promise(function(resolve){resolve(fn.apply(ctx,args))}).catch(function(){return new Promise(function(resolve,reject){fn.apply(ctx,areCallbacksFirst?[resolve,reject].concat(args):args.concat([resolve,reject]))})})}}function promisifyCreate(fn,ctx){return promisify(fn,ctx,true)}function promisifySet(fn,ctx){return promisify(fn,ctx,false)}module.exports=RTCPC}).call(this,typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{})},{"../util":28,"rtcpeerconnection-shim":51}],20:[function(require,module,exports){var MockRTCStatsReport=require("./mockrtcstatsreport");var ERROR_PEER_CONNECTION_NULL="PeerConnection is null";var ERROR_WEB_RTC_UNSUPPORTED="WebRTC statistics are unsupported";var SIGNED_SHORT=32767;var isChrome=false;if(typeof window!=="undefined"){var isCriOS=!!window.navigator.userAgent.match("CriOS");var isElectron=!!window.navigator.userAgent.match("Electron");var isGoogle=typeof window.chrome!=="undefined"&&window.navigator.vendor==="Google Inc."&&window.navigator.userAgent.indexOf("OPR")===-1&&window.navigator.userAgent.indexOf("Edge")===-1;isChrome=isCriOS||isElectron||isGoogle}function getStatistics(peerConnection,options){options=Object.assign({createRTCSample:createRTCSample},options);if(!peerConnection){return Promise.reject(new Error(ERROR_PEER_CONNECTION_NULL))}if(typeof peerConnection.getStats!=="function"){return Promise.reject(new Error(ERROR_WEB_RTC_UNSUPPORTED))}if(isChrome){return new Promise(function(resolve,reject){return peerConnection.getStats(resolve,reject)}).then(MockRTCStatsReport.fromRTCStatsResponse).then(options.createRTCSample)}var promise;try{promise=peerConnection.getStats()}catch(e){promise=new Promise(function(resolve,reject){return peerConnection.getStats(resolve,reject)}).then(MockRTCStatsReport.fromRTCStatsResponse)}return promise.then(options.createRTCSample)}function RTCSample(){}function createRTCSample(statsReport){var activeTransportId=null;var sample=new RTCSample;var fallbackTimestamp;Array.from(statsReport.values()).forEach(function(stats){var type=stats.type.replace("-","");fallbackTimestamp=fallbackTimestamp||stats.timestamp;switch(type){case"inboundrtp":sample.timestamp=sample.timestamp||stats.timestamp;sample.jitter=stats.jitter*1e3;sample.packetsLost=stats.packetsLost;sample.packetsReceived=stats.packetsReceived;sample.bytesReceived=stats.bytesReceived;var inboundTrack=statsReport.get(stats.trackId);if(inboundTrack){sample.audioOutputLevel=inboundTrack.audioLevel*SIGNED_SHORT}break;case"outboundrtp":sample.timestamp=stats.timestamp;sample.packetsSent=stats.packetsSent;sample.bytesSent=stats.bytesSent;if(stats.codecId&&statsReport.get(stats.codecId)){var mimeType=statsReport.get(stats.codecId).mimeType;sample.codecName=mimeType&&mimeType.match(/(.*\/)?(.*)/)[2]}var outboundTrack=statsReport.get(stats.trackId);if(outboundTrack){sample.audioInputLevel=outboundTrack.audioLevel*SIGNED_SHORT}break;case"transport":if(stats.dtlsState==="connected"){activeTransportId=stats.id}break}});if(!sample.timestamp){sample.timestamp=fallbackTimestamp}var activeTransport=statsReport.get(activeTransportId);if(!activeTransport){return sample}var selectedCandidatePair=statsReport.get(activeTransport.selectedCandidatePairId);if(!selectedCandidatePair){return sample}var localCandidate=statsReport.get(selectedCandidatePair.localCandidateId);var remoteCandidate=statsReport.get(selectedCandidatePair.remoteCandidateId);Object.assign(sample,{localAddress:localCandidate&&localCandidate.ip,remoteAddress:remoteCandidate&&remoteCandidate.ip,rtt:selectedCandidatePair&&selectedCandidatePair.currentRoundTripTime*1e3});return sample}module.exports=getStatistics},{"./mockrtcstatsreport":15}],21:[function(require,module,exports){var EventEmitter=require("events").EventEmitter;function EventTarget(){Object.defineProperties(this,{_eventEmitter:{value:new EventEmitter},_handlers:{value:{}}})}EventTarget.prototype.dispatchEvent=function dispatchEvent(event){return this._eventEmitter.emit(event.type,event)};EventTarget.prototype.addEventListener=function addEventListener(){return(_a=this._eventEmitter).addListener.apply(_a,arguments);var _a};EventTarget.prototype.removeEventListener=function removeEventListener(){return(_a=this._eventEmitter).removeListener.apply(_a,arguments);var _a};EventTarget.prototype._defineEventHandler=function _defineEventHandler(eventName){var self=this;Object.defineProperty(this,"on"+eventName,{get:function(){return self._handlers[eventName]},set:function(newHandler){var oldHandler=self._handlers[eventName];if(oldHandler&&(typeof newHandler==="function"||typeof newHandler==="undefined"||newHandler===null)){self._handlers[eventName]=null;self.removeEventListener(eventName,oldHandler)}if(typeof newHandler==="function"){self._handlers[eventName]=newHandler;self.addEventListener(eventName,newHandler)}}})};module.exports=EventTarget},{events:43}],22:[function(require,module,exports){function MediaDeviceInfoShim(options){Object.defineProperties(this,{deviceId:{get:function(){return options.deviceId}},groupId:{get:function(){return options.groupId}},kind:{get:function(){return options.kind}},label:{get:function(){return options.label}}})}module.exports=MediaDeviceInfoShim},{}],23:[function(require,module,exports){var EventTarget=require("./eventtarget");var inherits=require("util").inherits;var POLL_INTERVAL_MS=500;var nativeMediaDevices=typeof navigator!=="undefined"&&navigator.mediaDevices;function MediaDevicesShim(){EventTarget.call(this);this._defineEventHandler("devicechange");this._defineEventHandler("deviceinfochange");var knownDevices=[];Object.defineProperties(this,{_deviceChangeIsNative:{value:reemitNativeEvent(this,"devicechange")},_deviceInfoChangeIsNative:{value:reemitNativeEvent(this,"deviceinfochange")},_knownDevices:{value:knownDevices},_pollInterval:{value:null,writable:true}});if(typeof nativeMediaDevices.enumerateDevices==="function"){nativeMediaDevices.enumerateDevices().then(function(devices){devices.sort(sortDevicesById).forEach([].push,knownDevices)})}this._eventEmitter.on("newListener",function maybeStartPolling(eventName){if(eventName!=="devicechange"&&eventName!=="deviceinfochange"){return}this._pollInterval=this._pollInterval||setInterval(sampleDevices.bind(null,this),POLL_INTERVAL_MS)}.bind(this));this._eventEmitter.on("removeListener",function maybeStopPolling(){if(this._pollInterval&&!hasChangeListeners(this)){clearInterval(this._pollInterval);this._pollInterval=null}}.bind(this))}inherits(MediaDevicesShim,EventTarget);if(nativeMediaDevices&&typeof nativeMediaDevices.enumerateDevices==="function"){MediaDevicesShim.prototype.enumerateDevices=function enumerateDevices(){return nativeMediaDevices.enumerateDevices.apply(nativeMediaDevices,arguments)}}MediaDevicesShim.prototype.getUserMedia=function getUserMedia(){return nativeMediaDevices.getUserMedia.apply(nativeMediaDevices,arguments)};function deviceInfosHaveChanged(newDevices,oldDevices){var oldLabels=oldDevices.reduce(function(map,device){return map.set(device.deviceId,device.label||null)},new Map);return newDevices.some(function(newDevice){var oldLabel=oldLabels.get(newDevice.deviceId);return typeof oldLabel!=="undefined"&&oldLabel!==newDevice.label})}function devicesHaveChanged(newDevices,oldDevices){return newDevices.length!==oldDevices.length||propertyHasChanged("deviceId",newDevices,oldDevices)}function hasChangeListeners(mediaDevices){return["devicechange","deviceinfochange"].reduce(function(count,event){return count+mediaDevices._eventEmitter.listenerCount(event)},0)>0}function sampleDevices(mediaDevices){nativeMediaDevices.enumerateDevices().then(function(newDevices){var knownDevices=mediaDevices._knownDevices;var oldDevices=knownDevices.slice();[].splice.apply(knownDevices,[0,knownDevices.length].concat(newDevices.sort(sortDevicesById)));if(!mediaDevices._deviceChangeIsNative&&devicesHaveChanged(knownDevices,oldDevices)){mediaDevices.dispatchEvent(new Event("devicechange"))}if(!mediaDevices._deviceInfoChangeIsNative&&deviceInfosHaveChanged(knownDevices,oldDevices)){mediaDevices.dispatchEvent(new Event("deviceinfochange"))}})}function propertyHasChanged(propertyName,as,bs){return as.some(function(a,i){return a[propertyName]!==bs[i][propertyName]})}function reemitNativeEvent(mediaDevices,eventName){var methodName="on"+eventName;function dispatchEvent(event){mediaDevices.dispatchEvent(event)}if(methodName in nativeMediaDevices){if("addEventListener"in nativeMediaDevices){nativeMediaDevices.addEventListener(eventName,dispatchEvent)}else{nativeMediaDevices[methodName]=dispatchEvent}return true}return false}function sortDevicesById(a,b){return a.deviceId0){this._maxDurationTimeout=setTimeout(this.stop.bind(this),this._maxDuration)}var self=this;var playPromise=this._playPromise=Promise.all(this._sinkIds.map(function createAudioElement(sinkId){if(!self._Audio){return Promise.resolve()}var audioElement=new self._Audio(self.url);audioElement.loop=self._shouldLoop;audioElement.addEventListener("ended",function(){self._activeEls.delete(audioElement)});return new Promise(function(resolve){audioElement.addEventListener("canplaythrough",resolve)}).then(function(){if(!self.isPlaying||self._playPromise!==playPromise){return Promise.resolve()}return(self._isSinkSupported?audioElement.setSinkId(sinkId):Promise.resolve()).then(function setSinkIdSuccess(){self._activeEls.add(audioElement);return audioElement.play()}).then(function playSuccess(){return audioElement},function playFailure(reason){self._activeEls.delete(audioElement);throw reason})})}));return playPromise};module.exports=Sound},{AudioPlayer:33}],25:[function(require,module,exports){var inherits=require("util").inherits;function StateMachine(states,initialState){if(!(this instanceof StateMachine)){return new StateMachine(states,initialState)}var currentState=initialState;Object.defineProperties(this,{_currentState:{get:function(){return currentState},set:function(_currentState){currentState=_currentState}},currentState:{enumerable:true,get:function(){return currentState}},states:{enumerable:true,value:states},transitions:{enumerable:true,value:[]}});Object.freeze(this)}StateMachine.prototype.transition=function transition(to){var from=this.currentState;var valid=this.states[from];var newTransition=valid&&valid.indexOf(to)!==-1?new StateTransition(from,to):new InvalidStateTransition(from,to);this.transitions.push(newTransition);this._currentState=to;if(newTransition instanceof InvalidStateTransition){throw newTransition}return this};function StateTransition(from,to){Object.defineProperties(this,{from:{enumerable:true,value:from},to:{enumerable:true,value:to}})}function InvalidStateTransition(from,to){if(!(this instanceof InvalidStateTransition)){return new InvalidStateTransition(from,to)}Error.call(this);StateTransition.call(this,from,to);var errorMessage="Invalid transition from "+(typeof from==="string"?'"'+from+'"':"null")+' to "'+to+'"';Object.defineProperties(this,{message:{enumerable:true,value:errorMessage}});Object.freeze(this)}inherits(InvalidStateTransition,Error);module.exports=StateMachine},{util:55}],26:[function(require,module,exports){exports.SOUNDS_DEPRECATION_WARNING="Device.sounds is deprecated and will be removed in the next breaking "+"release. Please use the new functionality available on Device.audio.";function generateEventWarning(event,name,maxListeners){return"The number of "+event+" listeners on "+name+" exceeds the recommended number of "+maxListeners+". While twilio.js will continue to function normally, this may be indicative of an application error. Note that "+event+" listeners exist for the lifetime of the "+name+"."}exports.generateEventWarning=generateEventWarning},{}],27:[function(require,module,exports){"use strict";Object.defineProperty(exports,"__esModule",{value:true});var LogLevel;(function(LogLevel){LogLevel["Off"]="off";LogLevel["Debug"]="debug";LogLevel["Info"]="info";LogLevel["Warn"]="warn";LogLevel["Error"]="error"})(LogLevel=exports.LogLevel||(exports.LogLevel={}));var logLevelMethods=(_a={},_a[LogLevel.Debug]="info",_a[LogLevel.Info]="info",_a[LogLevel.Warn]="warn",_a[LogLevel.Error]="error",_a);var logLevelRanks=(_b={},_b[LogLevel.Debug]=0,_b[LogLevel.Info]=1,_b[LogLevel.Warn]=2,_b[LogLevel.Error]=3,_b[LogLevel.Off]=4,_b);var Log=function(){function Log(_logLevel,options){this._logLevel=_logLevel;this._console=console;if(options&&options.console){this._console=options.console}}Object.defineProperty(Log.prototype,"logLevel",{get:function(){return this._logLevel},enumerable:true,configurable:true});Log.prototype.debug=function(){var args=[];for(var _i=0;_i0){var padlen=4-remainder;encodedPayload+=new Array(padlen+1).join("=")}encodedPayload=encodedPayload.replace(/-/g,"+").replace(/_/g,"/");var decodedPayload=_atob(encodedPayload);return JSON.parse(decodedPayload)}var memoizedDecodePayload=memoize(decodePayload);function decode(token){var segs=token.split(".");if(segs.length!==3){throw new TwilioException("Wrong number of segments")}var encodedPayload=segs[1];var payload=memoizedDecodePayload(encodedPayload);return payload}function makedict(params){if(params==="")return{};if(params.indexOf("&")===-1&¶ms.indexOf("=")===-1)return params;var pairs=params.split("&");var result={};for(var i=0;i=MAX_LISTENERS){if(typeof console!=="undefined"){if(console.warn){console.warn(warning)}else if(console.log){console.log(warning)}}object.removeListener("newListener",monitor)}}object.on("newListener",monitor)}function deepEqual(a,b){if(a===b){return true}else if(typeof a!==typeof b){return false}else if(a instanceof Date&&b instanceof Date){return a.getTime()===b.getTime()}else if(typeof a!=="object"&&typeof b!=="object"){return a===b}return objectDeepEqual(a,b)}var objectKeys=typeof Object.keys==="function"?Object.keys:function(obj){var keys=[];for(var key in obj){keys.push(key)}return keys};function isUndefinedOrNull(a){return typeof a==="undefined"||a===null}function objectDeepEqual(a,b){if(isUndefinedOrNull(a)||isUndefinedOrNull(b)){return false}else if(a.prototype!==b.prototype){return false}var ka;var kb;try{ka=objectKeys(a);kb=objectKeys(b)}catch(e){return false}if(ka.length!==kb.length){return false}ka.sort();kb.sort();for(var i=ka.length-1;i>=0;i--){var k=ka[i];if(!deepEqual(a[k],b[k])){return false}}return true}function average(values){return values.reduce(function(t,v){return t+v})/values.length}function difference(lefts,rights,getKey){getKey=getKey||function(a){return a};var rightKeys=new Set(rights.map(getKey));return lefts.filter(function(left){return!rightKeys.has(getKey(left))})}function encodescope(service,privilege,params){var capability=["scope",service,privilege].join(":");var empty=true;for(var _ in params){void _;empty=false;break}return empty?capability:capability+"?"+buildquery(params)}function buildquery(params){var pairs=[];for(var name_1 in params){var value=typeof params[name_1]==="object"?buildquery(params[name_1]):params[name_1];pairs.push(encodeURIComponent(name_1)+"="+encodeURIComponent(value))}}function isFirefox(navigator){navigator=navigator||(typeof window==="undefined"?global.navigator:window.navigator);return navigator&&typeof navigator.userAgent==="string"&&/firefox|fxios/i.test(navigator.userAgent)}function isEdge(navigator){navigator=navigator||(typeof window==="undefined"?global.navigator:window.navigator);return navigator&&typeof navigator.userAgent==="string"&&/edge\/\d+/i.test(navigator.userAgent)}exports.getReleaseVersion=getReleaseVersion;exports.getSoundVersion=getSoundVersion;exports.dummyToken=dummyToken;exports.Exception=TwilioException;exports.decode=decode;exports.btoa=_btoa;exports.atob=_atob;exports.objectize=memoizedObjectize;exports.urlencode=urlencode;exports.encodescope=encodescope;exports.Set=Set;exports.bind=bind;exports.getSystemInfo=getSystemInfo;exports.splitObjects=splitObjects;exports.generateConnectionUUID=generateConnectionUUID;exports.getTwilioRoot=getTwilioRoot;exports.monitorEventEmitter=monitorEventEmitter;exports.deepEqual=deepEqual;exports.average=average;exports.difference=difference;exports.isFirefox=isFirefox;exports.isEdge=isEdge}).call(this,typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{},require("buffer").Buffer)},{"../../package.json":56,"./strings":26,buffer:42,events:43}],29:[function(require,module,exports){"use strict";var __extends=this&&this.__extends||function(){var extendStatics=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(d,b){d.__proto__=b}||function(d,b){for(var p in b)if(b.hasOwnProperty(p))d[p]=b[p]};return function(d,b){extendStatics(d,b);function __(){this.constructor=d}d.prototype=b===null?Object.create(b):(__.prototype=b.prototype,new __)}}();Object.defineProperty(exports,"__esModule",{value:true});var events_1=require("events");var WebSocket=require("ws");var tslog_1=require("./tslog");var Backoff=require("backoff");var CONNECT_SUCCESS_TIMEOUT=1e4;var CONNECT_TIMEOUT=5e3;var HEARTBEAT_TIMEOUT=15e3;var WSTransportState;(function(WSTransportState){WSTransportState["Connecting"]="connecting";WSTransportState["Closed"]="closed";WSTransportState["Open"]="open"})(WSTransportState=exports.WSTransportState||(exports.WSTransportState={}));var WSTransport=function(_super){__extends(WSTransport,_super);function WSTransport(uri,options){if(options===void 0){options={}}var _this=_super.call(this)||this;_this.state=WSTransportState.Closed;_this._backoff=Backoff.exponential({factor:1.5,initialDelay:30,maxDelay:3e3,randomisationFactor:.25});_this._onSocketClose=function(){_this._closeSocket()};_this._onSocketError=function(err){_this._log.info("WebSocket received error: "+err.message);_this.emit("error",{code:31e3,message:err.message||"WSTransport socket error"})};_this._onSocketMessage=function(message){_this._setHeartbeatTimeout();if(_this._socket&&message.data==="\n"){_this._socket.send("\n");return}_this.emit("message",message)};_this._onSocketOpen=function(){_this._log.info("WebSocket opened successfully.");_this._timeOpened=Date.now();_this.state=WSTransportState.Open;clearTimeout(_this._connectTimeout);_this._setHeartbeatTimeout();_this.emit("open")};_this._log=new tslog_1.default(options.logLevel||tslog_1.LogLevel.Off);_this._uri=uri;_this._WebSocket=options.WebSocket||WebSocket;_this._backoff.on("backoff",function(_,delay){if(_this.state===WSTransportState.Closed){return}_this._log.info("Will attempt to reconnect WebSocket in "+delay+"ms")});_this._backoff.on("ready",function(attempt){if(_this.state===WSTransportState.Closed){return}_this._connect(attempt+1)});return _this}WSTransport.prototype.close=function(){this._log.info("WSTransport.close() called...");this._close()};WSTransport.prototype.open=function(){this._log.info("WSTransport.open() called...");if(this._socket&&(this._socket.readyState===WebSocket.CONNECTING||this._socket.readyState===WebSocket.OPEN)){this._log.info("WebSocket already open.");return}this._connect()};WSTransport.prototype.send=function(message){if(!this._socket||this._socket.readyState!==WebSocket.OPEN){return false}try{this._socket.send(message)}catch(e){this._log.info("Error while sending message:",e.message);this._closeSocket();return false}return true};WSTransport.prototype._close=function(){this.state=WSTransportState.Closed;this._closeSocket()};WSTransport.prototype._closeSocket=function(){clearTimeout(this._connectTimeout);clearTimeout(this._heartbeatTimeout);this._log.info("Closing and cleaning up WebSocket...");if(!this._socket){this._log.info("No WebSocket to clean up.");return}this._socket.removeEventListener("close",this._onSocketClose);this._socket.removeEventListener("error",this._onSocketError);this._socket.removeEventListener("message",this._onSocketMessage);this._socket.removeEventListener("open",this._onSocketOpen);if(this._socket.readyState===WebSocket.CONNECTING||this._socket.readyState===WebSocket.OPEN){this._socket.close()}if(this._timeOpened&&Date.now()-this._timeOpened>CONNECT_SUCCESS_TIMEOUT){this._backoff.reset()}this._backoff.backoff();delete this._socket;this.emit("close")};WSTransport.prototype._connect=function(retryCount){var _this=this;if(retryCount){this._log.info("Attempting to reconnect (retry #"+retryCount+")...")}else{this._log.info("Attempting to connect...")}this._closeSocket();this.state=WSTransportState.Connecting;var socket=null;try{socket=new this._WebSocket(this._uri)}catch(e){this._log.info("Could not connect to endpoint:",e.message);this._close();this.emit("error",{code:31e3,message:e.message||"Could not connect to "+this._uri});return}delete this._timeOpened;this._connectTimeout=setTimeout(function(){_this._log.info("WebSocket connection attempt timed out.");_this._closeSocket()},CONNECT_TIMEOUT);socket.addEventListener("close",this._onSocketClose);socket.addEventListener("error",this._onSocketError);socket.addEventListener("message",this._onSocketMessage);socket.addEventListener("open",this._onSocketOpen);this._socket=socket};WSTransport.prototype._setHeartbeatTimeout=function(){var _this=this;clearTimeout(this._heartbeatTimeout);this._heartbeatTimeout=setTimeout(function(){_this._log.info("No messages received in "+HEARTBEAT_TIMEOUT/1e3+" seconds. Reconnecting...");_this._closeSocket()},HEARTBEAT_TIMEOUT)};return WSTransport}(events_1.EventEmitter);exports.default=WSTransport},{"./tslog":27,backoff:35,events:43,ws:1}],30:[function(require,module,exports){"use strict";var _regenerator=require("babel-runtime/regenerator");var _regenerator2=_interopRequireDefault(_regenerator);var _createClass=function(){function defineProperties(target,props){for(var i=0;i1&&arguments[1]!==undefined?arguments[1]:{};var options=arguments.length>2&&arguments[2]!==undefined?arguments[2]:{};_classCallCheck(this,AudioPlayer);var _this=_possibleConstructorReturn(this,(AudioPlayer.__proto__||Object.getPrototypeOf(AudioPlayer)).call(this));_this._audioNode=null;_this._pendingPlayDeferreds=[];_this._loop=false;_this._src="";_this._sinkId="default";if(typeof srcOrOptions!=="string"){options=srcOrOptions}_this._audioContext=audioContext;_this._audioElement=new(options.AudioFactory||Audio);_this._bufferPromise=_this._createPlayDeferred().promise;_this._destination=_this._audioContext.destination;_this._gainNode=_this._audioContext.createGain();_this._gainNode.connect(_this._destination);_this._XMLHttpRequest=options.XMLHttpRequestFactory||XMLHttpRequest;_this.addEventListener("canplaythrough",function(){_this._resolvePlayDeferreds()});if(typeof srcOrOptions==="string"){_this.src=srcOrOptions}return _this}_createClass(AudioPlayer,[{key:"load",value:function load(){this._load(this._src)}},{key:"pause",value:function pause(){if(this.paused){return}this._audioElement.pause();this._audioNode.stop();this._audioNode.disconnect(this._gainNode);this._audioNode=null;this._rejectPlayDeferreds(new Error("The play() request was interrupted by a call to pause()."))}},{key:"play",value:function play(){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee(){var _this2=this;var buffer;return _regenerator2.default.wrap(function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:if(this.paused){_context.next=6;break}_context.next=3;return this._bufferPromise;case 3:if(this.paused){_context.next=5;break}return _context.abrupt("return");case 5:throw new Error("The play() request was interrupted by a call to pause().");case 6:this._audioNode=this._audioContext.createBufferSource();this._audioNode.loop=this.loop;this._audioNode.addEventListener("ended",function(){if(_this2._audioNode&&_this2._audioNode.loop){return}_this2.dispatchEvent("ended")});_context.next=11;return this._bufferPromise;case 11:buffer=_context.sent;if(!this.paused){_context.next=14;break}throw new Error("The play() request was interrupted by a call to pause().");case 14:this._audioNode.buffer=buffer;this._audioNode.connect(this._gainNode);this._audioNode.start();if(!this._audioElement.srcObject){_context.next=19;break}return _context.abrupt("return",this._audioElement.play());case 19:case"end":return _context.stop()}}},_callee,this)}))}},{key:"setSinkId",value:function setSinkId(sinkId){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee2(){return _regenerator2.default.wrap(function _callee2$(_context2){while(1){switch(_context2.prev=_context2.next){case 0:if(!(typeof this._audioElement.setSinkId!=="function")){_context2.next=2;break}throw new Error("This browser does not support setSinkId.");case 2:if(!(sinkId===this.sinkId)){_context2.next=4;break}return _context2.abrupt("return");case 4:if(!(sinkId==="default")){_context2.next=11;break}if(!this.paused){this._gainNode.disconnect(this._destination)}this._audioElement.srcObject=null;this._destination=this._audioContext.destination;this._gainNode.connect(this._destination);this._sinkId=sinkId;return _context2.abrupt("return");case 11:_context2.next=13;return this._audioElement.setSinkId(sinkId);case 13:if(!this._audioElement.srcObject){_context2.next=15;break}return _context2.abrupt("return");case 15:this._gainNode.disconnect(this._audioContext.destination);this._destination=this._audioContext.createMediaStreamDestination();this._audioElement.srcObject=this._destination.stream;this._sinkId=sinkId;this._gainNode.connect(this._destination);case 20:case"end":return _context2.stop()}}},_callee2,this)}))}},{key:"_createPlayDeferred",value:function _createPlayDeferred(){var deferred=new Deferred_1.default;this._pendingPlayDeferreds.push(deferred);return deferred}},{key:"_load",value:function _load(src){var _this3=this;if(this._src&&this._src!==src){this.pause()}this._src=src;this._bufferPromise=new Promise(function(resolve,reject){return __awaiter(_this3,void 0,void 0,_regenerator2.default.mark(function _callee3(){var buffer;return _regenerator2.default.wrap(function _callee3$(_context3){while(1){switch(_context3.prev=_context3.next){case 0:if(src){_context3.next=2;break}return _context3.abrupt("return",this._createPlayDeferred().promise);case 2:_context3.next=4;return bufferSound(this._audioContext,this._XMLHttpRequest,src);case 4:buffer=_context3.sent;this.dispatchEvent("canplaythrough");resolve(buffer);case 7:case"end":return _context3.stop()}}},_callee3,this)}))})}},{key:"_rejectPlayDeferreds",value:function _rejectPlayDeferreds(reason){var deferreds=this._pendingPlayDeferreds;deferreds.splice(0,deferreds.length).forEach(function(_ref){var reject=_ref.reject;return reject(reason)})}},{key:"_resolvePlayDeferreds",value:function _resolvePlayDeferreds(result){var deferreds=this._pendingPlayDeferreds;deferreds.splice(0,deferreds.length).forEach(function(_ref2){var resolve=_ref2.resolve;return resolve(result)})}},{key:"destination",get:function get(){return this._destination}},{key:"loop",get:function get(){return this._loop},set:function set(shouldLoop){if(!shouldLoop&&this.loop&&!this.paused){var _pauseAfterPlaythrough=function _pauseAfterPlaythrough(){self._audioNode.removeEventListener("ended",_pauseAfterPlaythrough);self.pause()};var self=this;this._audioNode.addEventListener("ended",_pauseAfterPlaythrough)}this._loop=shouldLoop}},{key:"muted",get:function get(){return this._gainNode.gain.value===0},set:function set(shouldBeMuted){this._gainNode.gain.value=shouldBeMuted?0:1}},{key:"paused",get:function get(){return this._audioNode===null}},{key:"src",get:function get(){return this._src},set:function set(src){this._load(src)}},{key:"sinkId",get:function get(){return this._sinkId}}]);return AudioPlayer}(EventTarget_1.default);exports.default=AudioPlayer;function bufferSound(context,RequestFactory,src){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee4(){var request,event;return _regenerator2.default.wrap(function _callee4$(_context4){while(1){switch(_context4.prev=_context4.next){case 0:request=new RequestFactory;request.open("GET",src,true);request.responseType="arraybuffer";_context4.next=5;return new Promise(function(resolve){request.addEventListener("load",resolve);request.send()});case 5:event=_context4.sent;_context4.prev=6;return _context4.abrupt("return",context.decodeAudioData(event.target.response));case 10:_context4.prev=10;_context4.t0=_context4["catch"](6);return _context4.abrupt("return",new Promise(function(resolve){context.decodeAudioData(event.target.response,resolve)}));case 13:case"end":return _context4.stop()}}},_callee4,this,[[6,10]])}))}},{"./Deferred":31,"./EventTarget":32,"babel-runtime/regenerator":34}],31:[function(require,module,exports){"use strict";var _createClass=function(){function defineProperties(target,props){for(var i=0;i1?_len-1:0),_key=1;_key<_len;_key++){args[_key-1]=arguments[_key]}return(_eventEmitter=this._eventEmitter).emit.apply(_eventEmitter,[name].concat(args))}},{key:"removeEventListener",value:function removeEventListener(name,handler){return this._eventEmitter.removeListener(name,handler)}}]);return EventTarget}();exports.default=EventTarget},{events:43}],33:[function(require,module,exports){"use strict";var AudioPlayer=require("./AudioPlayer");module.exports=AudioPlayer.default},{"./AudioPlayer":30}],34:[function(require,module,exports){module.exports=require("regenerator-runtime")},{"regenerator-runtime":49}],35:[function(require,module,exports){var Backoff=require("./lib/backoff");var ExponentialBackoffStrategy=require("./lib/strategy/exponential");var FibonacciBackoffStrategy=require("./lib/strategy/fibonacci");var FunctionCall=require("./lib/function_call.js");module.exports.Backoff=Backoff;module.exports.FunctionCall=FunctionCall;module.exports.FibonacciStrategy=FibonacciBackoffStrategy;module.exports.ExponentialStrategy=ExponentialBackoffStrategy;module.exports.fibonacci=function(options){return new Backoff(new FibonacciBackoffStrategy(options))};module.exports.exponential=function(options){return new Backoff(new ExponentialBackoffStrategy(options))};module.exports.call=function(fn,vargs,callback){var args=Array.prototype.slice.call(arguments);fn=args[0];vargs=args.slice(1,args.length-1);callback=args[args.length-1];return new FunctionCall(fn,vargs,callback)}},{"./lib/backoff":36,"./lib/function_call.js":37,"./lib/strategy/exponential":38,"./lib/strategy/fibonacci":39}],36:[function(require,module,exports){var events=require("events");var precond=require("precond");var util=require("util");function Backoff(backoffStrategy){events.EventEmitter.call(this);this.backoffStrategy_=backoffStrategy;this.maxNumberOfRetry_=-1;this.backoffNumber_=0;this.backoffDelay_=0;this.timeoutID_=-1;this.handlers={backoff:this.onBackoff_.bind(this)}}util.inherits(Backoff,events.EventEmitter);Backoff.prototype.failAfter=function(maxNumberOfRetry){precond.checkArgument(maxNumberOfRetry>0,"Expected a maximum number of retry greater than 0 but got %s.",maxNumberOfRetry);this.maxNumberOfRetry_=maxNumberOfRetry};Backoff.prototype.backoff=function(err){precond.checkState(this.timeoutID_===-1,"Backoff in progress.");if(this.backoffNumber_===this.maxNumberOfRetry_){this.emit("fail",err);this.reset()}else{this.backoffDelay_=this.backoffStrategy_.next();this.timeoutID_=setTimeout(this.handlers.backoff,this.backoffDelay_);this.emit("backoff",this.backoffNumber_,this.backoffDelay_,err)}};Backoff.prototype.onBackoff_=function(){this.timeoutID_=-1;this.emit("ready",this.backoffNumber_,this.backoffDelay_);this.backoffNumber_++};Backoff.prototype.reset=function(){this.backoffNumber_=0;this.backoffStrategy_.reset();clearTimeout(this.timeoutID_);this.timeoutID_=-1};module.exports=Backoff},{events:43,precond:45,util:55}],37:[function(require,module,exports){var events=require("events");var precond=require("precond");var util=require("util");var Backoff=require("./backoff");var FibonacciBackoffStrategy=require("./strategy/fibonacci");function FunctionCall(fn,args,callback){events.EventEmitter.call(this);precond.checkIsFunction(fn,"Expected fn to be a function.");precond.checkIsArray(args,"Expected args to be an array.");precond.checkIsFunction(callback,"Expected callback to be a function.");this.function_=fn;this.arguments_=args;this.callback_=callback;this.lastResult_=[];this.numRetries_=0;this.backoff_=null;this.strategy_=null;this.failAfter_=-1;this.retryPredicate_=FunctionCall.DEFAULT_RETRY_PREDICATE_;this.state_=FunctionCall.State_.PENDING}util.inherits(FunctionCall,events.EventEmitter);FunctionCall.State_={PENDING:0,RUNNING:1,COMPLETED:2,ABORTED:3};FunctionCall.DEFAULT_RETRY_PREDICATE_=function(err){return true};FunctionCall.prototype.isPending=function(){return this.state_==FunctionCall.State_.PENDING};FunctionCall.prototype.isRunning=function(){return this.state_==FunctionCall.State_.RUNNING};FunctionCall.prototype.isCompleted=function(){return this.state_==FunctionCall.State_.COMPLETED};FunctionCall.prototype.isAborted=function(){return this.state_==FunctionCall.State_.ABORTED};FunctionCall.prototype.setStrategy=function(strategy){precond.checkState(this.isPending(),"FunctionCall in progress.");this.strategy_=strategy;return this};FunctionCall.prototype.retryIf=function(retryPredicate){precond.checkState(this.isPending(),"FunctionCall in progress.");this.retryPredicate_=retryPredicate;return this};FunctionCall.prototype.getLastResult=function(){return this.lastResult_.concat()};FunctionCall.prototype.getNumRetries=function(){return this.numRetries_};FunctionCall.prototype.failAfter=function(maxNumberOfRetry){precond.checkState(this.isPending(),"FunctionCall in progress.");this.failAfter_=maxNumberOfRetry;return this};FunctionCall.prototype.abort=function(){if(this.isCompleted()||this.isAborted()){return}if(this.isRunning()){this.backoff_.reset()}this.state_=FunctionCall.State_.ABORTED;this.lastResult_=[new Error("Backoff aborted.")];this.emit("abort");this.doCallback_()};FunctionCall.prototype.start=function(backoffFactory){precond.checkState(!this.isAborted(),"FunctionCall is aborted.");precond.checkState(this.isPending(),"FunctionCall already started.");var strategy=this.strategy_||new FibonacciBackoffStrategy;this.backoff_=backoffFactory?backoffFactory(strategy):new Backoff(strategy);this.backoff_.on("ready",this.doCall_.bind(this,true));this.backoff_.on("fail",this.doCallback_.bind(this));this.backoff_.on("backoff",this.handleBackoff_.bind(this));if(this.failAfter_>0){this.backoff_.failAfter(this.failAfter_)}this.state_=FunctionCall.State_.RUNNING;this.doCall_(false)};FunctionCall.prototype.doCall_=function(isRetry){if(isRetry){this.numRetries_++}var eventArgs=["call"].concat(this.arguments_);events.EventEmitter.prototype.emit.apply(this,eventArgs);var callback=this.handleFunctionCallback_.bind(this);this.function_.apply(null,this.arguments_.concat(callback))};FunctionCall.prototype.doCallback_=function(){this.callback_.apply(null,this.lastResult_)};FunctionCall.prototype.handleFunctionCallback_=function(){if(this.isAborted()){return}var args=Array.prototype.slice.call(arguments);this.lastResult_=args;events.EventEmitter.prototype.emit.apply(this,["callback"].concat(args));var err=args[0];if(err&&this.retryPredicate_(err)){this.backoff_.backoff(err)}else{this.state_=FunctionCall.State_.COMPLETED;this.doCallback_()}};FunctionCall.prototype.handleBackoff_=function(number,delay,err){this.emit("backoff",number,delay,err)};module.exports=FunctionCall},{"./backoff":36,"./strategy/fibonacci":39,events:43,precond:45,util:55}],38:[function(require,module,exports){var util=require("util");var precond=require("precond");var BackoffStrategy=require("./strategy");function ExponentialBackoffStrategy(options){BackoffStrategy.call(this,options);this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay();this.factor_=ExponentialBackoffStrategy.DEFAULT_FACTOR;if(options&&options.factor!==undefined){precond.checkArgument(options.factor>1,"Exponential factor should be greater than 1 but got %s.",options.factor);this.factor_=options.factor}}util.inherits(ExponentialBackoffStrategy,BackoffStrategy);ExponentialBackoffStrategy.DEFAULT_FACTOR=2;ExponentialBackoffStrategy.prototype.next_=function(){this.backoffDelay_=Math.min(this.nextBackoffDelay_,this.getMaxDelay());this.nextBackoffDelay_=this.backoffDelay_*this.factor_;return this.backoffDelay_};ExponentialBackoffStrategy.prototype.reset_=function(){this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay()};module.exports=ExponentialBackoffStrategy},{"./strategy":40,precond:45,util:55}],39:[function(require,module,exports){var util=require("util");var BackoffStrategy=require("./strategy");function FibonacciBackoffStrategy(options){BackoffStrategy.call(this,options);this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay()}util.inherits(FibonacciBackoffStrategy,BackoffStrategy);FibonacciBackoffStrategy.prototype.next_=function(){var backoffDelay=Math.min(this.nextBackoffDelay_,this.getMaxDelay());this.nextBackoffDelay_+=this.backoffDelay_;this.backoffDelay_=backoffDelay;return backoffDelay};FibonacciBackoffStrategy.prototype.reset_=function(){this.nextBackoffDelay_=this.getInitialDelay();this.backoffDelay_=0};module.exports=FibonacciBackoffStrategy},{"./strategy":40,util:55}],40:[function(require,module,exports){var events=require("events");var util=require("util");function isDef(value){return value!==undefined&&value!==null}function BackoffStrategy(options){options=options||{};if(isDef(options.initialDelay)&&options.initialDelay<1){throw new Error("The initial timeout must be greater than 0.")}else if(isDef(options.maxDelay)&&options.maxDelay<1){throw new Error("The maximal timeout must be greater than 0.")}this.initialDelay_=options.initialDelay||100;this.maxDelay_=options.maxDelay||1e4;if(this.maxDelay_<=this.initialDelay_){throw new Error("The maximal backoff delay must be "+"greater than the initial backoff delay.")}if(isDef(options.randomisationFactor)&&(options.randomisationFactor<0||options.randomisationFactor>1)){throw new Error("The randomisation factor must be between 0 and 1.")}this.randomisationFactor_=options.randomisationFactor||0}BackoffStrategy.prototype.getMaxDelay=function(){return this.maxDelay_};BackoffStrategy.prototype.getInitialDelay=function(){return this.initialDelay_};BackoffStrategy.prototype.next=function(){var backoffDelay=this.next_();var randomisationMultiple=1+Math.random()*this.randomisationFactor_;var randomizedDelay=Math.round(backoffDelay*randomisationMultiple);return randomizedDelay};BackoffStrategy.prototype.next_=function(){throw new Error("BackoffStrategy.next_() unimplemented.")};BackoffStrategy.prototype.reset=function(){this.reset_()};BackoffStrategy.prototype.reset_=function(){throw new Error("BackoffStrategy.reset_() unimplemented.")};module.exports=BackoffStrategy},{events:43,util:55}],41:[function(require,module,exports){"use strict";exports.byteLength=byteLength;exports.toByteArray=toByteArray;exports.fromByteArray=fromByteArray;var lookup=[];var revLookup=[];var Arr=typeof Uint8Array!=="undefined"?Uint8Array:Array;var code="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(var i=0,len=code.length;i0){throw new Error("Invalid string. Length must be a multiple of 4")}var validLen=b64.indexOf("=");if(validLen===-1)validLen=len;var placeHoldersLen=validLen===len?0:4-validLen%4;return[validLen,placeHoldersLen]}function byteLength(b64){var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function _byteLength(b64,validLen,placeHoldersLen){return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function toByteArray(b64){var tmp;var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];var arr=new Arr(_byteLength(b64,validLen,placeHoldersLen));var curByte=0;var len=placeHoldersLen>0?validLen-4:validLen;for(var i=0;i>16&255;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}if(placeHoldersLen===2){tmp=revLookup[b64.charCodeAt(i)]<<2|revLookup[b64.charCodeAt(i+1)]>>4;arr[curByte++]=tmp&255}if(placeHoldersLen===1){tmp=revLookup[b64.charCodeAt(i)]<<10|revLookup[b64.charCodeAt(i+1)]<<4|revLookup[b64.charCodeAt(i+2)]>>2;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}return arr}function tripletToBase64(num){return lookup[num>>18&63]+lookup[num>>12&63]+lookup[num>>6&63]+lookup[num&63]}function encodeChunk(uint8,start,end){var tmp;var output=[];for(var i=start;ilen2?len2:i+maxChunkLength))}if(extraBytes===1){tmp=uint8[len-1];parts.push(lookup[tmp>>2]+lookup[tmp<<4&63]+"==")}else if(extraBytes===2){tmp=(uint8[len-2]<<8)+uint8[len-1];parts.push(lookup[tmp>>10]+lookup[tmp>>4&63]+lookup[tmp<<2&63]+"=")}return parts.join("")}},{}],42:[function(require,module,exports){"use strict";var base64=require("base64-js");var ieee754=require("ieee754");exports.Buffer=Buffer;exports.SlowBuffer=SlowBuffer;exports.INSPECT_MAX_BYTES=50;var K_MAX_LENGTH=2147483647;exports.kMaxLength=K_MAX_LENGTH;Buffer.TYPED_ARRAY_SUPPORT=typedArraySupport();if(!Buffer.TYPED_ARRAY_SUPPORT&&typeof console!=="undefined"&&typeof console.error==="function"){console.error("This browser lacks typed array (Uint8Array) support which is required by "+"`buffer` v5.x. Use `buffer` v4.x if you require old browser support.")}function typedArraySupport(){try{var arr=new Uint8Array(1);arr.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}};return arr.foo()===42}catch(e){return false}}Object.defineProperty(Buffer.prototype,"parent",{get:function(){if(!(this instanceof Buffer)){return undefined}return this.buffer}});Object.defineProperty(Buffer.prototype,"offset",{get:function(){if(!(this instanceof Buffer)){return undefined}return this.byteOffset}});function createBuffer(length){if(length>K_MAX_LENGTH){throw new RangeError("Invalid typed array length")}var buf=new Uint8Array(length);buf.__proto__=Buffer.prototype;return buf}function Buffer(arg,encodingOrOffset,length){if(typeof arg==="number"){if(typeof encodingOrOffset==="string"){throw new Error("If encoding is specified then the first argument must be a string")}return allocUnsafe(arg)}return from(arg,encodingOrOffset,length)}if(typeof Symbol!=="undefined"&&Symbol.species&&Buffer[Symbol.species]===Buffer){Object.defineProperty(Buffer,Symbol.species,{value:null,configurable:true,enumerable:false,writable:false})}Buffer.poolSize=8192;function from(value,encodingOrOffset,length){if(typeof value==="number"){throw new TypeError('"value" argument must not be a number')}if(isArrayBuffer(value)||value&&isArrayBuffer(value.buffer)){return fromArrayBuffer(value,encodingOrOffset,length)}if(typeof value==="string"){return fromString(value,encodingOrOffset)}return fromObject(value)}Buffer.from=function(value,encodingOrOffset,length){return from(value,encodingOrOffset,length)};Buffer.prototype.__proto__=Uint8Array.prototype;Buffer.__proto__=Uint8Array;function assertSize(size){if(typeof size!=="number"){throw new TypeError('"size" argument must be of type number')}else if(size<0){throw new RangeError('"size" argument must not be negative')}}function alloc(size,fill,encoding){assertSize(size);if(size<=0){return createBuffer(size)}if(fill!==undefined){return typeof encoding==="string"?createBuffer(size).fill(fill,encoding):createBuffer(size).fill(fill)}return createBuffer(size)}Buffer.alloc=function(size,fill,encoding){return alloc(size,fill,encoding)};function allocUnsafe(size){assertSize(size);return createBuffer(size<0?0:checked(size)|0)}Buffer.allocUnsafe=function(size){return allocUnsafe(size)};Buffer.allocUnsafeSlow=function(size){return allocUnsafe(size)};function fromString(string,encoding){if(typeof encoding!=="string"||encoding===""){encoding="utf8"}if(!Buffer.isEncoding(encoding)){throw new TypeError("Unknown encoding: "+encoding)}var length=byteLength(string,encoding)|0;var buf=createBuffer(length);var actual=buf.write(string,encoding);if(actual!==length){buf=buf.slice(0,actual)}return buf}function fromArrayLike(array){var length=array.length<0?0:checked(array.length)|0;var buf=createBuffer(length);for(var i=0;i=K_MAX_LENGTH){throw new RangeError("Attempt to allocate Buffer larger than maximum "+"size: 0x"+K_MAX_LENGTH.toString(16)+" bytes")}return length|0}function SlowBuffer(length){if(+length!=length){length=0}return Buffer.alloc(+length)}Buffer.isBuffer=function isBuffer(b){return b!=null&&b._isBuffer===true};Buffer.compare=function compare(a,b){if(!Buffer.isBuffer(a)||!Buffer.isBuffer(b)){throw new TypeError("Arguments must be Buffers")}if(a===b)return 0;var x=a.length;var y=b.length;for(var i=0,len=Math.min(x,y);i>>1;case"base64":return base64ToBytes(string).length;default:if(loweredCase)return utf8ToBytes(string).length;encoding=(""+encoding).toLowerCase();loweredCase=true}}}Buffer.byteLength=byteLength;function slowToString(encoding,start,end){var loweredCase=false;if(start===undefined||start<0){start=0}if(start>this.length){return""}if(end===undefined||end>this.length){end=this.length}if(end<=0){return""}end>>>=0;start>>>=0;if(end<=start){return""}if(!encoding)encoding="utf8";while(true){switch(encoding){case"hex":return hexSlice(this,start,end);case"utf8":case"utf-8":return utf8Slice(this,start,end);case"ascii":return asciiSlice(this,start,end);case"latin1":case"binary":return latin1Slice(this,start,end);case"base64":return base64Slice(this,start,end);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,start,end);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(encoding+"").toLowerCase();loweredCase=true}}}Buffer.prototype._isBuffer=true;function swap(b,n,m){var i=b[n];b[n]=b[m];b[m]=i}Buffer.prototype.swap16=function swap16(){var len=this.length;if(len%2!==0){throw new RangeError("Buffer size must be a multiple of 16-bits")}for(var i=0;i0){str=this.toString("hex",0,max).match(/.{2}/g).join(" ");if(this.length>max)str+=" ... "}return""};Buffer.prototype.compare=function compare(target,start,end,thisStart,thisEnd){if(!Buffer.isBuffer(target)){throw new TypeError("Argument must be a Buffer")}if(start===undefined){start=0}if(end===undefined){end=target?target.length:0}if(thisStart===undefined){thisStart=0}if(thisEnd===undefined){thisEnd=this.length}if(start<0||end>target.length||thisStart<0||thisEnd>this.length){throw new RangeError("out of range index")}if(thisStart>=thisEnd&&start>=end){return 0}if(thisStart>=thisEnd){return-1}if(start>=end){return 1}start>>>=0;end>>>=0;thisStart>>>=0;thisEnd>>>=0;if(this===target)return 0;var x=thisEnd-thisStart;var y=end-start;var len=Math.min(x,y);var thisCopy=this.slice(thisStart,thisEnd);var targetCopy=target.slice(start,end);for(var i=0;i2147483647){byteOffset=2147483647}else if(byteOffset<-2147483648){byteOffset=-2147483648}byteOffset=+byteOffset;if(numberIsNaN(byteOffset)){byteOffset=dir?0:buffer.length-1}if(byteOffset<0)byteOffset=buffer.length+byteOffset;if(byteOffset>=buffer.length){if(dir)return-1;else byteOffset=buffer.length-1}else if(byteOffset<0){if(dir)byteOffset=0;else return-1}if(typeof val==="string"){val=Buffer.from(val,encoding)}if(Buffer.isBuffer(val)){if(val.length===0){return-1}return arrayIndexOf(buffer,val,byteOffset,encoding,dir)}else if(typeof val==="number"){val=val&255;if(typeof Uint8Array.prototype.indexOf==="function"){if(dir){return Uint8Array.prototype.indexOf.call(buffer,val,byteOffset)}else{return Uint8Array.prototype.lastIndexOf.call(buffer,val,byteOffset)}}return arrayIndexOf(buffer,[val],byteOffset,encoding,dir)}throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(arr,val,byteOffset,encoding,dir){var indexSize=1;var arrLength=arr.length;var valLength=val.length;if(encoding!==undefined){encoding=String(encoding).toLowerCase();if(encoding==="ucs2"||encoding==="ucs-2"||encoding==="utf16le"||encoding==="utf-16le"){if(arr.length<2||val.length<2){return-1}indexSize=2;arrLength/=2;valLength/=2;byteOffset/=2}}function read(buf,i){if(indexSize===1){return buf[i]}else{return buf.readUInt16BE(i*indexSize)}}var i;if(dir){var foundIndex=-1;for(i=byteOffset;iarrLength)byteOffset=arrLength-valLength;for(i=byteOffset;i>=0;i--){var found=true;for(var j=0;jremaining){length=remaining}}var strLen=string.length;if(length>strLen/2){length=strLen/2}for(var i=0;i>>0;if(isFinite(length)){length=length>>>0;if(encoding===undefined)encoding="utf8"}else{encoding=length;length=undefined}}else{throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported")}var remaining=this.length-offset;if(length===undefined||length>remaining)length=remaining;if(string.length>0&&(length<0||offset<0)||offset>this.length){throw new RangeError("Attempt to write outside buffer bounds")}if(!encoding)encoding="utf8";var loweredCase=false;for(;;){switch(encoding){case"hex":return hexWrite(this,string,offset,length);case"utf8":case"utf-8":return utf8Write(this,string,offset,length);case"ascii":return asciiWrite(this,string,offset,length);case"latin1":case"binary":return latin1Write(this,string,offset,length);case"base64":return base64Write(this,string,offset,length);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,string,offset,length);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(""+encoding).toLowerCase();loweredCase=true}}};Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};function base64Slice(buf,start,end){if(start===0&&end===buf.length){return base64.fromByteArray(buf)}else{return base64.fromByteArray(buf.slice(start,end))}}function utf8Slice(buf,start,end){end=Math.min(buf.length,end);var res=[];var i=start;while(i239?4:firstByte>223?3:firstByte>191?2:1;if(i+bytesPerSequence<=end){var secondByte,thirdByte,fourthByte,tempCodePoint;switch(bytesPerSequence){case 1:if(firstByte<128){codePoint=firstByte}break;case 2:secondByte=buf[i+1];if((secondByte&192)===128){tempCodePoint=(firstByte&31)<<6|secondByte&63;if(tempCodePoint>127){codePoint=tempCodePoint}}break;case 3:secondByte=buf[i+1];thirdByte=buf[i+2];if((secondByte&192)===128&&(thirdByte&192)===128){tempCodePoint=(firstByte&15)<<12|(secondByte&63)<<6|thirdByte&63;if(tempCodePoint>2047&&(tempCodePoint<55296||tempCodePoint>57343)){codePoint=tempCodePoint}}break;case 4:secondByte=buf[i+1];thirdByte=buf[i+2];fourthByte=buf[i+3];if((secondByte&192)===128&&(thirdByte&192)===128&&(fourthByte&192)===128){tempCodePoint=(firstByte&15)<<18|(secondByte&63)<<12|(thirdByte&63)<<6|fourthByte&63;if(tempCodePoint>65535&&tempCodePoint<1114112){codePoint=tempCodePoint}}}}if(codePoint===null){codePoint=65533;bytesPerSequence=1}else if(codePoint>65535){codePoint-=65536;res.push(codePoint>>>10&1023|55296);codePoint=56320|codePoint&1023}res.push(codePoint);i+=bytesPerSequence}return decodeCodePointsArray(res)}var MAX_ARGUMENTS_LENGTH=4096;function decodeCodePointsArray(codePoints){var len=codePoints.length;if(len<=MAX_ARGUMENTS_LENGTH){return String.fromCharCode.apply(String,codePoints)}var res="";var i=0;while(ilen)end=len;var out="";for(var i=start;ilen){start=len}if(end<0){end+=len;if(end<0)end=0}else if(end>len){end=len}if(endlength)throw new RangeError("Trying to access beyond buffer length")}Buffer.prototype.readUIntLE=function readUIntLE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var val=this[offset];var mul=1;var i=0;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){checkOffset(offset,byteLength,this.length)}var val=this[offset+--byteLength];var mul=1;while(byteLength>0&&(mul*=256)){val+=this[offset+--byteLength]*mul}return val};Buffer.prototype.readUInt8=function readUInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);return this[offset]};Buffer.prototype.readUInt16LE=function readUInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]|this[offset+1]<<8};Buffer.prototype.readUInt16BE=function readUInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]<<8|this[offset+1]};Buffer.prototype.readUInt32LE=function readUInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+this[offset+3]*16777216};Buffer.prototype.readUInt32BE=function readUInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]*16777216+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])};Buffer.prototype.readIntLE=function readIntLE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var val=this[offset];var mul=1;var i=0;while(++i=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readIntBE=function readIntBE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var i=byteLength;var mul=1;var val=this[offset+--i];while(i>0&&(mul*=256)){val+=this[offset+--i]*mul}mul*=128;if(val>=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readInt8=function readInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);if(!(this[offset]&128))return this[offset];return(255-this[offset]+1)*-1};Buffer.prototype.readInt16LE=function readInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);var val=this[offset]|this[offset+1]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt16BE=function readInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);var val=this[offset+1]|this[offset]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt32LE=function readInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24};Buffer.prototype.readInt32BE=function readInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]};Buffer.prototype.readFloatLE=function readFloatLE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,true,23,4)};Buffer.prototype.readFloatBE=function readFloatBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,false,23,4)};Buffer.prototype.readDoubleLE=function readDoubleLE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,true,52,8)};Buffer.prototype.readDoubleBE=function readDoubleBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,false,52,8)};function checkInt(buf,value,offset,ext,max,min){if(!Buffer.isBuffer(buf))throw new TypeError('"buffer" argument must be a Buffer instance');if(value>max||valuebuf.length)throw new RangeError("Index out of range")}Buffer.prototype.writeUIntLE=function writeUIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var mul=1;var i=0;this[offset]=value&255;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var i=byteLength-1;var mul=1;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){this[offset+i]=value/mul&255}return offset+byteLength};Buffer.prototype.writeUInt8=function writeUInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,255,0);this[offset]=value&255;return offset+1};Buffer.prototype.writeUInt16LE=function writeUInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeUInt16BE=function writeUInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeUInt32LE=function writeUInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset+3]=value>>>24;this[offset+2]=value>>>16;this[offset+1]=value>>>8;this[offset]=value&255;return offset+4};Buffer.prototype.writeUInt32BE=function writeUInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};Buffer.prototype.writeIntLE=function writeIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=0;var mul=1;var sub=0;this[offset]=value&255;while(++i>0)-sub&255}return offset+byteLength};Buffer.prototype.writeIntBE=function writeIntBE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=byteLength-1;var mul=1;var sub=0;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){if(value<0&&sub===0&&this[offset+i+1]!==0){sub=1}this[offset+i]=(value/mul>>0)-sub&255}return offset+byteLength};Buffer.prototype.writeInt8=function writeInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,127,-128);if(value<0)value=255+value+1;this[offset]=value&255;return offset+1};Buffer.prototype.writeInt16LE=function writeInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeInt16BE=function writeInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeInt32LE=function writeInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);this[offset]=value&255;this[offset+1]=value>>>8;this[offset+2]=value>>>16;this[offset+3]=value>>>24;return offset+4};Buffer.prototype.writeInt32BE=function writeInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);if(value<0)value=4294967295+value+1;this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};function checkIEEE754(buf,value,offset,ext,max,min){if(offset+ext>buf.length)throw new RangeError("Index out of range");if(offset<0)throw new RangeError("Index out of range")}function writeFloat(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,4,3.4028234663852886e38,-3.4028234663852886e38)}ieee754.write(buf,value,offset,littleEndian,23,4);return offset+4}Buffer.prototype.writeFloatLE=function writeFloatLE(value,offset,noAssert){return writeFloat(this,value,offset,true,noAssert)};Buffer.prototype.writeFloatBE=function writeFloatBE(value,offset,noAssert){return writeFloat(this,value,offset,false,noAssert)};function writeDouble(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,8,1.7976931348623157e308,-1.7976931348623157e308)}ieee754.write(buf,value,offset,littleEndian,52,8);return offset+8}Buffer.prototype.writeDoubleLE=function writeDoubleLE(value,offset,noAssert){return writeDouble(this,value,offset,true,noAssert)};Buffer.prototype.writeDoubleBE=function writeDoubleBE(value,offset,noAssert){return writeDouble(this,value,offset,false,noAssert)};Buffer.prototype.copy=function copy(target,targetStart,start,end){if(!Buffer.isBuffer(target))throw new TypeError("argument should be a Buffer");if(!start)start=0;if(!end&&end!==0)end=this.length;if(targetStart>=target.length)targetStart=target.length;if(!targetStart)targetStart=0;if(end>0&&end=this.length)throw new RangeError("Index out of range");if(end<0)throw new RangeError("sourceEnd out of bounds");if(end>this.length)end=this.length;if(target.length-targetStart=0;--i){target[i+targetStart]=this[i+start]}}else{Uint8Array.prototype.set.call(target,this.subarray(start,end),targetStart)}return len};Buffer.prototype.fill=function fill(val,start,end,encoding){if(typeof val==="string"){if(typeof start==="string"){encoding=start;start=0;end=this.length}else if(typeof end==="string"){encoding=end;end=this.length}if(encoding!==undefined&&typeof encoding!=="string"){throw new TypeError("encoding must be a string")}if(typeof encoding==="string"&&!Buffer.isEncoding(encoding)){throw new TypeError("Unknown encoding: "+encoding)}if(val.length===1){var code=val.charCodeAt(0);if(encoding==="utf8"&&code<128||encoding==="latin1"){val=code}}}else if(typeof val==="number"){val=val&255}if(start<0||this.length>>0;end=end===undefined?this.length:end>>>0;if(!val)val=0;var i;if(typeof val==="number"){for(i=start;i55295&&codePoint<57344){if(!leadSurrogate){if(codePoint>56319){if((units-=3)>-1)bytes.push(239,191,189);continue}else if(i+1===length){if((units-=3)>-1)bytes.push(239,191,189);continue}leadSurrogate=codePoint;continue}if(codePoint<56320){if((units-=3)>-1)bytes.push(239,191,189);leadSurrogate=codePoint;continue}codePoint=(leadSurrogate-55296<<10|codePoint-56320)+65536}else if(leadSurrogate){if((units-=3)>-1)bytes.push(239,191,189)}leadSurrogate=null;if(codePoint<128){if((units-=1)<0)break;bytes.push(codePoint)}else if(codePoint<2048){if((units-=2)<0)break;bytes.push(codePoint>>6|192,codePoint&63|128)}else if(codePoint<65536){if((units-=3)<0)break;bytes.push(codePoint>>12|224,codePoint>>6&63|128,codePoint&63|128)}else if(codePoint<1114112){if((units-=4)<0)break;bytes.push(codePoint>>18|240,codePoint>>12&63|128,codePoint>>6&63|128,codePoint&63|128)}else{throw new Error("Invalid code point")}}return bytes}function asciiToBytes(str){var byteArray=[];for(var i=0;i>8;lo=c%256;byteArray.push(lo);byteArray.push(hi)}return byteArray}function base64ToBytes(str){return base64.toByteArray(base64clean(str))}function blitBuffer(src,dst,offset,length){for(var i=0;i=dst.length||i>=src.length)break;dst[i+offset]=src[i]}return i}function isArrayBuffer(obj){return obj instanceof ArrayBuffer||obj!=null&&obj.constructor!=null&&obj.constructor.name==="ArrayBuffer"&&typeof obj.byteLength==="number"}function numberIsNaN(obj){return obj!==obj}},{"base64-js":41,ieee754:44}],43:[function(require,module,exports){function EventEmitter(){this._events=this._events||{};this._maxListeners=this._maxListeners||undefined}module.exports=EventEmitter;EventEmitter.EventEmitter=EventEmitter;EventEmitter.prototype._events=undefined;EventEmitter.prototype._maxListeners=undefined;EventEmitter.defaultMaxListeners=10;EventEmitter.prototype.setMaxListeners=function(n){if(!isNumber(n)||n<0||isNaN(n))throw TypeError("n must be a positive number");this._maxListeners=n;return this};EventEmitter.prototype.emit=function(type){var er,handler,len,args,i,listeners;if(!this._events)this._events={};if(type==="error"){if(!this._events.error||isObject(this._events.error)&&!this._events.error.length){er=arguments[1];if(er instanceof Error){throw er}else{var err=new Error('Uncaught, unspecified "error" event. ('+er+")");err.context=er;throw err}}}handler=this._events[type];if(isUndefined(handler))return false;if(isFunction(handler)){switch(arguments.length){case 1:handler.call(this);break;case 2:handler.call(this,arguments[1]);break;case 3:handler.call(this,arguments[1],arguments[2]);break;default:args=Array.prototype.slice.call(arguments,1);handler.apply(this,args)}}else if(isObject(handler)){args=Array.prototype.slice.call(arguments,1);listeners=handler.slice();len=listeners.length;for(i=0;i0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],44:[function(require,module,exports){exports.read=function(buffer,offset,isLE,mLen,nBytes){var e,m;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var nBits=-7;var i=isLE?nBytes-1:0;var d=isLE?-1:1;var s=buffer[offset+i];i+=d;e=s&(1<<-nBits)-1;s>>=-nBits;nBits+=eLen;for(;nBits>0;e=e*256+buffer[offset+i],i+=d,nBits-=8){}m=e&(1<<-nBits)-1;e>>=-nBits;nBits+=mLen;for(;nBits>0;m=m*256+buffer[offset+i],i+=d,nBits-=8){}if(e===0){e=1-eBias}else if(e===eMax){return m?NaN:(s?-1:1)*Infinity}else{m=m+Math.pow(2,mLen);e=e-eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)};exports.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var rt=mLen===23?Math.pow(2,-24)-Math.pow(2,-77):0;var i=isLE?0:nBytes-1;var d=isLE?1:-1;var s=value<0||value===0&&1/value<0?1:0;value=Math.abs(value);if(isNaN(value)||value===Infinity){m=isNaN(value)?1:0;e=eMax}else{e=Math.floor(Math.log(value)/Math.LN2);if(value*(c=Math.pow(2,-e))<1){e--;c*=2}if(e+eBias>=1){value+=rt/c}else{value+=rt*Math.pow(2,1-eBias)}if(value*c>=2){e++;c/=2}if(e+eBias>=eMax){m=0;e=eMax}else if(e+eBias>=1){m=(value*c-1)*Math.pow(2,mLen);e=e+eBias}else{m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen);e=0}}for(;mLen>=8;buffer[offset+i]=m&255,i+=d,m/=256,mLen-=8){}e=e<0;buffer[offset+i]=e&255,i+=d,e/=256,eLen-=8){}buffer[offset+i-d]|=s*128}},{}],45:[function(require,module,exports){module.exports=require("./lib/checks")},{"./lib/checks":46}],46:[function(require,module,exports){var util=require("util");var errors=module.exports=require("./errors");function failCheck(ExceptionConstructor,callee,messageFormat,formatArgs){messageFormat=messageFormat||"";var message=util.format.apply(this,[messageFormat].concat(formatArgs));var error=new ExceptionConstructor(message);Error.captureStackTrace(error,callee);throw error}function failArgumentCheck(callee,message,formatArgs){failCheck(errors.IllegalArgumentError,callee,message,formatArgs)}function failStateCheck(callee,message,formatArgs){failCheck(errors.IllegalStateError,callee,message,formatArgs)}module.exports.checkArgument=function(value,message){if(!value){failArgumentCheck(arguments.callee,message,Array.prototype.slice.call(arguments,2))}};module.exports.checkState=function(value,message){if(!value){failStateCheck(arguments.callee,message,Array.prototype.slice.call(arguments,2))}};module.exports.checkIsDef=function(value,message){if(value!==undefined){return value}failArgumentCheck(arguments.callee,message||"Expected value to be defined but was undefined.",Array.prototype.slice.call(arguments,2))};module.exports.checkIsDefAndNotNull=function(value,message){if(value!=null){return value}failArgumentCheck(arguments.callee,message||'Expected value to be defined and not null but got "'+typeOf(value)+'".',Array.prototype.slice.call(arguments,2))};function typeOf(value){var s=typeof value;if(s=="object"){if(!value){return"null"}else if(value instanceof Array){return"array"}}return s}function typeCheck(expect){return function(value,message){var type=typeOf(value);if(type==expect){return value}failArgumentCheck(arguments.callee,message||'Expected "'+expect+'" but got "'+type+'".',Array.prototype.slice.call(arguments,2))}}module.exports.checkIsString=typeCheck("string");module.exports.checkIsArray=typeCheck("array");module.exports.checkIsNumber=typeCheck("number");module.exports.checkIsBoolean=typeCheck("boolean");module.exports.checkIsFunction=typeCheck("function");module.exports.checkIsObject=typeCheck("object")},{"./errors":47,util:55}],47:[function(require,module,exports){var util=require("util");function IllegalArgumentError(message){Error.call(this,message);this.message=message}util.inherits(IllegalArgumentError,Error);IllegalArgumentError.prototype.name="IllegalArgumentError";function IllegalStateError(message){Error.call(this,message);this.message=message}util.inherits(IllegalStateError,Error);IllegalStateError.prototype.name="IllegalStateError";module.exports.IllegalStateError=IllegalStateError;module.exports.IllegalArgumentError=IllegalArgumentError},{util:55}],48:[function(require,module,exports){var process=module.exports={};var cachedSetTimeout;var cachedClearTimeout;function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}(function(){try{if(typeof setTimeout==="function"){cachedSetTimeout=setTimeout}else{cachedSetTimeout=defaultSetTimout}}catch(e){cachedSetTimeout=defaultSetTimout}try{if(typeof clearTimeout==="function"){cachedClearTimeout=clearTimeout}else{cachedClearTimeout=defaultClearTimeout}}catch(e){cachedClearTimeout=defaultClearTimeout}})();function runTimeout(fun){if(cachedSetTimeout===setTimeout){return setTimeout(fun,0)}if((cachedSetTimeout===defaultSetTimout||!cachedSetTimeout)&&setTimeout){cachedSetTimeout=setTimeout;return setTimeout(fun,0)}try{return cachedSetTimeout(fun,0)}catch(e){try{return cachedSetTimeout.call(null,fun,0)}catch(e){return cachedSetTimeout.call(this,fun,0)}}}function runClearTimeout(marker){if(cachedClearTimeout===clearTimeout){return clearTimeout(marker)}if((cachedClearTimeout===defaultClearTimeout||!cachedClearTimeout)&&clearTimeout){cachedClearTimeout=clearTimeout;return clearTimeout(marker)}try{return cachedClearTimeout(marker)}catch(e){try{return cachedClearTimeout.call(null,marker)}catch(e){return cachedClearTimeout.call(this,marker)}}}var queue=[];var draining=false;var currentQueue;var queueIndex=-1;function cleanUpNextTick(){if(!draining||!currentQueue){return}draining=false;if(currentQueue.length){queue=currentQueue.concat(queue)}else{queueIndex=-1}if(queue.length){drainQueue()}}function drainQueue(){if(draining){return}var timeout=runTimeout(cleanUpNextTick);draining=true;var len=queue.length;while(len){currentQueue=queue;queue=[];while(++queueIndex1){for(var i=1;i=0;var oldRuntime=hadRuntime&&g.regeneratorRuntime;g.regeneratorRuntime=undefined;module.exports=require("./runtime");if(hadRuntime){g.regeneratorRuntime=oldRuntime}else{try{delete g.regeneratorRuntime}catch(e){g.regeneratorRuntime=undefined}}},{"./runtime":50}],50:[function(require,module,exports){!function(global){"use strict";var Op=Object.prototype;var hasOwn=Op.hasOwnProperty;var undefined;var $Symbol=typeof Symbol==="function"?Symbol:{};var iteratorSymbol=$Symbol.iterator||"@@iterator";var asyncIteratorSymbol=$Symbol.asyncIterator||"@@asyncIterator";var toStringTagSymbol=$Symbol.toStringTag||"@@toStringTag";var inModule=typeof module==="object";var runtime=global.regeneratorRuntime;if(runtime){if(inModule){module.exports=runtime}return}runtime=global.regeneratorRuntime=inModule?module.exports:{};function wrap(innerFn,outerFn,self,tryLocsList){var protoGenerator=outerFn&&outerFn.prototype instanceof Generator?outerFn:Generator;var generator=Object.create(protoGenerator.prototype);var context=new Context(tryLocsList||[]);generator._invoke=makeInvokeMethod(innerFn,self,context);return generator}runtime.wrap=wrap;function tryCatch(fn,obj,arg){try{return{type:"normal",arg:fn.call(obj,arg)}}catch(err){return{type:"throw",arg:err}}}var GenStateSuspendedStart="suspendedStart";var GenStateSuspendedYield="suspendedYield";var GenStateExecuting="executing";var GenStateCompleted="completed";var ContinueSentinel={};function Generator(){}function GeneratorFunction(){}function GeneratorFunctionPrototype(){}var IteratorPrototype={};IteratorPrototype[iteratorSymbol]=function(){return this};var getProto=Object.getPrototypeOf;var NativeIteratorPrototype=getProto&&getProto(getProto(values([])));if(NativeIteratorPrototype&&NativeIteratorPrototype!==Op&&hasOwn.call(NativeIteratorPrototype,iteratorSymbol)){IteratorPrototype=NativeIteratorPrototype}var Gp=GeneratorFunctionPrototype.prototype=Generator.prototype=Object.create(IteratorPrototype);GeneratorFunction.prototype=Gp.constructor=GeneratorFunctionPrototype;GeneratorFunctionPrototype.constructor=GeneratorFunction;GeneratorFunctionPrototype[toStringTagSymbol]=GeneratorFunction.displayName="GeneratorFunction";function defineIteratorMethods(prototype){["next","throw","return"].forEach(function(method){prototype[method]=function(arg){return this._invoke(method,arg)}})}runtime.isGeneratorFunction=function(genFun){var ctor=typeof genFun==="function"&&genFun.constructor;return ctor?ctor===GeneratorFunction||(ctor.displayName||ctor.name)==="GeneratorFunction":false};runtime.mark=function(genFun){if(Object.setPrototypeOf){Object.setPrototypeOf(genFun,GeneratorFunctionPrototype)}else{genFun.__proto__=GeneratorFunctionPrototype;if(!(toStringTagSymbol in genFun)){genFun[toStringTagSymbol]="GeneratorFunction"}}genFun.prototype=Object.create(Gp);return genFun};runtime.awrap=function(arg){return{__await:arg}};function AsyncIterator(generator){function invoke(method,arg,resolve,reject){var record=tryCatch(generator[method],generator,arg);if(record.type==="throw"){reject(record.arg)}else{var result=record.arg;var value=result.value;if(value&&typeof value==="object"&&hasOwn.call(value,"__await")){return Promise.resolve(value.__await).then(function(value){invoke("next",value,resolve,reject)},function(err){invoke("throw",err,resolve,reject)})}return Promise.resolve(value).then(function(unwrapped){result.value=unwrapped;resolve(result)},reject)}}var previousPromise;function enqueue(method,arg){function callInvokeWithMethodAndArg(){return new Promise(function(resolve,reject){invoke(method,arg,resolve,reject)})}return previousPromise=previousPromise?previousPromise.then(callInvokeWithMethodAndArg,callInvokeWithMethodAndArg):callInvokeWithMethodAndArg()}this._invoke=enqueue}defineIteratorMethods(AsyncIterator.prototype);AsyncIterator.prototype[asyncIteratorSymbol]=function(){return this};runtime.AsyncIterator=AsyncIterator;runtime.async=function(innerFn,outerFn,self,tryLocsList){var iter=new AsyncIterator(wrap(innerFn,outerFn,self,tryLocsList));return runtime.isGeneratorFunction(outerFn)?iter:iter.next().then(function(result){return result.done?result.value:iter.next()})};function makeInvokeMethod(innerFn,self,context){var state=GenStateSuspendedStart;return function invoke(method,arg){if(state===GenStateExecuting){throw new Error("Generator is already running")}if(state===GenStateCompleted){if(method==="throw"){throw arg}return doneResult()}context.method=method;context.arg=arg;while(true){var delegate=context.delegate;if(delegate){var delegateResult=maybeInvokeDelegate(delegate,context);if(delegateResult){if(delegateResult===ContinueSentinel)continue;return delegateResult}}if(context.method==="next"){context.sent=context._sent=context.arg}else if(context.method==="throw"){if(state===GenStateSuspendedStart){state=GenStateCompleted;throw context.arg}context.dispatchException(context.arg)}else if(context.method==="return"){context.abrupt("return",context.arg)}state=GenStateExecuting;var record=tryCatch(innerFn,self,context);if(record.type==="normal"){state=context.done?GenStateCompleted:GenStateSuspendedYield;if(record.arg===ContinueSentinel){continue}return{value:record.arg,done:context.done}}else if(record.type==="throw"){state=GenStateCompleted;context.method="throw";context.arg=record.arg}}}}function maybeInvokeDelegate(delegate,context){var method=delegate.iterator[context.method];if(method===undefined){context.delegate=null;if(context.method==="throw"){if(delegate.iterator.return){context.method="return";context.arg=undefined;maybeInvokeDelegate(delegate,context);if(context.method==="throw"){return ContinueSentinel}}context.method="throw";context.arg=new TypeError("The iterator does not provide a 'throw' method")}return ContinueSentinel}var record=tryCatch(method,delegate.iterator,context.arg);if(record.type==="throw"){context.method="throw";context.arg=record.arg;context.delegate=null;return ContinueSentinel}var info=record.arg;if(!info){context.method="throw";context.arg=new TypeError("iterator result is not an object");context.delegate=null;return ContinueSentinel}if(info.done){context[delegate.resultName]=info.value;context.next=delegate.nextLoc;if(context.method!=="return"){context.method="next";context.arg=undefined}}else{return info}context.delegate=null;return ContinueSentinel}defineIteratorMethods(Gp);Gp[toStringTagSymbol]="Generator";Gp[iteratorSymbol]=function(){return this};Gp.toString=function(){return"[object Generator]"};function pushTryEntry(locs){var entry={tryLoc:locs[0]};if(1 in locs){entry.catchLoc=locs[1]}if(2 in locs){entry.finallyLoc=locs[2];entry.afterLoc=locs[3]}this.tryEntries.push(entry)}function resetTryEntry(entry){var record=entry.completion||{};record.type="normal";delete record.arg;entry.completion=record}function Context(tryLocsList){this.tryEntries=[{tryLoc:"root"}];tryLocsList.forEach(pushTryEntry,this);this.reset(true)}runtime.keys=function(object){var keys=[];for(var key in object){keys.push(key)}keys.reverse();return function next(){while(keys.length){var key=keys.pop();if(key in object){next.value=key;next.done=false;return next}}next.done=true;return next}};function values(iterable){if(iterable){var iteratorMethod=iterable[iteratorSymbol];if(iteratorMethod){return iteratorMethod.call(iterable)}if(typeof iterable.next==="function"){return iterable}if(!isNaN(iterable.length)){var i=-1,next=function next(){while(++i=0;--i){var entry=this.tryEntries[i];var record=entry.completion;if(entry.tryLoc==="root"){return handle("end")}if(entry.tryLoc<=this.prev){var hasCatch=hasOwn.call(entry,"catchLoc");var hasFinally=hasOwn.call(entry,"finallyLoc");if(hasCatch&&hasFinally){if(this.prev=0;--i){var entry=this.tryEntries[i];if(entry.tryLoc<=this.prev&&hasOwn.call(entry,"finallyLoc")&&this.prev=0;--i){var entry=this.tryEntries[i];if(entry.finallyLoc===finallyLoc){this.complete(entry.completion,entry.afterLoc);resetTryEntry(entry);return ContinueSentinel}}},catch:function(tryLoc){for(var i=this.tryEntries.length-1;i>=0;--i){var entry=this.tryEntries[i];if(entry.tryLoc===tryLoc){var record=entry.completion;if(record.type==="throw"){var thrown=record.arg;resetTryEntry(entry)}return thrown}}throw new Error("illegal catch attempt")},delegateYield:function(iterable,resultName,nextLoc){this.delegate={iterator:values(iterable),resultName:resultName,nextLoc:nextLoc};if(this.method==="next"){this.arg=undefined}return ContinueSentinel}}}(function(){return this}()||Function("return this")())},{}],51:[function(require,module,exports){"use strict";var SDPUtils=require("sdp");function fixStatsType(stat){return{inboundrtp:"inbound-rtp",outboundrtp:"outbound-rtp",candidatepair:"candidate-pair",localcandidate:"local-candidate",remotecandidate:"remote-candidate"}[stat.type]||stat.type}function writeMediaSection(transceiver,caps,type,stream,dtlsRole){var sdp=SDPUtils.writeRtpDescription(transceiver.kind,caps);sdp+=SDPUtils.writeIceParameters(transceiver.iceGatherer.getLocalParameters());sdp+=SDPUtils.writeDtlsParameters(transceiver.dtlsTransport.getLocalParameters(),type==="offer"?"actpass":dtlsRole||"active");sdp+="a=mid:"+transceiver.mid+"\r\n";if(transceiver.rtpSender&&transceiver.rtpReceiver){sdp+="a=sendrecv\r\n"}else if(transceiver.rtpSender){sdp+="a=sendonly\r\n"}else if(transceiver.rtpReceiver){sdp+="a=recvonly\r\n"}else{sdp+="a=inactive\r\n"}if(transceiver.rtpSender){var trackId=transceiver.rtpSender._initialTrackId||transceiver.rtpSender.track.id;transceiver.rtpSender._initialTrackId=trackId;var msid="msid:"+(stream?stream.id:"-")+" "+trackId+"\r\n";sdp+="a="+msid;sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" "+msid;if(transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" "+msid;sdp+="a=ssrc-group:FID "+transceiver.sendEncodingParameters[0].ssrc+" "+transceiver.sendEncodingParameters[0].rtx.ssrc+"\r\n"}}sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" cname:"+SDPUtils.localCName+"\r\n";if(transceiver.rtpSender&&transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" cname:"+SDPUtils.localCName+"\r\n"}return sdp}function filterIceServers(iceServers,edgeVersion){var hasTurn=false;iceServers=JSON.parse(JSON.stringify(iceServers));return iceServers.filter(function(server){if(server&&(server.urls||server.url)){var urls=server.urls||server.url;if(server.url&&!server.urls){console.warn("RTCIceServer.url is deprecated! Use urls instead.")}var isString=typeof urls==="string";if(isString){urls=[urls]}urls=urls.filter(function(url){var validTurn=url.indexOf("turn:")===0&&url.indexOf("transport=udp")!==-1&&url.indexOf("turn:[")===-1&&!hasTurn;if(validTurn){hasTurn=true;return true}return url.indexOf("stun:")===0&&edgeVersion>=14393&&url.indexOf("?transport=udp")===-1});delete server.url;server.urls=isString?urls[0]:urls;return!!urls.length}})}function getCommonCapabilities(localCapabilities,remoteCapabilities){var commonCapabilities={codecs:[],headerExtensions:[],fecMechanisms:[]};var findCodecByPayloadType=function(pt,codecs){pt=parseInt(pt,10);for(var i=0;i0;i--){this._iceGatherers.push(new window.RTCIceGatherer({iceServers:config.iceServers,gatherPolicy:config.iceTransportPolicy}))}}else{config.iceCandidatePoolSize=0}this._config=config;this.transceivers=[];this._sdpSessionId=SDPUtils.generateSessionId();this._sdpSessionVersion=0;this._dtlsRole=undefined;this._isClosed=false};RTCPeerConnection.prototype.onicecandidate=null;RTCPeerConnection.prototype.onaddstream=null;RTCPeerConnection.prototype.ontrack=null;RTCPeerConnection.prototype.onremovestream=null;RTCPeerConnection.prototype.onsignalingstatechange=null;RTCPeerConnection.prototype.oniceconnectionstatechange=null;RTCPeerConnection.prototype.onconnectionstatechange=null;RTCPeerConnection.prototype.onicegatheringstatechange=null;RTCPeerConnection.prototype.onnegotiationneeded=null;RTCPeerConnection.prototype.ondatachannel=null;RTCPeerConnection.prototype._dispatchEvent=function(name,event){if(this._isClosed){return}this.dispatchEvent(event);if(typeof this["on"+name]==="function"){this["on"+name](event)}};RTCPeerConnection.prototype._emitGatheringStateChange=function(){var event=new Event("icegatheringstatechange");this._dispatchEvent("icegatheringstatechange",event)};RTCPeerConnection.prototype.getConfiguration=function(){return this._config};RTCPeerConnection.prototype.getLocalStreams=function(){return this.localStreams};RTCPeerConnection.prototype.getRemoteStreams=function(){return this.remoteStreams};RTCPeerConnection.prototype._createTransceiver=function(kind,doNotAdd){var hasBundleTransport=this.transceivers.length>0;var transceiver={track:null,iceGatherer:null,iceTransport:null,dtlsTransport:null,localCapabilities:null,remoteCapabilities:null,rtpSender:null,rtpReceiver:null,kind:kind,mid:null,sendEncodingParameters:null,recvEncodingParameters:null,stream:null,associatedRemoteMediaStreams:[],wantReceive:true};if(this.usingBundle&&hasBundleTransport){transceiver.iceTransport=this.transceivers[0].iceTransport;transceiver.dtlsTransport=this.transceivers[0].dtlsTransport}else{var transports=this._createIceAndDtlsTransports();transceiver.iceTransport=transports.iceTransport;transceiver.dtlsTransport=transports.dtlsTransport}if(!doNotAdd){this.transceivers.push(transceiver)}return transceiver};RTCPeerConnection.prototype.addTrack=function(track,stream){if(this._isClosed){throw makeError("InvalidStateError","Attempted to call addTrack on a closed peerconnection.")}var alreadyExists=this.transceivers.find(function(s){return s.track===track});if(alreadyExists){throw makeError("InvalidAccessError","Track already exists.")}var transceiver;for(var i=0;i=15025){stream.getTracks().forEach(function(track){pc.addTrack(track,stream)})}else{var clonedStream=stream.clone();stream.getTracks().forEach(function(track,idx){var clonedTrack=clonedStream.getTracks()[idx];track.addEventListener("enabled",function(event){clonedTrack.enabled=event.enabled})});clonedStream.getTracks().forEach(function(track){pc.addTrack(track,clonedStream)})}};RTCPeerConnection.prototype.removeTrack=function(sender){if(this._isClosed){throw makeError("InvalidStateError","Attempted to call removeTrack on a closed peerconnection.")}if(!(sender instanceof window.RTCRtpSender)){throw new TypeError("Argument 1 of RTCPeerConnection.removeTrack "+"does not implement interface RTCRtpSender.")}var transceiver=this.transceivers.find(function(t){return t.rtpSender===sender});if(!transceiver){throw makeError("InvalidAccessError","Sender was not created by this connection.")}var stream=transceiver.stream;transceiver.rtpSender.stop();transceiver.rtpSender=null;transceiver.track=null;transceiver.stream=null;var localStreams=this.transceivers.map(function(t){return t.stream});if(localStreams.indexOf(stream)===-1&&this.localStreams.indexOf(stream)>-1){this.localStreams.splice(this.localStreams.indexOf(stream),1)}this._maybeFireNegotiationNeeded()};RTCPeerConnection.prototype.removeStream=function(stream){var pc=this;stream.getTracks().forEach(function(track){var sender=pc.getSenders().find(function(s){return s.track===track});if(sender){pc.removeTrack(sender)}})};RTCPeerConnection.prototype.getSenders=function(){return this.transceivers.filter(function(transceiver){return!!transceiver.rtpSender}).map(function(transceiver){return transceiver.rtpSender})};RTCPeerConnection.prototype.getReceivers=function(){return this.transceivers.filter(function(transceiver){return!!transceiver.rtpReceiver}).map(function(transceiver){return transceiver.rtpReceiver})};RTCPeerConnection.prototype._createIceGatherer=function(sdpMLineIndex,usingBundle){var pc=this;if(usingBundle&&sdpMLineIndex>0){return this.transceivers[0].iceGatherer}else if(this._iceGatherers.length){return this._iceGatherers.shift()}var iceGatherer=new window.RTCIceGatherer({iceServers:this._config.iceServers,gatherPolicy:this._config.iceTransportPolicy});Object.defineProperty(iceGatherer,"state",{value:"new",writable:true});this.transceivers[sdpMLineIndex].bufferedCandidateEvents=[];this.transceivers[sdpMLineIndex].bufferCandidates=function(event){var end=!event.candidate||Object.keys(event.candidate).length===0;iceGatherer.state=end?"completed":"gathering";if(pc.transceivers[sdpMLineIndex].bufferedCandidateEvents!==null){pc.transceivers[sdpMLineIndex].bufferedCandidateEvents.push(event)}};iceGatherer.addEventListener("localcandidate",this.transceivers[sdpMLineIndex].bufferCandidates);return iceGatherer};RTCPeerConnection.prototype._gather=function(mid,sdpMLineIndex){var pc=this;var iceGatherer=this.transceivers[sdpMLineIndex].iceGatherer;if(iceGatherer.onlocalcandidate){return}var bufferedCandidateEvents=this.transceivers[sdpMLineIndex].bufferedCandidateEvents;this.transceivers[sdpMLineIndex].bufferedCandidateEvents=null;iceGatherer.removeEventListener("localcandidate",this.transceivers[sdpMLineIndex].bufferCandidates);iceGatherer.onlocalcandidate=function(evt){if(pc.usingBundle&&sdpMLineIndex>0){return}var event=new Event("icecandidate");event.candidate={sdpMid:mid,sdpMLineIndex:sdpMLineIndex};var cand=evt.candidate;var end=!cand||Object.keys(cand).length===0;if(end){if(iceGatherer.state==="new"||iceGatherer.state==="gathering"){iceGatherer.state="completed"}}else{if(iceGatherer.state==="new"){iceGatherer.state="gathering"}cand.component=1;cand.ufrag=iceGatherer.getLocalParameters().usernameFragment;var serializedCandidate=SDPUtils.writeCandidate(cand);event.candidate=Object.assign(event.candidate,SDPUtils.parseCandidate(serializedCandidate));event.candidate.candidate=serializedCandidate;event.candidate.toJSON=function(){return{candidate:event.candidate.candidate,sdpMid:event.candidate.sdpMid,sdpMLineIndex:event.candidate.sdpMLineIndex,usernameFragment:event.candidate.usernameFragment}}}var sections=SDPUtils.getMediaSections(pc.localDescription.sdp);if(!end){sections[event.candidate.sdpMLineIndex]+="a="+event.candidate.candidate+"\r\n"}else{sections[event.candidate.sdpMLineIndex]+="a=end-of-candidates\r\n"}pc.localDescription.sdp=SDPUtils.getDescription(pc.localDescription.sdp)+sections.join("");var complete=pc.transceivers.every(function(transceiver){return transceiver.iceGatherer&&transceiver.iceGatherer.state==="completed"});if(pc.iceGatheringState!=="gathering"){pc.iceGatheringState="gathering";pc._emitGatheringStateChange()}if(!end){pc._dispatchEvent("icecandidate",event)}if(complete){pc._dispatchEvent("icecandidate",new Event("icecandidate"));pc.iceGatheringState="complete";pc._emitGatheringStateChange()}};window.setTimeout(function(){bufferedCandidateEvents.forEach(function(e){iceGatherer.onlocalcandidate(e)})},0)};RTCPeerConnection.prototype._createIceAndDtlsTransports=function(){var pc=this;var iceTransport=new window.RTCIceTransport(null);iceTransport.onicestatechange=function(){pc._updateIceConnectionState();pc._updateConnectionState()};var dtlsTransport=new window.RTCDtlsTransport(iceTransport);dtlsTransport.ondtlsstatechange=function(){pc._updateConnectionState()};dtlsTransport.onerror=function(){Object.defineProperty(dtlsTransport,"state",{value:"failed",writable:true});pc._updateConnectionState()};return{iceTransport:iceTransport,dtlsTransport:dtlsTransport}};RTCPeerConnection.prototype._disposeIceAndDtlsTransports=function(sdpMLineIndex){var iceGatherer=this.transceivers[sdpMLineIndex].iceGatherer;if(iceGatherer){delete iceGatherer.onlocalcandidate;delete this.transceivers[sdpMLineIndex].iceGatherer}var iceTransport=this.transceivers[sdpMLineIndex].iceTransport;if(iceTransport){delete iceTransport.onicestatechange;delete this.transceivers[sdpMLineIndex].iceTransport}var dtlsTransport=this.transceivers[sdpMLineIndex].dtlsTransport;if(dtlsTransport){delete dtlsTransport.ondtlsstatechange;delete dtlsTransport.onerror;delete this.transceivers[sdpMLineIndex].dtlsTransport}};RTCPeerConnection.prototype._transceive=function(transceiver,send,recv){var params=getCommonCapabilities(transceiver.localCapabilities,transceiver.remoteCapabilities);if(send&&transceiver.rtpSender){params.encodings=transceiver.sendEncodingParameters;params.rtcp={cname:SDPUtils.localCName,compound:transceiver.rtcpParameters.compound};if(transceiver.recvEncodingParameters.length){params.rtcp.ssrc=transceiver.recvEncodingParameters[0].ssrc}transceiver.rtpSender.send(params)}if(recv&&transceiver.rtpReceiver&¶ms.codecs.length>0){if(transceiver.kind==="video"&&transceiver.recvEncodingParameters&&edgeVersion<15019){transceiver.recvEncodingParameters.forEach(function(p){delete p.rtx})}if(transceiver.recvEncodingParameters.length){params.encodings=transceiver.recvEncodingParameters}else{params.encodings=[{}]}params.rtcp={compound:transceiver.rtcpParameters.compound};if(transceiver.rtcpParameters.cname){params.rtcp.cname=transceiver.rtcpParameters.cname}if(transceiver.sendEncodingParameters.length){params.rtcp.ssrc=transceiver.sendEncodingParameters[0].ssrc}transceiver.rtpReceiver.receive(params)}};RTCPeerConnection.prototype.setLocalDescription=function(description){var pc=this;if(["offer","answer"].indexOf(description.type)===-1){return Promise.reject(makeError("TypeError",'Unsupported type "'+description.type+'"'))}if(!isActionAllowedInSignalingState("setLocalDescription",description.type,pc.signalingState)||pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not set local "+description.type+" in state "+pc.signalingState))}var sections;var sessionpart;if(description.type==="offer"){sections=SDPUtils.splitSections(description.sdp);sessionpart=sections.shift();sections.forEach(function(mediaSection,sdpMLineIndex){var caps=SDPUtils.parseRtpParameters(mediaSection);pc.transceivers[sdpMLineIndex].localCapabilities=caps});pc.transceivers.forEach(function(transceiver,sdpMLineIndex){pc._gather(transceiver.mid,sdpMLineIndex)})}else if(description.type==="answer"){sections=SDPUtils.splitSections(pc.remoteDescription.sdp);sessionpart=sections.shift();var isIceLite=SDPUtils.matchPrefix(sessionpart,"a=ice-lite").length>0;sections.forEach(function(mediaSection,sdpMLineIndex){var transceiver=pc.transceivers[sdpMLineIndex];var iceGatherer=transceiver.iceGatherer;var iceTransport=transceiver.iceTransport;var dtlsTransport=transceiver.dtlsTransport;var localCapabilities=transceiver.localCapabilities;var remoteCapabilities=transceiver.remoteCapabilities;var rejected=SDPUtils.isRejected(mediaSection)&&SDPUtils.matchPrefix(mediaSection,"a=bundle-only").length===0;if(!rejected&&!transceiver.rejected){var remoteIceParameters=SDPUtils.getIceParameters(mediaSection,sessionpart);var remoteDtlsParameters=SDPUtils.getDtlsParameters(mediaSection,sessionpart);if(isIceLite){remoteDtlsParameters.role="server"}if(!pc.usingBundle||sdpMLineIndex===0){pc._gather(transceiver.mid,sdpMLineIndex);if(iceTransport.state==="new"){iceTransport.start(iceGatherer,remoteIceParameters,isIceLite?"controlling":"controlled")}if(dtlsTransport.state==="new"){dtlsTransport.start(remoteDtlsParameters)}}var params=getCommonCapabilities(localCapabilities,remoteCapabilities);pc._transceive(transceiver,params.codecs.length>0,false)}})}pc.localDescription={type:description.type,sdp:description.sdp};if(description.type==="offer"){pc._updateSignalingState("have-local-offer")}else{pc._updateSignalingState("stable")}return Promise.resolve()};RTCPeerConnection.prototype.setRemoteDescription=function(description){var pc=this;if(["offer","answer"].indexOf(description.type)===-1){return Promise.reject(makeError("TypeError",'Unsupported type "'+description.type+'"'))}if(!isActionAllowedInSignalingState("setRemoteDescription",description.type,pc.signalingState)||pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not set remote "+description.type+" in state "+pc.signalingState))}var streams={};pc.remoteStreams.forEach(function(stream){streams[stream.id]=stream});var receiverList=[];var sections=SDPUtils.splitSections(description.sdp);var sessionpart=sections.shift();var isIceLite=SDPUtils.matchPrefix(sessionpart,"a=ice-lite").length>0;var usingBundle=SDPUtils.matchPrefix(sessionpart,"a=group:BUNDLE ").length>0;pc.usingBundle=usingBundle;var iceOptions=SDPUtils.matchPrefix(sessionpart,"a=ice-options:")[0];if(iceOptions){pc.canTrickleIceCandidates=iceOptions.substr(14).split(" ").indexOf("trickle")>=0}else{pc.canTrickleIceCandidates=false}sections.forEach(function(mediaSection,sdpMLineIndex){var lines=SDPUtils.splitLines(mediaSection);var kind=SDPUtils.getKind(mediaSection);var rejected=SDPUtils.isRejected(mediaSection)&&SDPUtils.matchPrefix(mediaSection,"a=bundle-only").length===0;var protocol=lines[0].substr(2).split(" ")[2];var direction=SDPUtils.getDirection(mediaSection,sessionpart);var remoteMsid=SDPUtils.parseMsid(mediaSection);var mid=SDPUtils.getMid(mediaSection)||SDPUtils.generateIdentifier();if(kind==="application"&&protocol==="DTLS/SCTP"||rejected){pc.transceivers[sdpMLineIndex]={mid:mid,kind:kind,rejected:true};return}if(!rejected&&pc.transceivers[sdpMLineIndex]&&pc.transceivers[sdpMLineIndex].rejected){pc.transceivers[sdpMLineIndex]=pc._createTransceiver(kind,true)}var transceiver;var iceGatherer;var iceTransport;var dtlsTransport;var rtpReceiver;var sendEncodingParameters;var recvEncodingParameters;var localCapabilities;var track;var remoteCapabilities=SDPUtils.parseRtpParameters(mediaSection);var remoteIceParameters;var remoteDtlsParameters;if(!rejected){remoteIceParameters=SDPUtils.getIceParameters(mediaSection,sessionpart);remoteDtlsParameters=SDPUtils.getDtlsParameters(mediaSection,sessionpart);remoteDtlsParameters.role="client"}recvEncodingParameters=SDPUtils.parseRtpEncodingParameters(mediaSection);var rtcpParameters=SDPUtils.parseRtcpParameters(mediaSection);var isComplete=SDPUtils.matchPrefix(mediaSection,"a=end-of-candidates",sessionpart).length>0;var cands=SDPUtils.matchPrefix(mediaSection,"a=candidate:").map(function(cand){return SDPUtils.parseCandidate(cand)}).filter(function(cand){return cand.component===1});if((description.type==="offer"||description.type==="answer")&&!rejected&&usingBundle&&sdpMLineIndex>0&&pc.transceivers[sdpMLineIndex]){pc._disposeIceAndDtlsTransports(sdpMLineIndex);pc.transceivers[sdpMLineIndex].iceGatherer=pc.transceivers[0].iceGatherer;pc.transceivers[sdpMLineIndex].iceTransport=pc.transceivers[0].iceTransport;pc.transceivers[sdpMLineIndex].dtlsTransport=pc.transceivers[0].dtlsTransport;if(pc.transceivers[sdpMLineIndex].rtpSender){pc.transceivers[sdpMLineIndex].rtpSender.setTransport(pc.transceivers[0].dtlsTransport)}if(pc.transceivers[sdpMLineIndex].rtpReceiver){pc.transceivers[sdpMLineIndex].rtpReceiver.setTransport(pc.transceivers[0].dtlsTransport)}}if(description.type==="offer"&&!rejected){transceiver=pc.transceivers[sdpMLineIndex]||pc._createTransceiver(kind);transceiver.mid=mid;if(!transceiver.iceGatherer){transceiver.iceGatherer=pc._createIceGatherer(sdpMLineIndex,usingBundle)}if(cands.length&&transceiver.iceTransport.state==="new"){if(isComplete&&(!usingBundle||sdpMLineIndex===0)){transceiver.iceTransport.setRemoteCandidates(cands)}else{cands.forEach(function(candidate){maybeAddCandidate(transceiver.iceTransport,candidate)})}}localCapabilities=window.RTCRtpReceiver.getCapabilities(kind);if(edgeVersion<15019){localCapabilities.codecs=localCapabilities.codecs.filter(function(codec){return codec.name!=="rtx"})}sendEncodingParameters=transceiver.sendEncodingParameters||[{ssrc:(2*sdpMLineIndex+2)*1001}];var isNewTrack=false;if(direction==="sendrecv"||direction==="sendonly"){isNewTrack=!transceiver.rtpReceiver;rtpReceiver=transceiver.rtpReceiver||new window.RTCRtpReceiver(transceiver.dtlsTransport,kind);if(isNewTrack){var stream;track=rtpReceiver.track;if(remoteMsid&&remoteMsid.stream==="-"){}else if(remoteMsid){if(!streams[remoteMsid.stream]){streams[remoteMsid.stream]=new window.MediaStream;Object.defineProperty(streams[remoteMsid.stream],"id",{get:function(){return remoteMsid.stream}})}Object.defineProperty(track,"id",{get:function(){return remoteMsid.track}});stream=streams[remoteMsid.stream]}else{if(!streams.default){streams.default=new window.MediaStream}stream=streams.default}if(stream){addTrackToStreamAndFireEvent(track,stream);transceiver.associatedRemoteMediaStreams.push(stream)}receiverList.push([track,rtpReceiver,stream])}}else if(transceiver.rtpReceiver&&transceiver.rtpReceiver.track){transceiver.associatedRemoteMediaStreams.forEach(function(s){var nativeTrack=s.getTracks().find(function(t){return t.id===transceiver.rtpReceiver.track.id});if(nativeTrack){removeTrackFromStreamAndFireEvent(nativeTrack,s)}});transceiver.associatedRemoteMediaStreams=[]}transceiver.localCapabilities=localCapabilities;transceiver.remoteCapabilities=remoteCapabilities;transceiver.rtpReceiver=rtpReceiver;transceiver.rtcpParameters=rtcpParameters;transceiver.sendEncodingParameters=sendEncodingParameters;transceiver.recvEncodingParameters=recvEncodingParameters;pc._transceive(pc.transceivers[sdpMLineIndex],false,isNewTrack)}else if(description.type==="answer"&&!rejected){transceiver=pc.transceivers[sdpMLineIndex];iceGatherer=transceiver.iceGatherer;iceTransport=transceiver.iceTransport;dtlsTransport=transceiver.dtlsTransport;rtpReceiver=transceiver.rtpReceiver;sendEncodingParameters=transceiver.sendEncodingParameters;localCapabilities=transceiver.localCapabilities;pc.transceivers[sdpMLineIndex].recvEncodingParameters=recvEncodingParameters;pc.transceivers[sdpMLineIndex].remoteCapabilities=remoteCapabilities;pc.transceivers[sdpMLineIndex].rtcpParameters=rtcpParameters;if(cands.length&&iceTransport.state==="new"){if((isIceLite||isComplete)&&(!usingBundle||sdpMLineIndex===0)){iceTransport.setRemoteCandidates(cands)}else{cands.forEach(function(candidate){maybeAddCandidate(transceiver.iceTransport,candidate)})}}if(!usingBundle||sdpMLineIndex===0){if(iceTransport.state==="new"){iceTransport.start(iceGatherer,remoteIceParameters,"controlling")}if(dtlsTransport.state==="new"){dtlsTransport.start(remoteDtlsParameters)}}pc._transceive(transceiver,direction==="sendrecv"||direction==="recvonly",direction==="sendrecv"||direction==="sendonly");if(rtpReceiver&&(direction==="sendrecv"||direction==="sendonly")){track=rtpReceiver.track;if(remoteMsid){if(!streams[remoteMsid.stream]){streams[remoteMsid.stream]=new window.MediaStream}addTrackToStreamAndFireEvent(track,streams[remoteMsid.stream]);receiverList.push([track,rtpReceiver,streams[remoteMsid.stream]])}else{if(!streams.default){streams.default=new window.MediaStream}addTrackToStreamAndFireEvent(track,streams.default);receiverList.push([track,rtpReceiver,streams.default])}}else{delete transceiver.rtpReceiver}}});if(pc._dtlsRole===undefined){pc._dtlsRole=description.type==="offer"?"active":"passive"}pc.remoteDescription={type:description.type,sdp:description.sdp};if(description.type==="offer"){pc._updateSignalingState("have-remote-offer")}else{pc._updateSignalingState("stable")}Object.keys(streams).forEach(function(sid){var stream=streams[sid];if(stream.getTracks().length){if(pc.remoteStreams.indexOf(stream)===-1){pc.remoteStreams.push(stream);var event=new Event("addstream");event.stream=stream;window.setTimeout(function(){pc._dispatchEvent("addstream",event)})}receiverList.forEach(function(item){var track=item[0];var receiver=item[1];if(stream.id!==item[2].id){return}fireAddTrack(pc,track,receiver,[stream])})}});receiverList.forEach(function(item){if(item[2]){return}fireAddTrack(pc,item[0],item[1],[])});window.setTimeout(function(){if(!(pc&&pc.transceivers)){return}pc.transceivers.forEach(function(transceiver){if(transceiver.iceTransport&&transceiver.iceTransport.state==="new"&&transceiver.iceTransport.getRemoteCandidates().length>0){console.warn("Timeout for addRemoteCandidate. Consider sending "+"an end-of-candidates notification");transceiver.iceTransport.addRemoteCandidate({})}})},4e3);return Promise.resolve()};RTCPeerConnection.prototype.close=function(){this.transceivers.forEach(function(transceiver){if(transceiver.iceTransport){transceiver.iceTransport.stop()}if(transceiver.dtlsTransport){transceiver.dtlsTransport.stop()}if(transceiver.rtpSender){transceiver.rtpSender.stop()}if(transceiver.rtpReceiver){transceiver.rtpReceiver.stop()}});this._isClosed=true;this._updateSignalingState("closed")};RTCPeerConnection.prototype._updateSignalingState=function(newState){this.signalingState=newState;var event=new Event("signalingstatechange");this._dispatchEvent("signalingstatechange",event)};RTCPeerConnection.prototype._maybeFireNegotiationNeeded=function(){var pc=this;if(this.signalingState!=="stable"||this.needNegotiation===true){return}this.needNegotiation=true;window.setTimeout(function(){if(pc.needNegotiation){pc.needNegotiation=false;var event=new Event("negotiationneeded");pc._dispatchEvent("negotiationneeded",event)}},0)};RTCPeerConnection.prototype._updateIceConnectionState=function(){var newState;var states={new:0,closed:0,checking:0,connected:0,completed:0,disconnected:0,failed:0};this.transceivers.forEach(function(transceiver){states[transceiver.iceTransport.state]++});newState="new";if(states.failed>0){newState="failed"}else if(states.checking>0){newState="checking"}else if(states.disconnected>0){newState="disconnected"}else if(states.new>0){newState="new"}else if(states.connected>0){newState="connected"}else if(states.completed>0){newState="completed"}if(newState!==this.iceConnectionState){this.iceConnectionState=newState;var event=new Event("iceconnectionstatechange");this._dispatchEvent("iceconnectionstatechange",event)}};RTCPeerConnection.prototype._updateConnectionState=function(){var newState;var states={new:0,closed:0,connecting:0,connected:0,completed:0,disconnected:0,failed:0};this.transceivers.forEach(function(transceiver){states[transceiver.iceTransport.state]++;states[transceiver.dtlsTransport.state]++});states.connected+=states.completed;newState="new";if(states.failed>0){newState="failed"}else if(states.connecting>0){newState="connecting"}else if(states.disconnected>0){newState="disconnected"}else if(states.new>0){newState="new"}else if(states.connected>0){newState="connected"}if(newState!==this.connectionState){this.connectionState=newState;var event=new Event("connectionstatechange");this._dispatchEvent("connectionstatechange",event)}};RTCPeerConnection.prototype.createOffer=function(){var pc=this;if(pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not call createOffer after close"))}var numAudioTracks=pc.transceivers.filter(function(t){return t.kind==="audio"}).length;var numVideoTracks=pc.transceivers.filter(function(t){return t.kind==="video"}).length;var offerOptions=arguments[0];if(offerOptions){if(offerOptions.mandatory||offerOptions.optional){throw new TypeError("Legacy mandatory/optional constraints not supported.")}if(offerOptions.offerToReceiveAudio!==undefined){if(offerOptions.offerToReceiveAudio===true){numAudioTracks=1}else if(offerOptions.offerToReceiveAudio===false){numAudioTracks=0}else{numAudioTracks=offerOptions.offerToReceiveAudio}}if(offerOptions.offerToReceiveVideo!==undefined){if(offerOptions.offerToReceiveVideo===true){numVideoTracks=1}else if(offerOptions.offerToReceiveVideo===false){numVideoTracks=0}else{numVideoTracks=offerOptions.offerToReceiveVideo}}}pc.transceivers.forEach(function(transceiver){if(transceiver.kind==="audio"){numAudioTracks--;if(numAudioTracks<0){transceiver.wantReceive=false}}else if(transceiver.kind==="video"){numVideoTracks--;if(numVideoTracks<0){transceiver.wantReceive=false}}});while(numAudioTracks>0||numVideoTracks>0){if(numAudioTracks>0){pc._createTransceiver("audio");numAudioTracks--}if(numVideoTracks>0){pc._createTransceiver("video");numVideoTracks--}}var sdp=SDPUtils.writeSessionBoilerplate(pc._sdpSessionId,pc._sdpSessionVersion++);pc.transceivers.forEach(function(transceiver,sdpMLineIndex){var track=transceiver.track;var kind=transceiver.kind;var mid=transceiver.mid||SDPUtils.generateIdentifier();transceiver.mid=mid;if(!transceiver.iceGatherer){transceiver.iceGatherer=pc._createIceGatherer(sdpMLineIndex,pc.usingBundle)}var localCapabilities=window.RTCRtpSender.getCapabilities(kind);if(edgeVersion<15019){localCapabilities.codecs=localCapabilities.codecs.filter(function(codec){return codec.name!=="rtx"})}localCapabilities.codecs.forEach(function(codec){if(codec.name==="H264"&&codec.parameters["level-asymmetry-allowed"]===undefined){codec.parameters["level-asymmetry-allowed"]="1"}if(transceiver.remoteCapabilities&&transceiver.remoteCapabilities.codecs){transceiver.remoteCapabilities.codecs.forEach(function(remoteCodec){if(codec.name.toLowerCase()===remoteCodec.name.toLowerCase()&&codec.clockRate===remoteCodec.clockRate){codec.preferredPayloadType=remoteCodec.payloadType}})}});localCapabilities.headerExtensions.forEach(function(hdrExt){var remoteExtensions=transceiver.remoteCapabilities&&transceiver.remoteCapabilities.headerExtensions||[];remoteExtensions.forEach(function(rHdrExt){if(hdrExt.uri===rHdrExt.uri){hdrExt.id=rHdrExt.id}})});var sendEncodingParameters=transceiver.sendEncodingParameters||[{ssrc:(2*sdpMLineIndex+1)*1001}];if(track){if(edgeVersion>=15019&&kind==="video"&&!sendEncodingParameters[0].rtx){sendEncodingParameters[0].rtx={ssrc:sendEncodingParameters[0].ssrc+1}}}if(transceiver.wantReceive){transceiver.rtpReceiver=new window.RTCRtpReceiver(transceiver.dtlsTransport,kind)}transceiver.localCapabilities=localCapabilities;transceiver.sendEncodingParameters=sendEncodingParameters});if(pc._config.bundlePolicy!=="max-compat"){sdp+="a=group:BUNDLE "+pc.transceivers.map(function(t){return t.mid}).join(" ")+"\r\n"}sdp+="a=ice-options:trickle\r\n";pc.transceivers.forEach(function(transceiver,sdpMLineIndex){sdp+=writeMediaSection(transceiver,transceiver.localCapabilities,"offer",transceiver.stream,pc._dtlsRole);sdp+="a=rtcp-rsize\r\n";if(transceiver.iceGatherer&&pc.iceGatheringState!=="new"&&(sdpMLineIndex===0||!pc.usingBundle)){transceiver.iceGatherer.getLocalCandidates().forEach(function(cand){cand.component=1;sdp+="a="+SDPUtils.writeCandidate(cand)+"\r\n"});if(transceiver.iceGatherer.state==="completed"){sdp+="a=end-of-candidates\r\n"}}});var desc=new window.RTCSessionDescription({type:"offer",sdp:sdp});return Promise.resolve(desc)};RTCPeerConnection.prototype.createAnswer=function(){var pc=this;if(pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not call createAnswer after close"))}if(!(pc.signalingState==="have-remote-offer"||pc.signalingState==="have-local-pranswer")){return Promise.reject(makeError("InvalidStateError","Can not call createAnswer in signalingState "+pc.signalingState))}var sdp=SDPUtils.writeSessionBoilerplate(pc._sdpSessionId,pc._sdpSessionVersion++);if(pc.usingBundle){sdp+="a=group:BUNDLE "+pc.transceivers.map(function(t){return t.mid}).join(" ")+"\r\n"}var mediaSectionsInOffer=SDPUtils.getMediaSections(pc.remoteDescription.sdp).length;pc.transceivers.forEach(function(transceiver,sdpMLineIndex){if(sdpMLineIndex+1>mediaSectionsInOffer){return}if(transceiver.rejected){if(transceiver.kind==="application"){sdp+="m=application 0 DTLS/SCTP 5000\r\n"}else if(transceiver.kind==="audio"){sdp+="m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n"+"a=rtpmap:0 PCMU/8000\r\n"}else if(transceiver.kind==="video"){sdp+="m=video 0 UDP/TLS/RTP/SAVPF 120\r\n"+"a=rtpmap:120 VP8/90000\r\n"}sdp+="c=IN IP4 0.0.0.0\r\n"+"a=inactive\r\n"+"a=mid:"+transceiver.mid+"\r\n";return}if(transceiver.stream){var localTrack;if(transceiver.kind==="audio"){localTrack=transceiver.stream.getAudioTracks()[0]}else if(transceiver.kind==="video"){localTrack=transceiver.stream.getVideoTracks()[0]}if(localTrack){if(edgeVersion>=15019&&transceiver.kind==="video"&&!transceiver.sendEncodingParameters[0].rtx){transceiver.sendEncodingParameters[0].rtx={ssrc:transceiver.sendEncodingParameters[0].ssrc+1}}}}var commonCapabilities=getCommonCapabilities(transceiver.localCapabilities,transceiver.remoteCapabilities);var hasRtx=commonCapabilities.codecs.filter(function(c){return c.name.toLowerCase()==="rtx"}).length;if(!hasRtx&&transceiver.sendEncodingParameters[0].rtx){delete transceiver.sendEncodingParameters[0].rtx}sdp+=writeMediaSection(transceiver,commonCapabilities,"answer",transceiver.stream,pc._dtlsRole);if(transceiver.rtcpParameters&&transceiver.rtcpParameters.reducedSize){sdp+="a=rtcp-rsize\r\n"}});var desc=new window.RTCSessionDescription({type:"answer",sdp:sdp});return Promise.resolve(desc)};RTCPeerConnection.prototype.addIceCandidate=function(candidate){var pc=this;var sections;if(candidate&&!(candidate.sdpMLineIndex!==undefined||candidate.sdpMid)){return Promise.reject(new TypeError("sdpMLineIndex or sdpMid required"))}return new Promise(function(resolve,reject){if(!pc.remoteDescription){return reject(makeError("InvalidStateError","Can not add ICE candidate without a remote description"))}else if(!candidate||candidate.candidate===""){for(var j=0;j0?SDPUtils.parseCandidate(candidate.candidate):{};if(cand.protocol==="tcp"&&(cand.port===0||cand.port===9)){return resolve()}if(cand.component&&cand.component!==1){return resolve()}if(sdpMLineIndex===0||sdpMLineIndex>0&&transceiver.iceTransport!==pc.transceivers[0].iceTransport){if(!maybeAddCandidate(transceiver.iceTransport,cand)){return reject(makeError("OperationError","Can not add ICE candidate"))}}var candidateString=candidate.candidate.trim();if(candidateString.indexOf("a=")===0){candidateString=candidateString.substr(2)}sections=SDPUtils.getMediaSections(pc.remoteDescription.sdp);sections[sdpMLineIndex]+="a="+(cand.type?candidateString:"end-of-candidates")+"\r\n";pc.remoteDescription.sdp=SDPUtils.getDescription(pc.remoteDescription.sdp)+sections.join("")}else{return reject(makeError("OperationError","Can not add ICE candidate"))}}resolve()})};RTCPeerConnection.prototype.getStats=function(selector){if(selector&&selector instanceof window.MediaStreamTrack){var senderOrReceiver=null;this.transceivers.forEach(function(transceiver){if(transceiver.rtpSender&&transceiver.rtpSender.track===selector){senderOrReceiver=transceiver.rtpSender}else if(transceiver.rtpReceiver&&transceiver.rtpReceiver.track===selector){senderOrReceiver=transceiver.rtpReceiver}});if(!senderOrReceiver){throw makeError("InvalidAccessError","Invalid selector.")}return senderOrReceiver.getStats()}var promises=[];this.transceivers.forEach(function(transceiver){["rtpSender","rtpReceiver","iceGatherer","iceTransport","dtlsTransport"].forEach(function(method){if(transceiver[method]){promises.push(transceiver[method].getStats())}})});return Promise.all(promises).then(function(allStats){var results=new Map;allStats.forEach(function(stats){stats.forEach(function(stat){results.set(stat.id,stat)})});return results})};var ortcObjects=["RTCRtpSender","RTCRtpReceiver","RTCIceGatherer","RTCIceTransport","RTCDtlsTransport"];ortcObjects.forEach(function(ortcObjectName){var obj=window[ortcObjectName];if(obj&&obj.prototype&&obj.prototype.getStats){var nativeGetstats=obj.prototype.getStats;obj.prototype.getStats=function(){return nativeGetstats.apply(this).then(function(nativeStats){var mapStats=new Map;Object.keys(nativeStats).forEach(function(id){nativeStats[id].type=fixStatsType(nativeStats[id]);mapStats.set(id,nativeStats[id])});return mapStats})}}});var methods=["createOffer","createAnswer"];methods.forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[0]==="function"||typeof args[1]==="function"){return nativeMethod.apply(this,[arguments[2]]).then(function(description){if(typeof args[0]==="function"){args[0].apply(null,[description])}},function(error){if(typeof args[1]==="function"){args[1].apply(null,[error])}})}return nativeMethod.apply(this,arguments)}});methods=["setLocalDescription","setRemoteDescription","addIceCandidate"];methods.forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[1]==="function"||typeof args[2]==="function"){return nativeMethod.apply(this,arguments).then(function(){if(typeof args[1]==="function"){args[1].apply(null)}},function(error){if(typeof args[2]==="function"){args[2].apply(null,[error])}})}return nativeMethod.apply(this,arguments)}});["getStats"].forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[1]==="function"){return nativeMethod.apply(this,arguments).then(function(){if(typeof args[1]==="function"){args[1].apply(null)}})}return nativeMethod.apply(this,arguments)}});return RTCPeerConnection}},{sdp:52}],52:[function(require,module,exports){"use strict";var SDPUtils={};SDPUtils.generateIdentifier=function(){return Math.random().toString(36).substr(2,10)};SDPUtils.localCName=SDPUtils.generateIdentifier();SDPUtils.splitLines=function(blob){return blob.trim().split("\n").map(function(line){return line.trim()})};SDPUtils.splitSections=function(blob){var parts=blob.split("\nm=");return parts.map(function(part,index){return(index>0?"m="+part:part).trim()+"\r\n"})};SDPUtils.getDescription=function(blob){var sections=SDPUtils.splitSections(blob);return sections&§ions[0]};SDPUtils.getMediaSections=function(blob){var sections=SDPUtils.splitSections(blob);sections.shift();return sections};SDPUtils.matchPrefix=function(blob,prefix){return SDPUtils.splitLines(blob).filter(function(line){return line.indexOf(prefix)===0})};SDPUtils.parseCandidate=function(line){var parts;if(line.indexOf("a=candidate:")===0){parts=line.substring(12).split(" ")}else{parts=line.substring(10).split(" ")}var candidate={foundation:parts[0],component:parseInt(parts[1],10),protocol:parts[2].toLowerCase(),priority:parseInt(parts[3],10),ip:parts[4],port:parseInt(parts[5],10),type:parts[7]};for(var i=8;i0?parts[0].split("/")[1]:"sendrecv",uri:parts[1]}};SDPUtils.writeExtmap=function(headerExtension){return"a=extmap:"+(headerExtension.id||headerExtension.preferredId)+(headerExtension.direction&&headerExtension.direction!=="sendrecv"?"/"+headerExtension.direction:"")+" "+headerExtension.uri+"\r\n"};SDPUtils.parseFmtp=function(line){var parsed={};var kv;var parts=line.substr(line.indexOf(" ")+1).split(";");for(var j=0;j-1){parts.attribute=line.substr(sp+1,colon-sp-1);parts.value=line.substr(colon+1)}else{parts.attribute=line.substr(sp+1)}return parts};SDPUtils.getMid=function(mediaSection){var mid=SDPUtils.matchPrefix(mediaSection,"a=mid:")[0];if(mid){return mid.substr(6)}};SDPUtils.parseFingerprint=function(line){var parts=line.substr(14).split(" ");return{algorithm:parts[0].toLowerCase(),value:parts[1]}};SDPUtils.getDtlsParameters=function(mediaSection,sessionpart){var lines=SDPUtils.matchPrefix(mediaSection+sessionpart,"a=fingerprint:");return{role:"auto",fingerprints:lines.map(SDPUtils.parseFingerprint)}};SDPUtils.writeDtlsParameters=function(params,setupType){var sdp="a=setup:"+setupType+"\r\n";params.fingerprints.forEach(function(fp){sdp+="a=fingerprint:"+fp.algorithm+" "+fp.value+"\r\n"});return sdp};SDPUtils.getIceParameters=function(mediaSection,sessionpart){var lines=SDPUtils.splitLines(mediaSection);lines=lines.concat(SDPUtils.splitLines(sessionpart));var iceParameters={usernameFragment:lines.filter(function(line){return line.indexOf("a=ice-ufrag:")===0})[0].substr(12),password:lines.filter(function(line){return line.indexOf("a=ice-pwd:")===0})[0].substr(10)};return iceParameters};SDPUtils.writeIceParameters=function(params){return"a=ice-ufrag:"+params.usernameFragment+"\r\n"+"a=ice-pwd:"+params.password+"\r\n"};SDPUtils.parseRtpParameters=function(mediaSection){var description={codecs:[],headerExtensions:[],fecMechanisms:[],rtcp:[]};var lines=SDPUtils.splitLines(mediaSection);var mline=lines[0].split(" ");for(var i=3;i0?"9":"0";sdp+=" UDP/TLS/RTP/SAVPF ";sdp+=caps.codecs.map(function(codec){if(codec.preferredPayloadType!==undefined){return codec.preferredPayloadType}return codec.payloadType}).join(" ")+"\r\n";sdp+="c=IN IP4 0.0.0.0\r\n";sdp+="a=rtcp:9 IN IP4 0.0.0.0\r\n";caps.codecs.forEach(function(codec){sdp+=SDPUtils.writeRtpMap(codec);sdp+=SDPUtils.writeFmtp(codec);sdp+=SDPUtils.writeRtcpFb(codec)});var maxptime=0;caps.codecs.forEach(function(codec){if(codec.maxptime>maxptime){maxptime=codec.maxptime}});if(maxptime>0){sdp+="a=maxptime:"+maxptime+"\r\n"}sdp+="a=rtcp-mux\r\n";caps.headerExtensions.forEach(function(extension){sdp+=SDPUtils.writeExtmap(extension)});return sdp};SDPUtils.parseRtpEncodingParameters=function(mediaSection){var encodingParameters=[];var description=SDPUtils.parseRtpParameters(mediaSection);var hasRed=description.fecMechanisms.indexOf("RED")!==-1;var hasUlpfec=description.fecMechanisms.indexOf("ULPFEC")!==-1;var ssrcs=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(parts){return parts.attribute==="cname"});var primarySsrc=ssrcs.length>0&&ssrcs[0].ssrc;var secondarySsrc;var flows=SDPUtils.matchPrefix(mediaSection,"a=ssrc-group:FID").map(function(line){var parts=line.split(" ");parts.shift();return parts.map(function(part){return parseInt(part,10)})});if(flows.length>0&&flows[0].length>1&&flows[0][0]===primarySsrc){secondarySsrc=flows[0][1]}description.codecs.forEach(function(codec){if(codec.name.toUpperCase()==="RTX"&&codec.parameters.apt){var encParam={ssrc:primarySsrc,codecPayloadType:parseInt(codec.parameters.apt,10),rtx:{ssrc:secondarySsrc}};encodingParameters.push(encParam);if(hasRed){encParam=JSON.parse(JSON.stringify(encParam));encParam.fec={ssrc:secondarySsrc,mechanism:hasUlpfec?"red+ulpfec":"red"};encodingParameters.push(encParam)}}});if(encodingParameters.length===0&&primarySsrc){encodingParameters.push({ssrc:primarySsrc})}var bandwidth=SDPUtils.matchPrefix(mediaSection,"b=");if(bandwidth.length){if(bandwidth[0].indexOf("b=TIAS:")===0){bandwidth=parseInt(bandwidth[0].substr(7),10)}else if(bandwidth[0].indexOf("b=AS:")===0){bandwidth=parseInt(bandwidth[0].substr(5),10)*1e3*.95-50*40*8}else{bandwidth=undefined}encodingParameters.forEach(function(params){params.maxBitrate=bandwidth})}return encodingParameters};SDPUtils.parseRtcpParameters=function(mediaSection){var rtcpParameters={};var cname;var remoteSsrc=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(obj){return obj.attribute==="cname"})[0];if(remoteSsrc){rtcpParameters.cname=remoteSsrc.value;rtcpParameters.ssrc=remoteSsrc.ssrc}var rsize=SDPUtils.matchPrefix(mediaSection,"a=rtcp-rsize");rtcpParameters.reducedSize=rsize.length>0;rtcpParameters.compound=rsize.length===0;var mux=SDPUtils.matchPrefix(mediaSection,"a=rtcp-mux");rtcpParameters.mux=mux.length>0;return rtcpParameters};SDPUtils.parseMsid=function(mediaSection){var parts;var spec=SDPUtils.matchPrefix(mediaSection,"a=msid:");if(spec.length===1){parts=spec[0].substr(7).split(" ");return{stream:parts[0],track:parts[1]}}var planB=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(parts){return parts.attribute==="msid"});if(planB.length>0){parts=planB[0].value.split(" ");return{stream:parts[0],track:parts[1]}}};SDPUtils.generateSessionId=function(){return Math.random().toString().substr(2,21)};SDPUtils.writeSessionBoilerplate=function(sessId,sessVer){var sessionId;var version=sessVer!==undefined?sessVer:2;if(sessId){sessionId=sessId}else{sessionId=SDPUtils.generateSessionId()}return"v=0\r\n"+"o=thisisadapterortc "+sessionId+" "+version+" IN IP4 127.0.0.1\r\n"+"s=-\r\n"+"t=0 0\r\n"};SDPUtils.writeMediaSection=function(transceiver,caps,type,stream){var sdp=SDPUtils.writeRtpDescription(transceiver.kind,caps);sdp+=SDPUtils.writeIceParameters(transceiver.iceGatherer.getLocalParameters());sdp+=SDPUtils.writeDtlsParameters(transceiver.dtlsTransport.getLocalParameters(),type==="offer"?"actpass":"active");sdp+="a=mid:"+transceiver.mid+"\r\n";if(transceiver.direction){sdp+="a="+transceiver.direction+"\r\n"}else if(transceiver.rtpSender&&transceiver.rtpReceiver){sdp+="a=sendrecv\r\n"}else if(transceiver.rtpSender){sdp+="a=sendonly\r\n"}else if(transceiver.rtpReceiver){sdp+="a=recvonly\r\n"}else{sdp+="a=inactive\r\n"}if(transceiver.rtpSender){var msid="msid:"+stream.id+" "+transceiver.rtpSender.track.id+"\r\n";sdp+="a="+msid;sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" "+msid;if(transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" "+msid;sdp+="a=ssrc-group:FID "+transceiver.sendEncodingParameters[0].ssrc+" "+transceiver.sendEncodingParameters[0].rtx.ssrc+"\r\n"}}sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" cname:"+SDPUtils.localCName+"\r\n";if(transceiver.rtpSender&&transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" cname:"+SDPUtils.localCName+"\r\n"}return sdp};SDPUtils.getDirection=function(mediaSection,sessionpart){var lines=SDPUtils.splitLines(mediaSection);for(var i=0;i=len)return x;switch(x){case"%s":return String(args[i++]);case"%d":return Number(args[i++]);case"%j":try{return JSON.stringify(args[i++])}catch(_){return"[Circular]"}default:return x}});for(var x=args[i];i=3)ctx.depth=arguments[2];if(arguments.length>=4)ctx.colors=arguments[3];if(isBoolean(opts)){ctx.showHidden=opts}else if(opts){exports._extend(ctx,opts)}if(isUndefined(ctx.showHidden))ctx.showHidden=false;if(isUndefined(ctx.depth))ctx.depth=2;if(isUndefined(ctx.colors))ctx.colors=false;if(isUndefined(ctx.customInspect))ctx.customInspect=true;if(ctx.colors)ctx.stylize=stylizeWithColor;return formatValue(ctx,obj,ctx.depth)}exports.inspect=inspect;inspect.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]};inspect.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"};function stylizeWithColor(str,styleType){var style=inspect.styles[styleType];if(style){return"\x1b["+inspect.colors[style][0]+"m"+str+"\x1b["+inspect.colors[style][1]+"m"}else{return str}}function stylizeNoColor(str,styleType){return str}function arrayToHash(array){var hash={};array.forEach(function(val,idx){hash[val]=true});return hash}function formatValue(ctx,value,recurseTimes){if(ctx.customInspect&&value&&isFunction(value.inspect)&&value.inspect!==exports.inspect&&!(value.constructor&&value.constructor.prototype===value)){var ret=value.inspect(recurseTimes,ctx);if(!isString(ret)){ret=formatValue(ctx,ret,recurseTimes)}return ret}var primitive=formatPrimitive(ctx,value);if(primitive){return primitive}var keys=Object.keys(value);var visibleKeys=arrayToHash(keys);if(ctx.showHidden){keys=Object.getOwnPropertyNames(value)}if(isError(value)&&(keys.indexOf("message")>=0||keys.indexOf("description")>=0)){return formatError(value)}if(keys.length===0){if(isFunction(value)){var name=value.name?": "+value.name:"";return ctx.stylize("[Function"+name+"]","special")}if(isRegExp(value)){return ctx.stylize(RegExp.prototype.toString.call(value),"regexp")}if(isDate(value)){return ctx.stylize(Date.prototype.toString.call(value),"date")}if(isError(value)){return formatError(value)}}var base="",array=false,braces=["{","}"];if(isArray(value)){array=true;braces=["[","]"]}if(isFunction(value)){var n=value.name?": "+value.name:"";base=" [Function"+n+"]"}if(isRegExp(value)){base=" "+RegExp.prototype.toString.call(value)}if(isDate(value)){base=" "+Date.prototype.toUTCString.call(value)}if(isError(value)){base=" "+formatError(value)}if(keys.length===0&&(!array||value.length==0)){return braces[0]+base+braces[1]}if(recurseTimes<0){if(isRegExp(value)){return ctx.stylize(RegExp.prototype.toString.call(value),"regexp")}else{return ctx.stylize("[Object]","special")}}ctx.seen.push(value);var output;if(array){output=formatArray(ctx,value,recurseTimes,visibleKeys,keys)}else{output=keys.map(function(key){return formatProperty(ctx,value,recurseTimes,visibleKeys,key,array)})}ctx.seen.pop();return reduceToSingleString(output,base,braces)}function formatPrimitive(ctx,value){if(isUndefined(value))return ctx.stylize("undefined","undefined");if(isString(value)){var simple="'"+JSON.stringify(value).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return ctx.stylize(simple,"string")}if(isNumber(value))return ctx.stylize(""+value,"number");if(isBoolean(value))return ctx.stylize(""+value,"boolean");if(isNull(value))return ctx.stylize("null","null")}function formatError(value){return"["+Error.prototype.toString.call(value)+"]"}function formatArray(ctx,value,recurseTimes,visibleKeys,keys){var output=[];for(var i=0,l=value.length;i-1){if(array){str=str.split("\n").map(function(line){return" "+line}).join("\n").substr(2)}else{str="\n"+str.split("\n").map(function(line){return" "+line}).join("\n")}}}else{str=ctx.stylize("[Circular]","special")}}if(isUndefined(name)){if(array&&key.match(/^\d+$/)){return str}name=JSON.stringify(""+key);if(name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)){name=name.substr(1,name.length-2);name=ctx.stylize(name,"name")}else{name=name.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'");name=ctx.stylize(name,"string")}}return name+": "+str}function reduceToSingleString(output,base,braces){var numLinesEst=0;var length=output.reduce(function(prev,cur){numLinesEst++;if(cur.indexOf("\n")>=0)numLinesEst++;return prev+cur.replace(/\u001b\[\d\d?m/g,"").length+1},0);if(length>60){return braces[0]+(base===""?"":base+"\n ")+" "+output.join(",\n ")+" "+braces[1]}return braces[0]+base+" "+output.join(", ")+" "+braces[1]}function isArray(ar){return Array.isArray(ar)}exports.isArray=isArray;function isBoolean(arg){return typeof arg==="boolean"}exports.isBoolean=isBoolean;function isNull(arg){return arg===null}exports.isNull=isNull;function isNullOrUndefined(arg){return arg==null}exports.isNullOrUndefined=isNullOrUndefined;function isNumber(arg){return typeof arg==="number"}exports.isNumber=isNumber;function isString(arg){return typeof arg==="string"}exports.isString=isString;function isSymbol(arg){return typeof arg==="symbol"}exports.isSymbol=isSymbol;function isUndefined(arg){return arg===void 0}exports.isUndefined=isUndefined;function isRegExp(re){return isObject(re)&&objectToString(re)==="[object RegExp]"}exports.isRegExp=isRegExp;function isObject(arg){return typeof arg==="object"&&arg!==null}exports.isObject=isObject;function isDate(d){return isObject(d)&&objectToString(d)==="[object Date]"}exports.isDate=isDate;function isError(e){return isObject(e)&&(objectToString(e)==="[object Error]"||e instanceof Error)}exports.isError=isError;function isFunction(arg){return typeof arg==="function"}exports.isFunction=isFunction;function isPrimitive(arg){return arg===null||typeof arg==="boolean"||typeof arg==="number"||typeof arg==="string"||typeof arg==="symbol"||typeof arg==="undefined"}exports.isPrimitive=isPrimitive;exports.isBuffer=require("./support/isBuffer");function objectToString(o){return Object.prototype.toString.call(o)}function pad(n){return n<10?"0"+n.toString(10):n.toString(10)}var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function timestamp(){var d=new Date;var time=[pad(d.getHours()),pad(d.getMinutes()),pad(d.getSeconds())].join(":");return[d.getDate(),months[d.getMonth()],time].join(" ")}exports.log=function(){console.log("%s - %s",timestamp(),exports.format.apply(exports,arguments))};exports.inherits=require("inherits");exports._extend=function(origin,add){if(!add||!isObject(add))return origin;var keys=Object.keys(add);var i=keys.length;while(i--){origin[keys[i]]=add[keys[i]]}return origin};function hasOwnProperty(obj,prop){return Object.prototype.hasOwnProperty.call(obj,prop)}}).call(this,require("_process"),typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{})},{"./support/isBuffer":54,_process:48,inherits:53}],56:[function(require,module,exports){module.exports={name:"twilio-client",version:"1.4.32",description:"Javascript SDK for Twilio Client",homepage:"https://www.twilio.com/docs/client/twilio-js",main:"./es5/twilio.js",license:"Apache-2.0",repository:{type:"git",url:"git@code.hq.twilio.com:client/twiliojs.git"},scripts:{build:"npm-run-all clean build:es5 build:ts build:dist build:dist-min","build:es5":"rimraf ./es5 && babel lib -d es5","build:dist":"node ./scripts/build.js ./lib/browser.js ./LICENSE.md ./dist/twilio.js","build:dist-min":'uglifyjs ./dist/twilio.js -o ./dist/twilio.min.js --comments "/^! twilio-client.js/" -b beautify=false,ascii_only=true',"build:travis":"npm-run-all lint build test:unit test:webpack test:es5","build:ts":"tsc",clean:"rimraf ./coverage ./dist ./es5",coverage:"nyc --reporter=html ./node_modules/mocha/bin/mocha --reporter=spec tests/index.js",extension:"browserify -t brfs extension/token/index.js > extension/token.js",lint:"npm-run-all lint:js lint:ts","lint:js":"eslint lib","lint:ts":"tslint -c tslint.json --project tsconfig.json -t stylish",release:"release",start:"node server.js",test:"npm-run-all test:unit test:frameworks","test:es5":'es-check es5 "./es5/**/*.js" ./dist/*.js',"test:framework:no-framework":"mocha tests/framework/no-framework.js","test:framework:react:install":"cd ./tests/framework/react && rimraf ./node_modules package-lock.json && npm install","test:framework:react:build":"cd ./tests/framework/react && npm run build","test:framework:react:run":"mocha ./tests/framework/react.js","test:framework:react":"npm-run-all test:framework:react:*","test:frameworks":"npm-run-all test:framework:no-framework test:framework:react","test:selenium":"mocha tests/browser/index.js","test:unit":"mocha --reporter=spec -r ts-node/register ./tests/index.ts","test:webpack":"cd ./tests/webpack && npm install && npm test"},devDependencies:{"@types/mocha":"^5.0.0","@types/node":"^9.6.5","@types/ws":"^4.0.2","babel-cli":"^6.26.0","babel-eslint":"^8.2.2","babel-plugin-transform-class-properties":"^6.24.1","babel-preset-es2015":"^6.24.1",brfs:"^1.4.3",browserify:"^14.3.0",chromedriver:"^2.31.0",envify:"2.0.1","es-check":"^2.0.3",eslint:"^4.19.1","eslint-plugin-babel":"^4.1.2",express:"^4.14.1",geckodriver:"^1.8.1","js-yaml":"^3.9.1",jsonwebtoken:"^7.4.3",lodash:"^4.17.4",mocha:"^3.5.0","npm-run-all":"^4.1.2",nyc:"^10.1.2",querystring:"^0.2.0","release-tool":"^0.2.2","selenium-webdriver":"^3.5.0",sinon:"^4.0.0","ts-node":"^6.0.0",tslint:"^5.9.1",twilio:"^2.11.1",typescript:"^2.8.1","uglify-js":"^3.3.11","vinyl-fs":"^3.0.2","vinyl-source-stream":"^2.0.0"},dependencies:{AudioPlayer:"git+https://github.com/twilio/AudioPlayer.git#1.0.1",backoff:"^2.5.0","rtcpeerconnection-shim":"^1.2.8",ws:"0.4.31",xmlhttprequest:"^1.8.0"},browser:{xmlhttprequest:"./browser/xmlhttprequest.js",ws:"./browser/ws.js"}}},{}]},{},[3]);var Voice=bundle(3);if(typeof define==="function"&&define.amd){define([],function(){return Voice})}else{var Twilio=root.Twilio=root.Twilio||{};Twilio.Connection=Twilio.Connection||Voice.Connection;Twilio.Device=Twilio.Device||Voice.Device;Twilio.PStream=Twilio.PStream||Voice.PStream}})(typeof window!=="undefined"?window:typeof global!=="undefined"?global:this);
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/static/src/js/widget.js b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/widget.js
new file mode 100644
index 000000000..7affeb578
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/static/src/js/widget.js
@@ -0,0 +1,274 @@
+odoo.define('voip_sip_webrtc_twilio.voip_twilio_call_notification', function (require) {
+"use strict";
+
+var core = require('web.core');
+var framework = require('web.framework');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+var odoo_session = require('web.session');
+var web_client = require('web.web_client');
+var Widget = require('web.Widget');
+var ajax = require('web.ajax');
+var bus = require('bus.bus').bus;
+var Notification = require('web.notification').Notification;
+var WebClient = require('web.WebClient');
+var SystrayMenu = require('web.SystrayMenu');
+var _t = core._t;
+var qweb = core.qweb;
+var ActionManager = require('web.ActionManager');
+
+var call_conn;
+var myNotif = "";
+var secondsLeft;
+var incoming_ring_interval;
+var mySound = "";
+
+$(function() {
+
+ // Renew the token every 55 seconds
+ var myJWTTimer = setInterval(renewJWT, 55000);
+ renewJWT();
+
+ function renewJWT() {
+ rpc.query({
+ model: 'voip.number',
+ method: 'get_numbers',
+ args: [],
+ context: weContext.get()
+ }).then(function(result){
+
+ for (var i = 0; i < result.length; i++) {
+ var call_route = result[i];
+
+ console.log("Signing in as " + call_route.capability_token_url);
+
+ $.getJSON(call_route.capability_token_url).done(function (data) {
+ console.log('Got a token.');
+ console.log('Token: ' + data.token);
+
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+
+ Twilio.Device.ready(function (device) {
+ console.log('Twilio.Device Ready!');
+ });
+
+ })
+ .fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ }
+
+ });
+ }
+
+});
+
+
+// Bind to end call button
+$(document).on("click", "#voip_end_call", function(){
+ console.log('Hanging up...');
+ twilio_end_call();
+});
+
+function twilio_end_call() {
+ console.log('Call ended.');
+ $("#voip_text").html("Starting Call...");
+ $(".s-voip-manager").css("display","none");
+
+ console.log(twilio_call_sid);
+ if (twilio_call_sid != null) {
+ rpc.query({
+ model: 'voip.call',
+ method: 'add_twilio_call',
+ args: [[voip_call_id], twilio_call_sid],
+ context: weContext.get()
+ }).then(function(result){
+ console.log("Finished Updating Twilio Call");
+ sw_acton_manager.do_action({
+ name: 'Twilio Call Comments',
+ type: 'ir.actions.act_window',
+ res_model: 'voip.call.comment',
+ views: [[false, 'form']],
+ context: {'default_call_id': voip_call_id},
+ target: 'new'
+ });
+ });
+ } else {
+ console.log("Call Failed");
+ }
+
+ Twilio.Device.disconnectAll();
+}
+
+var twilio_call_sid;
+var voip_call_id;
+var sw_acton_manager;
+
+Twilio.Device.connect(function (conn) {
+ console.log('Successfully established call!');
+
+ twilio_call_sid = conn.parameters.CallSid;
+ console.log(twilio_call_sid);
+
+ $(".s-voip-manager").css("display","block");
+
+ var startDate = new Date();
+ var call_interval;
+
+ if (mySound != "") {
+ mySound.pause();
+ mySound.currentTime = 0;
+ }
+
+ call_interval = setInterval(function() {
+ var endDate = new Date();
+ var seconds = (endDate.getTime() - startDate.getTime()) / 1000;
+ $("#voip_text").html( Math.round(seconds) + " seconds");
+ }, 1000);
+});
+
+Twilio.Device.disconnect(function (conn) {
+ twilio_end_call();
+});
+
+Twilio.Device.incoming(function (conn) {
+ console.log('Incoming connection from ' + conn.parameters.From);
+
+ //Set it on a global scale because we we need it when the call it accepted or rejected inside the incoming call dialog
+ call_conn = conn;
+
+ //Poll the server so we can find who the call is from + ringtone
+ rpc.query({
+ model: 'res.users',
+ method: 'get_call_details',
+ args: [[odoo_session.uid], conn],
+ context: weContext.get()
+ }).then(function(result){
+
+ //Open the incoming call dialog
+ var self = this;
+
+ var from_name = result.from_name;
+ var ringtone = result.ringtone;
+ var caller_partner_id = result.caller_partner_id;
+ window.countdown = result.ring_duration;
+
+ var notif_text = from_name + " wants you to join a mobile call";
+
+ window.voip_call_id = result.voip_call_id
+
+ var incomingNotification = new VoipTwilioCallIncomingNotification(window.swnotification_manager, "Incoming Call", notif_text, 0);
+ window.swnotification_manager.display(incomingNotification);
+ mySound = new Audio(ringtone);
+ mySound.loop = true;
+ mySound.play();
+
+ //Display an image of the person who is calling
+ $("#voipcallincomingimage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+ $("#toPartnerImage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+
+ });
+
+});
+
+Twilio.Device.error(function (error) {
+ console.log('Twilio.Device Error: ' + error.message);
+});
+
+var VoipTwilioCallIncomingNotification = Notification.extend({
+ template: "VoipCallIncomingNotification",
+
+ init: function(parent, title, text, call_id) {
+ this._super(parent, title, text, true);
+
+
+ this.events = _.extend(this.events || {}, {
+ 'click .link2accept': function() {
+
+ call_conn.accept();
+
+ this.destroy(true);
+ },
+
+ 'click .link2reject': function() {
+
+ call_conn.reject();
+
+ this.destroy(true);
+ },
+ });
+ },
+ start: function() {
+ myNotif = this;
+ this._super.apply(this, arguments);
+ secondsLeft = window.countdown;
+ $("#callsecondsincomingleft").html(secondsLeft);
+
+ incoming_ring_interval = setInterval(function() {
+ $("#callsecondsincomingleft").html(secondsLeft);
+ if (secondsLeft == 0) {
+ mySound.pause();
+ mySound.currentTime = 0;
+ clearInterval(incoming_ring_interval);
+ myNotif.destroy(true);
+ }
+
+ secondsLeft--;
+ }, 1000);
+
+ },
+});
+
+WebClient.include({
+
+ show_application: function() {
+
+ window.swnotification_manager = this.notification_manager;
+ //Because this no longer referes to the action manager for the disconnect callback
+ sw_acton_manager = this;
+
+ bus.on('notification', this, function (notifications) {
+ _.each(notifications, (function (notification) {
+
+ if (notification[0][1] === 'voip.twilio.start') {
+ var self = this;
+
+ var from_number = notification[1].from_number;
+ var to_number = notification[1].to_number;
+ var capability_token_url = notification[1].capability_token_url;
+ voip_call_id = notification[1].call_id;
+
+ console.log("Call Type: Twilio");
+
+ //Make the audio call
+ console.log("Twilio audio calling: " + to_number);
+
+ var params = {
+ From: from_number,
+ To: to_number
+ };
+
+ console.log('Calling ' + params.To + '...');
+
+ $.getJSON(capability_token_url).done(function (data) {
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+ Twilio.Device.connect(params);
+ }).fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ }
+
+
+ }).bind(this));
+
+ });
+ return this._super.apply(this, arguments);
+ },
+
+});
+
+});
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/crm_lead_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/crm_lead_views.xml
new file mode 100644
index 000000000..6c8fd7399
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/crm_lead_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Twilio Call Lead Mobile
+
+
+ True
+ code
+ action = record.twilio_mobile_action()
+
+
+
+ crm.lead Twilio
+ crm.lead
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/mail_activity_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/mail_activity_views.xml
new file mode 100644
index 000000000..72a9ad8b9
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/mail_activity_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ mail.activity Twilio
+ mail.activity
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/menus.xml b/OTHER/voip_sip_webrtc_twilio_self/views/menus.xml
new file mode 100644
index 000000000..8ea5ea443
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/menus.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/res_partner_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/res_partner_views.xml
new file mode 100644
index 000000000..b8c8150ef
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/res_partner_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Twilio Call Mobile
+
+
+ True
+ code
+ action = record.twilio_mobile_action()
+
+
+
+ res.partner Twilio
+ res.partner
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/res_users(core)_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/res_users(core)_views.xml
new file mode 100644
index 000000000..1317f6cc4
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/res_users(core)_views.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ res.users Voip
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/res_users_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/res_users_views.xml
new file mode 100644
index 000000000..b725d48e3
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/res_users_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ res.users Voip TWilio
+ res.users
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action(core)_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action(core)_views.xml
new file mode 100644
index 000000000..ebc36835a
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action(core)_views.xml
@@ -0,0 +1,40 @@
+
+
+
+
+ voip.account.action form view
+ voip.account.action
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action_views.xml
new file mode 100644
index 000000000..5b12bd7b0
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_account_action_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ voip.account.action inherit twilio form view
+ voip.account.action
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_call(core)_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call(core)_views.xml
new file mode 100644
index 000000000..3558eb072
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call(core)_views.xml
@@ -0,0 +1,62 @@
+
+
+
+
+ voip.call view form
+ voip.call
+
+
+
+
+
+
+ voip.call view tree
+ voip.call
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Call Log
+ voip.call
+ tree,form
+
+
+ No Calls
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_comment_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_comment_views.xml
new file mode 100644
index 000000000..3542d4740
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_comment_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ voip.call.comment form view
+ voip.call.comment
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_views.xml
new file mode 100644
index 000000000..a1707981f
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_views.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ voip.call tree view inherit twilio
+ voip.call
+
+
+
+
+
+
+
+
+
+
+
+ voip.call form view inherit twilio
+ voip.call
+
+
+
+
+
+
+
+
+
+
+
+
+
+ view.call.view.search
+ voip.call
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_wizard_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_wizard_views.xml
new file mode 100644
index 000000000..eec79b549
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_call_wizard_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ voip.call.wizard form view
+ voip.call.wizard
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_number_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_number_views.xml
new file mode 100644
index 000000000..b09f6a733
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_number_views.xml
@@ -0,0 +1,49 @@
+
+
+
+
+ voip.number view form
+ voip.number
+
+
+
+
+
+
+ voip.number view tree
+ voip.number
+
+
+
+
+
+
+
+
+
+
+ VOIP Number
+ voip.number
+ tree,form
+
+
+ No Voip Numbers
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_sip_webrtc_twilio_templates.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_sip_webrtc_twilio_templates.xml
new file mode 100644
index 000000000..3e28b8932
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_sip_webrtc_twilio_templates.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Description
+
Source Document
+
Number of Calls
+
Price
+
Disc.(%)
+
Taxes
+
Amount
+
+
+
Description
+
Source Document
+
Quantity
+
Unit Price
+
Disc.(%)
+
Taxes
+
Amount
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Call Log
+
+
Time
Duration
Address
Cost
+
+
+
+
+
+
+ Total Time:
+ Total Cost:
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_invoice_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_invoice_views.xml
new file mode 100644
index 000000000..b330b5074
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_invoice_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ voip.twilio.invoice form view
+ voip.twilio.invoice
+
+
+
+
+
+
\ No newline at end of file
diff --git a/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_views.xml b/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_views.xml
new file mode 100644
index 000000000..c03d3ce2a
--- /dev/null
+++ b/OTHER/voip_sip_webrtc_twilio_self/views/voip_twilio_views.xml
@@ -0,0 +1,54 @@
+
+
+
+
+ voip.twilio form view
+ voip.twilio
+
+
+
+
+
+
+ voip.twilio tree view
+ voip.twilio
+
+
+
+
+
+
+
+
+ Twilio Accounts
+ voip.twilio
+ tree,form
+
+
+ No Twilio Accounts
+
+
+
+
+
\ No newline at end of file
diff --git a/app_store/__init__.py b/app_store/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/app_store/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/app_store/__manifest__.py b/app_store/__manifest__.py
new file mode 100644
index 000000000..308322f18
--- /dev/null
+++ b/app_store/__manifest__.py
@@ -0,0 +1,24 @@
+{
+ 'name': "Custom App Store",
+ 'version': "1.1.7",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Create your own app store",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/module_overview_templates.xml',
+ 'views/module_overview_views.xml',
+ 'views/appstore_account_views.xml',
+ 'views/module_access_views.xml',
+ 'views/menus.xml',
+ 'data/website.menu.csv',
+ 'data/ir.cron.csv',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [],
+ 'depends': ['website'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/app_store/controllers/__init__.py b/app_store/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/app_store/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/app_store/controllers/main.py b/app_store/controllers/main.py
new file mode 100644
index 000000000..b83320daf
--- /dev/null
+++ b/app_store/controllers/main.py
@@ -0,0 +1,149 @@
+# -*- coding: utf-8 -*-
+import base64
+import requests
+import urllib
+import os
+from os.path import expanduser
+from datetime import datetime
+from lxml import html, etree
+import logging
+_logger = logging.getLogger(__name__)
+import zipfile
+import json
+import io
+import csv
+import werkzeug.utils
+import werkzeug.wrappers
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp.tools import ustr
+import openerp.http as http
+from openerp.http import request
+
+class AppsController(http.Controller):
+
+ @http.route('/client/apps', type="http", auth="public", website=True)
+ def client_browse_apps(self, **kwargs):
+ """Browse all the modules inside Odoo client"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ if 'acess_token' in values:
+ modules = request.env['module.overview'].search(['|', ('published', '=', True), ('private_access_ids.access_token', '=', values['acess_token'])])
+ else:
+ modules = request.env['module.overview'].search([('published', '=', True)])
+
+ return http.request.render('app_store.client_app_list', {'modules':modules})
+
+ @http.route('/apps', type="http", auth="public", website=True)
+ def browse_apps(self, **kwargs):
+ """Browse all the modules"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ modules = request.env['module.overview'].search([])
+ return http.request.render('app_store.app_list', {'modules':modules})
+
+ @http.route('/apps/modules/download/', type="http", auth="public")
+ def app_download(self, module_name, **kwargs):
+ """Download the module zip"""
+
+ filename = module_name + ".zip"
+ headers = [
+ ('Content-Type', 'application/octet-stream; charset=binary'),
+ ('Content-Disposition', "attachment; filename=" + filename ),
+ ]
+
+ module = request.env['module.overview'].sudo().search([('name', '=', module_name)])
+ module.module_download_count += 1
+
+ home_directory = os.path.expanduser('~')
+ app_directory = home_directory + "/apps"
+
+ response = werkzeug.wrappers.Response(open(app_directory + "/" + module_name + ".zip", mode="r+b"), headers=headers, direct_passthrough=True)
+ return response
+
+ def content_disposition(self, filename):
+ filename = ustr(filename)
+
+ #escaped = urllib2.quote(filename.encode('utf8'))
+
+ browser = request.httprequest.user_agent.browser
+ version = int((request.httprequest.user_agent.version or '0').split('.')[0])
+ if browser == 'msie' and version < 9:
+ return "attachment; filename=%s" % escaped
+ elif browser == 'safari' and version < 537:
+ return u"attachment; filename=%s" % filename.encode('ascii', 'replace')
+ else:
+ return "attachment; filename*=UTF-8''%s" % escaped
+
+ @http.route('/apps/modules/', type="http", auth="public", website=True)
+ def app_page(self, module_name, **kwargs):
+ """View all the details about a module"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ module = request.env['module.overview'].search([('name','=',module_name)])
+
+ if module.published == False:
+ return "No hack bypassing published"
+
+ module.sudo().module_view_count += 1
+
+ header_string = ""
+ for keys,values in request.httprequest.headers.items():
+ header_string += keys + ": " + values + "\n"
+
+ ref = ""
+ if "Referer" in request.httprequest.headers:
+ ref = request.httprequest.headers['Referer']
+
+ request.env['module.overview.store.view'].sudo().create({'mo_id': module.id, 'ref':ref, 'ip': request.httprequest.remote_addr,'header':header_string})
+
+ return http.request.render('app_store.app_page', {'overview':module})
+
+ @http.route('/client/apps/modules/', type="http", auth="public", website=True)
+ def app_page_client(self, module_name, **kwargs):
+ """View all the details about a module"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ module = request.env['module.overview'].search([('name','=',module_name)])
+
+ if module.published == False:
+ return "No hack bypassing published"
+
+ module.module_view_count += 1
+
+ header_string = ""
+ for keys,values in request.httprequest.headers.items():
+ header_string += keys + ": " + values + "\n"
+
+ ref = ""
+ if "Referer" in request.httprequest.headers:
+ ref = request.httprequest.headers['Referer']
+
+ request.env['module.overview.store.view'].create({'mo_id': module.id, 'ref':ref, 'ip': request.httprequest.remote_addr,'header':header_string})
+
+ return http.request.render('app_store.client_app_page', {'overview':module})
+
+ @http.route('/custom/store/updates', type="http", auth="public")
+ def custom_app_store_updates(self, **kwargs):
+ module_list = []
+ for md in request.env['module.overview'].search([('version', '!=', False), ('published', '=', True)]):
+ if md.version.startswith("11.0."):
+ module_list.append({'name': md.name, 'latest_version': md.version})
+ else:
+ #Prefix with 11.0 so we can compare against the installed version
+ module_list.append({'name': md.name, 'latest_version': "11.0." + md.version})
+
+ return json.dumps(module_list)
\ No newline at end of file
diff --git a/app_store/data/ir.cron.csv b/app_store/data/ir.cron.csv
new file mode 100644
index 000000000..95d460a14
--- /dev/null
+++ b/app_store/data/ir.cron.csv
@@ -0,0 +1,2 @@
+id,name,model_id/id,state,code,interval_number,interval_type,numbercall,active
+"appstore_check","App Store Repository Sync",model_appstore_account_repository,"code",model.check_all_repositories(),12,hours,-1,true
diff --git a/app_store/data/website.menu.csv b/app_store/data/website.menu.csv
new file mode 100644
index 000000000..744af8c26
--- /dev/null
+++ b/app_store/data/website.menu.csv
@@ -0,0 +1,2 @@
+"id","name","url","parent_id/id"
+"app_store","App Store","/apps","website.main_menu"
diff --git a/app_store/doc/changelog.rst b/app_store/doc/changelog.rst
new file mode 100644
index 000000000..3c743fbdf
--- /dev/null
+++ b/app_store/doc/changelog.rst
@@ -0,0 +1,76 @@
+v1.1.7
+======
+* Unpublished modules can not be updated
+
+v1.1.6
+======
+* Skips analyses of view/models if an error is encountered
+
+v1.1.5
+======
+* Fix code syntax error
+
+v1.1.4
+======
+* Skip views without names
+
+v1.1.3
+======
+* Add a missing permission
+
+v1.1.2
+======
+* Stop updates from breaking if a module lacks a proper version number
+
+v1.1.1
+======
+* Bug fix for systems with old version of app_store client without access_token
+
+v1.1.0
+======
+* Private access to unpublished modules (alpha/beta access)
+
+v1.0.9
+======
+* Skip repos on error (better error handling later...)
+
+v1.0.8
+======
+* Tidy up repos on delete of account
+
+v1.0.7
+======
+* Public access security fixes
+
+v1.0.6
+======
+* Ability to unpublish modules from the store
+
+v1.0.5
+======
+* Added summary field
+
+v1.0.4
+======
+* Fix analyse issue
+* Addd author field
+* Revamp user interface of client app store
+* Fix latest version number display in custom apps update
+
+v1.0.3
+======
+* Fix to not analyse modules with no version number
+* Fix for modules whose version number already starts with 11.0
+* Fix for github repo zips not being in root ~/apps directory which update mechanism expects
+
+v1.0.2
+======
+* Module update support and github sync fix
+
+v1.0.1
+======
+* Fix module analyse issue
+
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/app_store/models/__init__.py b/app_store/models/__init__.py
new file mode 100644
index 000000000..8d37e493b
--- /dev/null
+++ b/app_store/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import module_overview
+from . import appstore_account
\ No newline at end of file
diff --git a/app_store/models/appstore_account.py b/app_store/models/appstore_account.py
new file mode 100644
index 000000000..7a9977b6d
--- /dev/null
+++ b/app_store/models/appstore_account.py
@@ -0,0 +1,311 @@
+# -*- coding: utf-8 -*
+import io
+import zipfile
+import tempfile
+from urllib.request import Request, urlopen
+import os
+from io import BytesIO
+import sys
+from os import walk
+import glob
+from lxml import html, etree
+import csv
+import fnmatch
+import logging
+_logger = logging.getLogger(__name__)
+import ast
+import base64
+import os.path
+from docutils.core import publish_string
+import json
+
+from odoo import api, fields, models
+
+class AppstoreAccount(models.Model):
+
+ _name = "appstore.account"
+ _description = "App Store Account"
+
+ name = fields.Char(string="Name")
+ repositories_ids = fields.One2many('appstore.account.repository', 'asa_id', string="Repositories")
+
+ def sync_repos(self):
+ self.env['appstore.account.repository'].check_all_repositories()
+
+class AppstoreAccountRepository(models.Model):
+
+ _name = "appstore.account.repository"
+ _description = "App Store Account Repository"
+
+ asa_id = fields.Many2one('appstore.account', ondelete='cascade', string="App Store Account")
+ url = fields.Char(string="Repository URL")
+ token = fields.Char(String="Token")
+
+ @api.model
+ def check_all_repositories(self):
+ """Checks to see if there are any new modules"""
+
+ filename = tempfile.mktemp('.zip')
+ destDir = tempfile.mktemp()
+ home_directory = os.path.expanduser('~')
+ app_directory = home_directory + "/apps"
+
+ for account_repository in self.env['appstore.account.repository'].search([]):
+ rep_directory = app_directory + "/" + account_repository.url.split("/")[3]
+
+ repository_url = account_repository.url.split("#")[0] + "/archive/" + account_repository.url.split("#")[1] + ".zip"
+ q = Request(repository_url)
+
+ if account_repository.token:
+ q.add_header('Authorization', 'token ' + account_repository.token)
+
+ try:
+ repo_data = urlopen(q).read()
+ except:
+ _logger.error("Failed to read repo")
+ continue
+
+ thefile = zipfile.ZipFile(BytesIO(repo_data))
+
+ if not os.path.exists(rep_directory):
+ os.makedirs(rep_directory)
+
+ thefile.extractall(rep_directory)
+
+ thefile.close()
+
+ rep_name = account_repository.url.split("/")[4].replace("#","-")
+
+ full_rep_path = rep_directory + "/" + rep_name
+
+ #Go through all module folders under the repository directory and analyse the module
+ for dir in os.listdir(full_rep_path):
+ if os.path.isdir(os.path.join(full_rep_path, dir)):
+ self.analyse_module(dir, full_rep_path)
+
+ def analyse_module(self, module_name, app_directory):
+ try:
+ manifest_file = ""
+ if os.path.exists(app_directory + "/" + module_name + "/__manifest__.py"):
+ manifest_file = "__manifest__.py"
+
+ if os.path.exists(app_directory + "/" + module_name + "/__openerp__.py"):
+ manifest_file = "__openerp__.py"
+
+ if manifest_file == "":
+ #If they module does not have a manifest file do not even bother
+ return 0
+
+ with open(app_directory + "/" + module_name + "/" + manifest_file, 'r') as myfile:
+ #Remove comments as literal_eval hates them
+ trimmed_data = ""
+ for i, line in enumerate(myfile):
+ if not line.lstrip().startswith("#"):
+ trimmed_data += line
+
+ #trimmed_data = trimmed_data.replace("'", "\"")
+ op_settings = ast.literal_eval(trimmed_data)
+
+
+ #Modules that don't have version number are not analysed
+ if 'version' not in op_settings:
+ return 0
+
+ #Convert icon file to base64
+ icon_base64 = ""
+ if os.path.isfile(app_directory + "/" + module_name + "/static/description/icon.png"):
+ with open(app_directory + "/" + module_name + "/static/description/icon.png", "rb") as image_file:
+ icon_base64 = base64.b64encode(image_file.read())
+
+ if self.env['module.overview'].search_count([('name', '=', module_name)]) == 0:
+ module_overview = self.env['module.overview'].create({'name': module_name})
+ else:
+ #Clear out most things except download / view count
+ module_overview = self.env['module.overview'].search([('name', '=', module_name)])[0]
+ module_overview.models_ids.unlink()
+ module_overview.menu_ids.unlink()
+ module_overview.group_ids.unlink()
+ module_overview.image_ids.unlink()
+ module_overview.depend_ids.unlink()
+
+ if 'author' in op_settings:
+ module_overview.author = op_settings['author']
+
+ if 'summary' in op_settings:
+ module_overview.summary = op_settings['summary']
+
+ module_overview.module_name = op_settings['name']
+ module_overview.icon = icon_base64
+ module_overview.version = op_settings['version']
+
+ except Exception as e:
+ _logger.error(module_name)
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+ try:
+ #Read /doc/changelog.rst file
+ if os.path.exists(app_directory + "/" + module_name + "/doc/changelog.rst"):
+ with open(app_directory + "/" + module_name + "/doc/changelog.rst", 'r') as changelogfile:
+ changelogdata = changelogfile.read()
+ module_overview.change_log_raw = changelogdata
+ module_overview.change_log_html = changelogdata
+ #module_overview.change_log_html = publish_string(changelogdata, writer_name='html').split("\n",2)[2]
+
+ #Read /static/description/index.html file
+ if os.path.exists(app_directory + "/" + module_name + "/static/description/index.html"):
+ with open(app_directory + "/" + module_name + "/static/description/index.html", 'r') as descriptionfile:
+ descriptiondata = descriptionfile.read()
+ module_overview.store_description = descriptiondata
+
+ if 'depends' in op_settings:
+ for depend in op_settings['depends']:
+ self.env['module.overview.depend'].create({'mo_id': module_overview.id, 'name': depend})
+
+ if 'images' in op_settings:
+ for img in op_settings['images']:
+ image_path = app_directory + "/" + module_name + "/" + img
+ if os.path.exists(image_path):
+ with open(image_path, "rb") as screenshot_file:
+ screenshot_base64 = base64.b64encode(screenshot_file.read())
+
+ self.env['module.overview.image'].create({'mo_id': module_overview.id, 'name': img, 'file_data': screenshot_base64})
+
+ for root, dirnames, filenames in os.walk(app_directory + '/' + module_name):
+ for filename in fnmatch.filter(filenames, '*.xml'):
+ self._read_xml(os.path.join(root, filename), module_overview.id)
+
+ #for filename in fnmatch.filter(filenames, '*.csv'):
+ # self._read_csv(filename, open( os.path.join(root, filename) ).read(), module_overview.id)
+
+ except Exception as e:
+ _logger.error(module_name)
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ pass
+
+ #Create a zip of the module (TODO include dependacies)
+ zf = zipfile.ZipFile(os.path.expanduser('~') + "/apps/" + module_name + ".zip", "w")
+ for dirname, subdirs, files in os.walk(app_directory + "/" + module_name):
+ for filename in files:
+ full_file_path = os.path.join(dirname, filename)
+ zf.write(full_file_path, arcname=os.path.relpath(full_file_path, app_directory + "/" + module_name))
+ zf.close()
+
+ def _read_csv(self, file_name, file_content, m_id):
+ if "ir.model.access":
+ rownum = 0
+ reader = csv.reader(file_content, delimiter=',')
+ header = []
+ for row in reader:
+ row_dict = {'mo_id': m_id}
+ # Save header row.
+ if rownum == 0:
+ header = row
+ else:
+ colnum = 0
+ for col in row:
+ #map the csv header columns to the fields
+ if header[colnum] == "id":
+ row_dict['x_id'] = col
+ elif header[colnum] == "model_id:id":
+ row_dict['model'] = col
+ elif header[colnum] == "group_id:id":
+ row_dict['group'] = col
+ elif header[colnum] == "perm_read":
+ row_dict['perm_read'] = bool(int(col))
+ elif header[colnum] == "perm_write":
+ row_dict['perm_write'] = bool(int(col))
+ elif header[colnum] == "perm_create":
+ row_dict['perm_create'] = bool(int(col))
+ elif header[colnum] == "perm_unlink":
+ row_dict['perm_unlink'] = bool(int(col))
+ else:
+ row_dict[header[colnum]] = col
+
+ colnum += 1
+
+ group_name = ""
+
+ #Deal with blank or other malformed rows
+ if ('group' not in row_dict) or ('x_id' not in row_dict):
+ continue
+
+ if row_dict['group'] != "":
+ group_name = self.env['ir.model.data'].get_object(row_dict['group'].split(".")[0], row_dict['group'].split(".")[1]).display_name
+
+ #Create the group if it does exist
+ ex_group = self.env['module.overview.group'].search([('x_id','=',row_dict['group'])])
+
+ if len(ex_group) == 0:
+ my_group = self.env['module.overview.group'].create({'mo_id': m_id, 'name': group_name, 'x_id': row_dict['group']})
+ else:
+ my_group = ex_group[0]
+
+ #If model diiesn't exists, create it
+ ex_model = self.env['module.overview.model'].search([('name','=', self._ref_to_model(row_dict['model']) )])
+
+ if len(ex_model) == 0:
+ #create the model
+ my_model = self.env['module.overview.model'].create({'name':self._ref_to_model(row_dict['model']), 'mo_id': m_id})
+ else:
+ my_model = ex_model[0]
+
+ #Add the access rule to the group
+ self.env['module.overview.group.access'].create({'mog_id':my_group.id, 'model_id': my_model.id, 'perm_read': row_dict['perm_read'], 'perm_write':row_dict['perm_write'], 'perm_create':row_dict['perm_create'], 'perm_unlink':row_dict['perm_unlink']})
+
+ access_dict = {'model_id':my_model.id, 'name': group_name, 'x_id': row_dict['group'], 'perm_read': row_dict['perm_read'], 'perm_write':row_dict['perm_write'], 'perm_create':row_dict['perm_create'], 'perm_unlink':row_dict['perm_unlink']}
+ self.env['module.overview.model.access'].create(access_dict)
+
+ rownum += 1
+
+ def _ref_to_model(self, ref_string):
+ """Turns 'model_sms_message' into 'sms.message'"""
+ return ref_string.replace("model_","").replace("_",".")
+
+ def _read_xml(self, file_path, m_id):
+ return_string = ""
+ root = etree.parse(file_path)
+
+ insert_menu = root.xpath('//menuitem')
+ for menu in insert_menu:
+ menu_dict = {'mo_id': m_id}
+ menu_dict['x_id'] = menu.attrib['id']
+
+ if 'name' in menu.attrib:
+ menu_dict['name'] = menu.attrib['name']
+
+ if 'parent' in menu.attrib:
+ try:
+ menu_dict['parent'] = self.env['ir.model.data'].get_object(menu.attrib['parent'].split(".")[0], menu.attrib['parent'].split(".")[1]).display_name
+ except:
+ pass
+ menu_dict['parent_x_id'] = menu.attrib['parent']
+
+ self.env['module.overview.menu'].create(menu_dict)
+
+ insert_records = root.xpath('//record')
+ for rec in insert_records:
+ record_id = rec.attrib['id']
+
+ #If it's a view
+ if rec.attrib['model'] == "ir.ui.view":
+ if len(rec.find(".//field[@name='name']")) > 0:
+ record_name = rec.find(".//field[@name='name']").text
+ else:
+ continue
+
+ model_name = rec.find(".//field[@name='model']").text
+ model_exist = self.env['module.overview.model'].search([('name','=',model_name),('mo_id','=',m_id) ])
+ model = ""
+
+ #if this is the first time encountering model, create it.
+ if len(model_exist) == 0:
+ model = self.env['module.overview.model'].create({'mo_id': m_id, 'name': model_name})
+ else:
+ model = model_exist[0]
+
+ #add this view to this model
+ self.env['module.overview.model.view'].create({'model_id': model.id, 'name': record_name, 'x_id': record_id})
\ No newline at end of file
diff --git a/app_store/models/module_overview.py b/app_store/models/module_overview.py
new file mode 100644
index 000000000..131e59732
--- /dev/null
+++ b/app_store/models/module_overview.py
@@ -0,0 +1,255 @@
+# -*- coding: utf-8 -*
+import os
+from os import walk
+import glob
+from lxml import html, etree
+import csv
+import fnmatch
+import zipfile
+import logging
+import ast
+import base64
+_logger = logging.getLogger(__name__)
+import os.path
+from docutils.core import publish_string
+import sys
+import json
+
+from openerp import api, fields, models
+
+class ModuleOverview(models.Model):
+
+ _name = "module.overview"
+ _description = "Module Overview"
+
+ name = fields.Char(string="Internal Name")
+ private_access_ids = fields.Many2many('module.access', string="Private Access")
+ models_ids = fields.One2many('module.overview.model', 'mo_id', string="Models")
+ model_count = fields.Integer(string="Model Count", compute="_compute_model_count")
+ menu_ids = fields.One2many('module.overview.menu', 'mo_id', string="Menus")
+ menu_count = fields.Integer(string="Menu Count", compute="_compute_menu_count")
+ group_ids = fields.One2many('module.overview.group', 'mo_id', string="Groups")
+ group_count = fields.Integer(string="Group Count", compute="_compute_group_count")
+ depend_ids = fields.One2many('module.overview.depend', 'mo_id', string="Module Dependacies")
+ image_ids = fields.One2many('module.overview.image', 'mo_id', string="Images")
+ store_views_ids = fields.One2many('module.overview.store.view', 'mo_id', string="Store Views")
+ module_view_count = fields.Integer(string="Module View Count", help="The amount of times the page for this module has been viewed")
+ module_download_count = fields.Integer(string="Module Download Count", help="The amount of times this module has been downloaded")
+ module_name = fields.Char(string="Module Name")
+ version = fields.Char(string="Version Number")
+ author = fields.Char(string="Author")
+ summary = fields.Char(string="summary")
+ icon = fields.Binary(string="Icon")
+ store_description = fields.Html(string="Store Description")
+ change_log_raw = fields.Text(string="Change Log")
+ change_log_html = fields.Html(string="Change Log(html)")
+ published = fields.Boolean(string="Published", default=True)
+
+ @api.one
+ @api.depends('menu_ids')
+ def _compute_menu_count(self):
+ self.menu_count = len(self.menu_ids)
+
+ @api.one
+ @api.depends('group_ids')
+ def _compute_group_count(self):
+ self.group_count = len(self.group_ids)
+
+ @api.one
+ @api.depends('models_ids')
+ def _compute_model_count(self):
+ self.model_count = len(self.models_ids)
+
+class ModuleAccess(models.Model):
+
+ _name = "module.access"
+ _description = "Module Access"
+
+ name = fields.Char(string="Name")
+ access_token = fields.Char(string="Access Token")
+ module_access_ids = fields.Many2many('module.overview', tring="Module Access", help="Inactive modules this token allows access too")
+
+class ModuleOverviewStoreView(models.Model):
+
+ _name = "module.overview.store.view"
+ _description = "Module Overview Store View"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ ip = fields.Char(string="IP")
+ ref = fields.Char(string="Ref", help="The URL the person came from")
+ header = fields.Char(string="Header", help="The raw header which misc info can be extracted from")
+
+class ModuleOverviewDepend(models.Model):
+
+ _name = "module.overview.depend"
+ _description = "Module Overview Dependacies"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ name = fields.Char(string="name")
+
+class ModuleOverviewImage(models.Model):
+
+ _name = "module.overview.image"
+ _description = "Module Overview Image"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ name = fields.Char(string="File Path")
+ file_data = fields.Binary(string="File Data")
+
+class ModuleOverviewWizard(models.Model):
+
+ _name = "module.overview.wizard"
+ _description = "Module Overview Wizard"
+
+ name = fields.Char(string="Module Name")
+
+class ModuleOverviewGroup(models.Model):
+
+ _name = "module.overview.group"
+ _description = "Module Overview Group"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ x_id = fields.Char(string="XML ID")
+ name = fields.Char(string="Name")
+ access_ids = fields.One2many('module.overview.group.access', 'mog_id', string="Group Permissions")
+
+class ModuleOverviewGroupAccess(models.Model):
+
+ _name = "module.overview.group.access"
+
+ mog_id = fields.Many2one('module.overview.group', string="Module Overview", ondelete="cascade")
+ model_id = fields.Many2one('module.overview.model', string="Model")
+ perm_read = fields.Boolean(string="Read Permmision")
+ perm_write = fields.Boolean(string="Write Permmision")
+ perm_create = fields.Boolean(string="Create Permmision")
+ perm_unlink = fields.Boolean(string="Delete Permmision")
+ access_string = fields.Char(string="Access String", compute="_compute_access_string")
+
+ @api.one
+ @api.depends('perm_read', 'perm_write', 'perm_create', 'perm_unlink')
+ def _compute_access_string(self):
+ a_string = ""
+
+ if self.perm_read:
+ a_string += "Read, "
+
+ if self.perm_write:
+ a_string += "Write, "
+
+ if self.perm_create:
+ a_string += "Create, "
+
+ if self.perm_unlink:
+ a_string += "Delete, "
+
+ self.access_string = a_string[:-2]
+
+class ModuleOverviewAccess(models.Model):
+
+ _name = "module.overview.access"
+ _description = "depricted"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ x_id = fields.Char(string="XML ID")
+ name = fields.Char(string="Name")
+ model = fields.Char(string="Model")
+ group = fields.Char(string="Group")
+ perm_read = fields.Boolean(string="Read Permmision")
+ perm_write = fields.Boolean(string="Write Permmision")
+ perm_create = fields.Boolean(string="Create Permmision")
+ perm_unlink = fields.Boolean(string="Delete Permmision")
+ access_string = fields.Char(string="Access String", compute="_compute_access_string")
+
+ @api.one
+ @api.depends('perm_read', 'perm_write', 'perm_create', 'perm_unlink')
+ def _compute_access_string(self):
+ a_string = ""
+
+ if self.perm_read:
+ a_string += "Read, "
+
+ if self.perm_write:
+ a_string += "Write, "
+
+ if self.perm_create:
+ a_string += "Create, "
+
+ if self.perm_unlink:
+ a_string += "Delete, "
+
+ self.access_string = a_string[:-2]
+
+class ModuleOverviewMenu(models.Model):
+
+ _name = "module.overview.menu"
+ _description = "Module Overview Menu"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ name = fields.Char(string="Name")
+ x_id = fields.Char(string="XML ID")
+ parent = fields.Char(string="Parent Menu")
+ parent_x_id = fields.Char(string="Parent Menu XML ID")
+
+class ModuleOverviewModel(models.Model):
+
+ _name = "module.overview.model"
+ _description = "Module Overview Model"
+
+ mo_id = fields.Many2one('module.overview', string="Module Overview", ondelete="cascade")
+ name = fields.Char(string="Name")
+ view_ids = fields.One2many('module.overview.model.view', 'model_id', string="Views")
+ view_count = fields.Integer(string="View Count", compute="_compute_view_count")
+ access_ids = fields.One2many('module.overview.model.access', 'model_id', string="Access Rules")
+ access_count = fields.Integer(string="Access Count", compute="_compute_access_count")
+
+ @api.one
+ @api.depends('view_ids')
+ def _compute_view_count(self):
+ self.view_count = len(self.view_ids)
+
+ @api.one
+ @api.depends('access_ids')
+ def _compute_access_count(self):
+ self.access_count = len(self.access_ids)
+
+class ModuleOverviewModelAccess(models.Model):
+
+ _name = "module.overview.model.access"
+ _description = "Module Overview Model Access"
+
+ model_id = fields.Many2one('module.overview.model', string="Model", ondelete="cascade")
+ name = fields.Char(string="Group Name")
+ x_id = fields.Char(string="XML ID")
+ perm_read = fields.Boolean(string="Read Permmision")
+ perm_write = fields.Boolean(string="Write Permmision")
+ perm_create = fields.Boolean(string="Create Permmision")
+ perm_unlink = fields.Boolean(string="Delete Permmision")
+ access_string = fields.Char(string="Access String", compute="_compute_access_string")
+
+ @api.one
+ @api.depends('perm_read', 'perm_write', 'perm_create', 'perm_unlink')
+ def _compute_access_string(self):
+ a_string = ""
+
+ if self.perm_read:
+ a_string += "Read, "
+
+ if self.perm_write:
+ a_string += "Write, "
+
+ if self.perm_create:
+ a_string += "Create, "
+
+ if self.perm_unlink:
+ a_string += "Delete, "
+
+ self.access_string = a_string[:-2]
+
+class ModuleOverviewModelView(models.Model):
+
+ _name = "module.overview.model.view"
+ _description = "Module Overview Model View"
+
+ model_id = fields.Many2one('module.overview.model', string="Model", ondelete="cascade")
+ name = fields.Char(string="Name")
+ x_id = fields.Char(string="XML ID")
\ No newline at end of file
diff --git a/app_store/security/ir.model.access.csv b/app_store/security/ir.model.access.csv
new file mode 100644
index 000000000..b337272b5
--- /dev/null
+++ b/app_store/security/ir.model.access.csv
@@ -0,0 +1,10 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_module_access","access module.access","model_module_access",,1,1,1,1
+"access_module_overview","access module.overview","model_module_overview",,1,0,0,0
+"access_module_overview_depend","access module.overview.depend","model_module_overview_depend",,1,0,0,0
+"access_module_overview_image","access module.overview.image","model_module_overview_image",,1,0,0,0
+"access_module_overview_group","access module.overview.group","model_module_overview_group",,1,0,0,0
+"access_module_overview_menu","access module.overview.menu","model_module_overview_menu",,1,0,0,0
+"access_module_overview_model","access module.overview.model","model_module_overview_model",,1,0,0,0
+"access_module_overview_model_view","access module.overview.model.view","model_module_overview_model_view",,1,0,0,0
+"access_module_overview_model_access","access module.overview.model.access","model_module_overview_model_access",,1,0,0,0
\ No newline at end of file
diff --git a/app_store/static/description/1.jpg b/app_store/static/description/1.jpg
new file mode 100644
index 000000000..733df8e0a
Binary files /dev/null and b/app_store/static/description/1.jpg differ
diff --git a/app_store/static/description/2.jpg b/app_store/static/description/2.jpg
new file mode 100644
index 000000000..a51035890
Binary files /dev/null and b/app_store/static/description/2.jpg differ
diff --git a/app_store/static/description/index.html b/app_store/static/description/index.html
new file mode 100644
index 000000000..97259110c
--- /dev/null
+++ b/app_store/static/description/index.html
@@ -0,0 +1,10 @@
+
+
Description
+Your own app store
+
+
Instructions
+1. Create a folder named "apps" in the Odoo instances home directory e.g. "/odoo/apps"
+2. Upload your module folders to the newly created 'apps' folder
+3. Go to Settings->App Store->Update App Store and click 'Update Module List'
+4. View your modules on the App Store menu on your website
+
\ No newline at end of file
diff --git a/app_store/views/appstore_account_views.xml b/app_store/views/appstore_account_views.xml
new file mode 100644
index 000000000..a5f443df8
--- /dev/null
+++ b/app_store/views/appstore_account_views.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ appstore.account.view.form
+ appstore.account
+
+
+
+
+
+
+ appstore.account.view.tree
+ appstore.account
+
+
+
+
+
+
+
+
+ App Store Accounts
+ appstore.account
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/app_store/views/menus.xml b/app_store/views/menus.xml
new file mode 100644
index 000000000..d99213404
--- /dev/null
+++ b/app_store/views/menus.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app_store/views/module_access_views.xml b/app_store/views/module_access_views.xml
new file mode 100644
index 000000000..a1c5319f2
--- /dev/null
+++ b/app_store/views/module_access_views.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ module_access.view.form
+ module.access
+
+
+
+
+
+
+ module_access.view.tree
+ module.access
+
+
+
+
+
+
+
+
+
+
+ Module Access
+ module.access
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/app_store/views/module_overview_templates.xml b/app_store/views/module_overview_templates.xml
new file mode 100644
index 000000000..f16f5eed5
--- /dev/null
+++ b/app_store/views/module_overview_templates.xml
@@ -0,0 +1,405 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ module.custom.updates tree view
+ module.custom.updates
+
+
+
+
+
+
+
+
+
+
+
+ Check for Custom Apps Updates
+ module.custom.updates
+ form
+ form
+ new
+
+
+
+
+
\ No newline at end of file
diff --git a/crm_custom_fields/__init__.py b/crm_custom_fields/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/crm_custom_fields/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/crm_custom_fields/__manifest__.py b/crm_custom_fields/__manifest__.py
new file mode 100644
index 000000000..47af7c8e5
--- /dev/null
+++ b/crm_custom_fields/__manifest__.py
@@ -0,0 +1,23 @@
+{
+ 'name': "CRM Custom Fields",
+ 'version': "1.4.0",
+ 'author': "Sythil Tech",
+ 'category': "CRM",
+ 'summary': "Allows users in the 'Sales / Manager' group to add additional fields to the partner form",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/res_partner_views.xml',
+ 'views/ir_model_views.xml',
+ 'views/sale_config_settings_convert_views.xml',
+ 'security/ir.model.access.csv',
+ 'data/crm.custom.fields.widget.csv',
+ ],
+ 'demo': [],
+ 'depends': ['crm'],
+ 'images':[
+ 'static/description/1.jpg',
+ 'static/description/2.jpg',
+ 'static/description/3.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/crm_custom_fields/data/crm.custom.fields.widget.csv b/crm_custom_fields/data/crm.custom.fields.widget.csv
new file mode 100644
index 000000000..cdf8e4548
--- /dev/null
+++ b/crm_custom_fields/data/crm.custom.fields.widget.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"url","URL","url"
\ No newline at end of file
diff --git a/crm_custom_fields/doc/changelog.rst b/crm_custom_fields/doc/changelog.rst
new file mode 100644
index 000000000..f428dba03
--- /dev/null
+++ b/crm_custom_fields/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0
+====
+* Port to version 11
\ No newline at end of file
diff --git a/crm_custom_fields/models/__init__.py b/crm_custom_fields/models/__init__.py
new file mode 100644
index 000000000..2850ffb13
--- /dev/null
+++ b/crm_custom_fields/models/__init__.py
@@ -0,0 +1,7 @@
+# -*- coding: utf-8 -*-
+
+from . import ir_model
+from . import ir_model_fields
+from . import res_partner
+from . import crm_lead
+from . import sale_config_settings
\ No newline at end of file
diff --git a/crm_custom_fields/models/crm_lead.py b/crm_custom_fields/models/crm_lead.py
new file mode 100644
index 000000000..42555108d
--- /dev/null
+++ b/crm_custom_fields/models/crm_lead.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models, tools, SUPERUSER_ID
+
+class CRMLeadConert(models.Model):
+
+ _inherit = "crm.lead"
+
+ @api.multi
+ def _lead_create_contact(self, name, is_company, parent_id=False):
+ """ extract data from lead to create a partner
+ :param name : furtur name of the partner
+ :param is_company : True if the partner is a company
+ :param parent_id : id of the parent partner (False if no parent)
+ :returns res.partner record
+ """
+ email_split = tools.email_split(self.email_from)
+ values = {
+ 'name': name,
+ 'user_id': self.user_id.id,
+ 'comment': self.description,
+ 'team_id': self.team_id.id,
+ 'parent_id': parent_id,
+ 'phone': self.phone,
+ 'mobile': self.mobile,
+ 'email': email_split[0] if email_split else False,
+ 'fax': self.fax,
+ 'title': self.title.id,
+ 'function': self.function,
+ 'street': self.street,
+ 'street2': self.street2,
+ 'zip': self.zip,
+ 'city': self.city,
+ 'country_id': self.country_id.id,
+ 'state_id': self.state_id.id,
+ 'is_company': is_company,
+ 'type': 'contact'
+ }
+
+ for convert_field in self.env['sale.config.settings.convert'].search([]):
+ values[convert_field.partner_field_id.name] = self[convert_field.lead_field_id.name]
+
+ return self.env['res.partner'].create(values)
+
diff --git a/crm_custom_fields/models/ir_model.py b/crm_custom_fields/models/ir_model.py
new file mode 100644
index 000000000..2bbf724b1
--- /dev/null
+++ b/crm_custom_fields/models/ir_model.py
@@ -0,0 +1,44 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class IrModelCRMFields(models.Model):
+
+ _inherit = "ir.model"
+
+
+ custom_field_ids = fields.One2many('ir.model.fields', 'custom_model_id', string="Custom Fields", domain=[('crm_custom_field','=',True)])
+
+ @api.one
+ def fake_save(self):
+ """Function does nothing, save gets called after"""
+ pass
+
+ @api.multi
+ def write(self, values):
+
+ ins = super(IrModelCRMFields, self).write(values)
+
+ partner_custom_form_fields = self.env['ir.model.data'].sudo().get_object('crm_custom_fields', 'view_partner_form_inherit_crm_custom_fields')
+
+ custom_form_fields_string = ""
+
+ custom_form_fields_string += "\n"
+ custom_form_fields_string += " \n"
+ custom_form_fields_string += " \n"
+
+ for custom_field in self.env['ir.model.fields'].sudo().search([('crm_custom_field','=',True)]):
+ custom_form_fields_string += " "
+
+ custom_form_fields_string += " \n"
+ custom_form_fields_string += " \n"
+ custom_form_fields_string += " \n"
+ custom_form_fields_string += ""
+
+ partner_custom_form_fields.arch = custom_form_fields_string
+
+ return ins
\ No newline at end of file
diff --git a/crm_custom_fields/models/ir_model_fields.py b/crm_custom_fields/models/ir_model_fields.py
new file mode 100644
index 000000000..039164575
--- /dev/null
+++ b/crm_custom_fields/models/ir_model_fields.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+from openerp import api, fields, models
+
+class IrModelFieldsCRMFields(models.Model):
+
+ _inherit = "ir.model.fields"
+
+ custom_model_id = fields.Many2one('ir.model', string="Custom Model")
+ crm_limited_types = fields.Selection([('char','Single Line Textbox'), ('text','Multi Line Textbox'), ('date','Date'), ('datetime','Date Time'), ('selection', 'Static Dropdown')], default="char", string="Field Type")
+ crm_custom_name = fields.Char(string="Field Name")
+ crm_custom_field = fields.Boolean(string="Custom CRM Field")
+ crm_custom_field_widget = fields.Many2one('crm.custom.fields.widget', string="Widget")
+ crm_custom_field_selection_ids = fields.One2many('ir.model.fields.selections', 'imf_id', string="Selection Options")
+
+ @api.onchange('crm_limited_types')
+ def _onchange_crm_limited_types(self):
+ self.ttype = self.crm_limited_types
+
+ @api.onchange('crm_custom_field_selection_ids')
+ def _onchange_crm_custom_field_selection_ids(self):
+ sel_string = ""
+ for sel in self.crm_custom_field_selection_ids:
+ sel_string += "('" + sel.internal_name + "','" + sel.name + "'), "
+
+ self.selection = "[" + sel_string[:-2] + "]"
+
+ @api.model
+ def create(self, values):
+ """Assign name when it's actually saved overwise they all get the same ID"""
+
+ if 'crm_custom_field' in values:
+ temp_name = "x_custom_" + values['field_description']
+ temp_name = temp_name.replace(" ","_").lower()
+ values['name'] = temp_name
+ values['model_id'] = self.env['ir.model.data'].get_object('base','model_res_partner').id
+ _logger.error(temp_name)
+
+ return super(IrModelFieldsCRMFields, self).create(values)
+
+class CrmCustomFieldsWidget(models.Model):
+
+ _name = "crm.custom.fields.widget"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Internal Name", help="The technicial name of the widget")
+
+class IrModelFieldsCRMFieldsSelection(models.Model):
+
+ _name = "ir.model.fields.selections"
+
+ imf_id = fields.Many2one('ir.model.fields', string="Field")
+ name = fields.Char(string="Name", required="True")
+ internal_name = fields.Char(string="Internal Name", required="True")
\ No newline at end of file
diff --git a/crm_custom_fields/models/res_partner.py b/crm_custom_fields/models/res_partner.py
new file mode 100644
index 000000000..153ff7273
--- /dev/null
+++ b/crm_custom_fields/models/res_partner.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class ResPartnerCustomFields(models.Model):
+
+ _inherit = "res.partner"
+
+ @api.multi
+ def open_custom_field_form(self):
+ my_model = self.env['ir.model'].search([('model','=','res.partner')])[0]
+ custom_model_view = self.env['ir.model.data'].sudo().get_object('crm_custom_fields','ir_model_view_form_custom_crm_fields')
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'ir.model',
+ 'res_id': my_model.id,
+ 'view_id': custom_model_view.id,
+ 'target': 'new',
+ }
\ No newline at end of file
diff --git a/crm_custom_fields/models/sale_config_settings.py b/crm_custom_fields/models/sale_config_settings.py
new file mode 100644
index 000000000..3e471b7f6
--- /dev/null
+++ b/crm_custom_fields/models/sale_config_settings.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class SaleConfigSettingsConvert(models.Model):
+
+ _name = "sale.config.settings.convert"
+
+ lead_field_id = fields.Many2one('ir.model.fields', string="Lead Field", domain="[('model','=', 'crm.lead')]")
+ partner_field_id = fields.Many2one('ir.model.fields', string="Partner Field", domain="[('model','=', 'res.partner')]")
\ No newline at end of file
diff --git a/crm_custom_fields/security/ir.model.access.csv b/crm_custom_fields/security/ir.model.access.csv
new file mode 100644
index 000000000..6d7523587
--- /dev/null
+++ b/crm_custom_fields/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_ir_model_fields,access ir.model.fields,model_ir_model_fields,sales_team.group_sale_manager,1,1,1,1
+access_ir_model,access ir.model,model_ir_model,sales_team.group_sale_manager,1,1,0,0
\ No newline at end of file
diff --git a/crm_custom_fields/static/description/1.jpg b/crm_custom_fields/static/description/1.jpg
new file mode 100644
index 000000000..f9720c444
Binary files /dev/null and b/crm_custom_fields/static/description/1.jpg differ
diff --git a/crm_custom_fields/static/description/2.jpg b/crm_custom_fields/static/description/2.jpg
new file mode 100644
index 000000000..1e3f544ae
Binary files /dev/null and b/crm_custom_fields/static/description/2.jpg differ
diff --git a/crm_custom_fields/static/description/3.jpg b/crm_custom_fields/static/description/3.jpg
new file mode 100644
index 000000000..5cb5fdfff
Binary files /dev/null and b/crm_custom_fields/static/description/3.jpg differ
diff --git a/crm_custom_fields/static/description/icon.png b/crm_custom_fields/static/description/icon.png
new file mode 100644
index 000000000..0249aa1b1
Binary files /dev/null and b/crm_custom_fields/static/description/icon.png differ
diff --git a/crm_custom_fields/static/description/index.html b/crm_custom_fields/static/description/index.html
new file mode 100644
index 000000000..78d16ed25
--- /dev/null
+++ b/crm_custom_fields/static/description/index.html
@@ -0,0 +1,48 @@
+
+
Description
+Allows users in the 'Sales / Manager' group to add additional fields to the partner form
+
+
+
+
Add new fields without having to add python or edit xml.
+
+
+
+
+
+
+
+Designed to simplify the adding of new fields to the CRM.
+
+Instructions
+1. Go to any customer record and open the 'Custom Fields' tab
+2. Click on the 'Add Custom Field' button
+3. Refresh your browser to see the new fields under the Custom Fields tab
+
+
+
+
+
+
+
+
Simple field configuration form
+
+
+Intuitive add field form
+
+Instructions
+1. Go to any customer record and open the 'Custom Fields' tab
+2. Click on the 'Add Custom Field' button
+3. Click 'Add an item'
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crm_custom_fields/views/ir_model_views.xml b/crm_custom_fields/views/ir_model_views.xml
new file mode 100644
index 000000000..4b7446433
--- /dev/null
+++ b/crm_custom_fields/views/ir_model_views.xml
@@ -0,0 +1,59 @@
+
+
+
+
+
+ Custom CRM fields Form View
+ ir.model
+ 200
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crm_custom_fields/views/res_partner_views.xml b/crm_custom_fields/views/res_partner_views.xml
new file mode 100644
index 000000000..09a670d03
--- /dev/null
+++ b/crm_custom_fields/views/res_partner_views.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Custom CRM fields Form View
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crm_custom_fields/views/sale_config_settings_convert_views.xml b/crm_custom_fields/views/sale_config_settings_convert_views.xml
new file mode 100644
index 000000000..5c7af3a43
--- /dev/null
+++ b/crm_custom_fields/views/sale_config_settings_convert_views.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+ sale.config.settings.convert tree view
+ sale.config.settings.convert
+
+
+
+
+
+
+
+
+
+ Lead to Partner Convert Fields
+ sale.config.settings.convert
+ tree
+
+
+ Build forms using snippets.
+
+ Instructions
+ 1. Go to Settings->HTML Embed Forms->Create Forms
+ 2. Go to your website, hit edit and scroll down to the form area
+ 3. Drag the form snippet into your website and select your form
+
+
+
+
+
+For a demonstration see check out this Youtube Video
+
\ No newline at end of file
diff --git a/html_form_builder/static/src/css/form.css b/html_form_builder/static/src/css/form.css
new file mode 100644
index 000000000..f6e457ff6
--- /dev/null
+++ b/html_form_builder/static/src/css/form.css
@@ -0,0 +1,30 @@
+/* Snippet drag and drop offset */
+#html_field_placeholder {
+margin-top:20px;
+}
+
+/* Snippet drag and drop offset counter balance */
+#html_field_placeholder .hff{
+margin-top:-20px;
+}
+
+/* Add required red star */
+.hff label.required:after {
+ content:" *";
+ color:red;
+}
+
+/* Counter balance Odoo's all bold labels */
+.hff .radio-inline label {
+ font-weight:normal;
+}
+
+/* Don't know where the weird margin comes from... */
+.hff .radio:nth-of-type(1) {
+ margin-top: 0px;
+}
+
+/* Remove the first checkbox in the groups top margin so there is roughly 5px between label and checkbox */
+.hff.hff_checkbox_group .checkbox:nth-of-type(1) {
+ margin-top:0px;
+}
\ No newline at end of file
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_binary.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_binary.jpg
new file mode 100644
index 000000000..d52db6f2d
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_binary.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_captcha.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_captcha.jpg
new file mode 100644
index 000000000..81e6b5c01
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_captcha.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox.jpg
new file mode 100644
index 000000000..166747407
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox_group.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox_group.jpg
new file mode 100644
index 000000000..a9bd5c582
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_checkbox_group.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_date_picker.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_date_picker.jpg
new file mode 100644
index 000000000..5c94f1a1f
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_date_picker.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_datetime_picker.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_datetime_picker.jpg
new file mode 100644
index 000000000..73ffc7c53
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_datetime_picker.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_dropbox.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_dropbox.jpg
new file mode 100644
index 000000000..c7f971559
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_dropbox.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_input_group.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_input_group.jpg
new file mode 100644
index 000000000..e154b1ba8
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_input_group.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_radio_group.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_radio_group.jpg
new file mode 100644
index 000000000..25b45bcd5
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_radio_group.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_textarea.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_textarea.jpg
new file mode 100644
index 000000000..84c510542
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_textarea.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_field_textbox.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_field_textbox.jpg
new file mode 100644
index 000000000..3748c1472
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_field_textbox.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_form.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_form.jpg
new file mode 100644
index 000000000..b23ae2bd8
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_form.jpg differ
diff --git a/html_form_builder/static/src/img/ui/snippet_thumb_form_new.jpg b/html_form_builder/static/src/img/ui/snippet_thumb_form_new.jpg
new file mode 100644
index 000000000..f7f3c385b
Binary files /dev/null and b/html_form_builder/static/src/img/ui/snippet_thumb_form_new.jpg differ
diff --git a/html_form_builder/static/src/js/html.form.builder.js b/html_form_builder/static/src/js/html.form.builder.js
new file mode 100644
index 000000000..46233602f
--- /dev/null
+++ b/html_form_builder/static/src/js/html.form.builder.js
@@ -0,0 +1,157 @@
+odoo.define('html_form_builder.front', function (require) {
+'use strict';
+
+var ajax = require('web.ajax');
+
+$(function() {
+
+ //Load the token via javascript since Odoo has issue with request.csrf_token() inside snippets
+ $(".html_form input[name='csrf_token']").val(odoo.csrf_token);
+
+ $( ".html_form button.btn-lg" ).click(function(e) {
+
+ e.preventDefault(); // Prevent the default submit behavior
+
+ var my_form = $(this).closest(".html_form form");
+
+ $(".html_form form input").each(function( index ) {
+ pattern = $( this ).attr('pattern');
+ if (typeof pattern !== typeof undefined && pattern !== false) {
+ var pattern = new RegExp( pattern );
+ var valid = pattern.test( $( this ).val() );
+ //Why does this always return true?!?
+ }
+
+ });
+
+
+ // Prepare form inputs
+ var form_data = my_form.serializeArray();
+
+ var form_values = {};
+ _.each(form_data, function(input) {
+ if (input.name in form_values) {
+ // If a value already exists for this field,
+ // we are facing a x2many field, so we store
+ // the values in an array.
+ if (Array.isArray(form_values[input.name])) {
+ form_values[input.name].push(input.value);
+ } else {
+ form_values[input.name] = [form_values[input.name], input.value];
+ }
+ } else {
+ if (input.value != '') {
+ form_values[input.name] = input.value;
+ }
+ }
+ });
+
+ //Input groups are added differently
+ var input_groups = my_form.find(".hff_input_group")
+ input_groups.each(function( index ) {
+ var html_name = $( this ).attr("data-html-name");
+ var input_group_list = [];
+ input_group_list = [];
+ var row_string = "";
+ var row_counter = 0;
+
+ //Go through each row(exlcuding the add button row)
+ var input_group_row = $( this ).find(".row.form-group")
+ input_group_row.each(function( index ) {
+
+ var input_group_row_list = [];
+ input_group_row_list = [];
+ var input_string = "";
+ var row_values = {};
+ row_counter += 1;
+
+ //Go through each input in the row
+ $( this ).find("input").each(function( index ) {
+ var my_key = $( this ).attr('data-sub-field-name');
+ var my_value = $( this ).val();
+
+
+ if ($( this ).attr('type') == "file") {
+ if (my_value != "") {
+
+ $.each($(this).prop('files'), function(index, file) {
+ //Post the file directly since we can't strinify it
+ var post_name = html_name + "_" + row_counter + "_" + my_key;
+ form_values[post_name] = file;
+
+ row_values[my_key] = post_name;
+ });
+
+ }
+ } else {
+ if (my_value != "") {
+ row_values[my_key] = my_value;
+ }
+ }
+ });
+
+ //Go through each selection in the row
+ $( this ).find("select").each(function( index ) {
+ var my_key = $( this ).attr('data-sub-field-name');
+ var my_value = $( this ).val();
+
+ if (my_value != "") {
+ row_values[my_key] = my_value;
+ }
+ });
+
+ if(! jQuery.isEmptyObject(row_values) ) {
+ input_group_list.push(row_values);
+ }
+
+ });
+
+ if (input_group_list.length > 0) {
+ form_values[html_name] = JSON.stringify(input_group_list);
+ }
+
+ });
+
+ //Have to get the files manually
+ _.each(my_form.find('input[type=file]'), function(input) {
+ $.each($(input).prop('files'), function(index, file) {
+ form_values[input.name] = file;
+ });
+ });
+
+ form_values['is_ajax_post'] = "Yes";
+
+ // Post form and handle result
+ ajax.post(my_form.attr('action'), form_values).then(function(result_data) {
+
+ try {
+ result_data = $.parseJSON(result_data);
+ } catch(err) {
+ alert("An error has occured during the submittion of your form data\n" + result_data);
+ }
+
+
+ if (result_data.status == "error") {
+ for (var i = 0; i < result_data.errors.length; i++) {
+ //Find the field and make it displays as an error
+ var input = my_form.find("input[name='" + result_data.errors[i].html_name + "']");
+ var parent_div = input.parent("div");
+
+ //Remove any existing help block
+ parent_div.find(".help-block").remove();
+
+ //Insert help block
+ input.after("" + result_data.errors[i].error_messsage + "");
+ parent_div.addClass("has-error");
+ }
+ } else if (result_data.status == "success") {
+ window.location = result_data.redirect_url;
+ }
+
+ });
+
+ });
+
+});
+
+});
\ No newline at end of file
diff --git a/html_form_builder/static/src/js/html.form.builder.snippets.editor.js b/html_form_builder/static/src/js/html.form.builder.snippets.editor.js
new file mode 100644
index 000000000..be11da174
--- /dev/null
+++ b/html_form_builder/static/src/js/html.form.builder.snippets.editor.js
@@ -0,0 +1,1180 @@
+odoo.define('html_form_builder_snippets.editor', function (require) {
+'use strict';
+
+var base = require('web_editor.base');
+var options = require('web_editor.snippets.options');
+var core = require('web.core');
+var session = require('web.session');
+var website = require('website.website');
+var return_string = ""; //Global because I can't change html in session.rpc function
+var ajax = require('web.ajax');
+var qweb = core.qweb;
+var wUtils = require('website.utils');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+
+ajax.loadXML('/html_form_builder/static/src/xml/html_form_modal30.xml', qweb);
+
+options.registry.html_form_builder = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+
+ rpc.query({
+ model: 'html.form',
+ method: 'name_search',
+ args: [],
+ context: weContext.get()
+ }).then(function(form_ids){
+
+ wUtils.prompt({
+ id: "editor_new_form",
+ window_title: "Existing HTML Form",
+ select: "Select Form",
+ init: function (field) {
+ return form_ids;
+ },
+ }).then(function (form_id) {
+
+ session.rpc('/form/load', {'form_id': form_id}).then(function(result) {
+ self.$target.html(result.html_string);
+ self.$target.attr('data-form-model', result.form_model );
+ self.$target.attr('data-form-id', form_id );
+ });
+ });
+
+ });
+
+ },
+ cleanForSave: function () {
+ //Sometimes gets saved with the token in it
+ $(".html_form input[name='csrf_token']").val("");
+ },
+
+});
+
+options.registry.html_form_builder_new = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ rpc.query({
+ model: 'html.form.snippet.action',
+ method: 'name_search',
+ args: [],
+ context: weContext.get()
+ }).then(function(action_ids){
+
+ wUtils.prompt({
+ id: "editor_new_form_new",
+ window_title: "New HTML Form",
+ select: "Select Action",
+ init: function (field) {
+ return action_ids;
+ },
+ }).then(function (action_id) {
+
+ session.rpc('/form/new', {'action_id': action_id}).then(function(result) {
+ self.$target.html(result.html_string);
+ self.$target.attr('data-form-model', result.form_model );
+ self.$target.attr('data-form-id', result.form_id );
+ //Behaves like a regular form after creation
+ self.$target.attr('class', 'html_form' );
+ });
+ });
+
+ });
+ },
+
+});
+
+// ------------------------ TEXTBOX CONFIG ----------------------
+options.registry.html_form_builder_field_textbox = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.textbox_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlTextboxModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['char','integer','float'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlTextboxModal').modal('show');
+
+ $('body').on('click', '#save_textbox_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var format_validation = self.$modal.find('#html_form_field_format_validation').val();
+ var character_limit = self.$modal.find('#html_form_field_character_limit').val();
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'format_validation': format_validation, 'character_limit': character_limit, 'field_required': field_required }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlTextboxModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ TEXTAREA CONFIG ----------------------
+options.registry.html_form_builder_field_textarea = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.textarea_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlTextareaModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['char','text'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlTextareaModal').modal('show');
+
+ $('body').on('click', '#save_textarea_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlTextareaModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ CHECKBOX GROUP CONFIG ----------------------
+options.registry.html_form_builder_field_checkbox_group = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.checkbox_group_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlCheckboxGroupModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['many2many'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlCheckboxGroupModal').modal('show');
+
+ $('body').on('click', '#save_checkbox_group_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type') }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlCheckboxGroupModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ DROPBOX CONFIG ----------------------
+options.registry.html_form_builder_field_dropbox = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.dropbox_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlDropboxModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['selection','many2one'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlDropboxModal').modal('show');
+
+ $('body').on('click', '#save_dropbox_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlDropboxModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ RADIO GROUP CONFIG ----------------------
+options.registry.html_form_builder_field_radio_group = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.radio_group_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlRadioGroupModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['selection'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlRadioGroupModal').modal('show');
+
+ $('body').on('click', '#save_radio_group_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+ var layout_type = self.$modal.find('#layout_type').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required, 'layout_type': layout_type }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlRadioGroupModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ DATE PICKER CONFIG ----------------------
+options.registry.html_form_builder_field_date_picker = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.date_picker_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlDatePickerModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['date'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlDatePickerModal').modal('show');
+
+ $('body').on('click', '#save_date_picker_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+ var date_format = self.$modal.find('#date_format').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required, 'setting_date_format':date_format }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlDatePickerModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ DATETIME PICKER CONFIG ----------------------
+options.registry.html_form_builder_field_datetime_picker = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.datetime_picker_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlDateTimePickerModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['datetime'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlDateTimePickerModal').modal('show');
+
+ $('body').on('click', '#save_datetime_picker_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required}).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlDateTimePickerModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ CHECKBOX CONFIG ----------------------
+options.registry.html_form_builder_field_checkbox = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.checkbox_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlCheckboxModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['boolean'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlCheckboxModal').modal('show');
+
+ $('body').on('click', '#save_checkbox_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+ var field_required = self.$modal.find('#html_form_field_required').is(':checked');
+ var field_size = self.$modal.find('#field_size').val();
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'field_required': field_required}).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlCheckboxModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ BINARY CONFIG ----------------------
+options.registry.html_form_builder_field_binary = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.binary_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlBinaryModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['binary'];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ //Count the amount of bootstrap columns in the row
+ var current_columns = 0;
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.parent().parent().find(".hff").each(function( index ) {
+ if ($( this ).hasClass("col-md-12")) { current_columns = 12;}
+ if ($( this ).hasClass("col-md-11")) { current_columns = 11;}
+ if ($( this ).hasClass("col-md-10")) { current_columns = 10;}
+ if ($( this ).hasClass("col-md-9")) { current_columns = 9;}
+ if ($( this ).hasClass("col-md-8")) { current_columns = 8;}
+ if ($( this ).hasClass("col-md-7")) { current_columns = 7;}
+ if ($( this ).hasClass("col-md-6")) { current_columns = 6;}
+ if ($( this ).hasClass("col-md-5")) { current_columns = 5;}
+ if ($( this ).hasClass("col-md-4")) { current_columns = 4;}
+ if ($( this ).hasClass("col-md-3")) { current_columns = 3;}
+ if ($( this ).hasClass("col-md-2")) { current_columns = 2;}
+ if ($( this ).hasClass("col-md-1")) { current_columns = 1;}
+ });
+ }
+
+ //Only show sizes that that are less then or equal to the remiaining columns
+ var field_size_html = "";
+ var i = 0;
+ for (i = (12 - current_columns); i > 0; i--) {
+ var number = (i / 12 * 100);
+ field_size_html += "\n"
+ }
+
+ self.$modal.find('#field_size').html(field_size_html);
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlBinaryModal').modal('show');
+
+ $('body').on('click', '#save_binary_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ var field_size = 12;
+ if (field_id != "") {
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type') }).then(function(result) {
+ if (field_size == "12") {
+ self.$target.replaceWith(result.html_string);
+ } else {
+ var header_wrapper = "";
+ var footer_wrapper = "";
+
+ //Create a row if you are the first element in a "row" of fields
+ if (self.$target.parent().attr("id") == "html_fields") {
+ header_wrapper = "
\n";
+ footer_wrapper = "
";
+ }
+
+ //Remove the placeholder div to keep the HTML clean
+ if (self.$target.parent().attr("id") == "html_field_placeholder") {
+ self.$target.unwrap();
+ }
+
+ //Add the current field size otherwise the reminaing wwhile be off
+ current_columns += field_size;
+
+ var remaining_columns = 12 - current_columns;
+
+ if (remaining_columns > 0) {
+ footer_wrapper = "\n" + footer_wrapper;
+ }
+
+ self.$target.replaceWith(header_wrapper + result.html_string.replace("hff ","hff col-md-" + field_size + " ") + footer_wrapper);
+
+
+ }
+ });
+
+ $('#htmlBinaryModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+// ------------------------ INPUT GROUP CONFIG ----------------------
+options.registry.html_form_builder_field_input_group = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+
+ this.template = 'html_form_builder_snippets.input_group_config';
+ self.$modal = $( qweb.render(this.template, {}) );
+
+ //Remove previous instance first
+ $('#htmlInputGroupModal').remove();
+
+ $('body').append(self.$modal);
+ var datatype_dict = ['one2many'];
+ var sub_fields = [];
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+
+ session.rpc('/form/field/config/general', {'data_types':datatype_dict, 'form_model':form_model, 'form_id': form_id}).then(function(result) {
+ self.$modal.find("#field_config_id").html(result.field_options_html);
+ });
+
+ $('#htmlInputGroupModal').modal('show');
+
+ //Onchange the ORM field
+ self.$modal.find('#field_config_id').change(function() {
+
+ session.rpc('/form/field/config/inputgroup', {'field_id': self.$modal.find('#field_config_id').val() }).then(function(result) {
+ self.$modal.find("#sub_fields_div").html(result.field_options_html);
+ });
+
+ });
+
+ $('body').on('click', '#save_input_group_field', function() {
+ var field_id = self.$modal.find('#field_config_id').val();
+ if (field_id != "") {
+
+ self.$modal.find("input[type='checkbox'][name='input_group_fields']:checked").each(function( index ) {
+ sub_fields.push( $( this ).val() );
+ });
+
+ session.rpc('/form/field/add', {'form_id': form_id, 'field_id': field_id, 'html_type': self.$target.attr('data-form-type'), 'sub_fields': sub_fields}).then(function(result) {
+ self.$target.replaceWith(result.html_string);
+ });
+
+ $('#htmlInputGroupModal').modal('hide');
+
+ }
+
+ });
+
+ },
+});
+
+options.registry.html_form_builder_field = options.Class.extend({
+ onBuilt: function() {
+ var self = this;
+ var form_id = this.$target.parents().closest(".html_form").attr('data-form-id')
+ var form_model = this.$target.parents().closest(".html_form").attr('data-form-model')
+
+
+ session.rpc('/form/fieldtype', {'field_type': self.$target.attr('data-form-type') }).then(function(result) {
+ var field_type = result.field_type;
+
+
+ rpc.query({
+ model: 'ir.model.fields',
+ method: 'name_search',
+ args: ['', [["model_id.model", "=", form_model],["ttype", "=", field_type],["name", "!=", "display_name"] ] ],
+ context: weContext.get()
+ }).then(function(field_ids){
+
+ wUtils.prompt({
+ id: "editor_new_field",
+ window_title: "New HTML Field",
+ select: "Select ORM Field",
+ init: function (field) {
+
+ var $group = this.$dialog.find("div.form-group");
+ $group.removeClass("mb0");
+
+ var $add = $(
+ '
');
+ $group.after($add);
+
+ return captcha_ids;
+ },
+ }).then(function (val, captcha_ids, $dialog) {
+
+ var client_key = $dialog.find('input[name="clientKey"]').val();
+ var client_secret = $dialog.find('input[name="clientSecret"]').val();
+
+ session.rpc('/form/captcha/load', {'captcha_id': val, 'form_id': form_id, 'client_key': client_key, 'client_secret':client_secret}).then(function(result) {
+ self.$target.attr('data-captcha-id', val );
+ self.$target.attr('data-captcha-client-key', client_key );
+ self.$target.html(result.html_string);
+ });
+
+ });
+
+ });
+ },
+ cleanForSave: function () {
+
+ var captcha_id = this.$target.attr('data-captcha-id');
+ var captcha_client_key = this.$target.attr('data-captcha-client-key');
+
+ var form_id = $(".html_form_captcha").parents().closest(".html_form").attr('data-form-id');
+
+ //We can't use sync rpc, the page will refresh before it's got the data back, so just use the attribute data-captcha-client-key
+ return_string = "";
+ return_string += "
+
+
+
+
\ No newline at end of file
diff --git a/migration_wordpress/__init__.py b/migration_wordpress/__init__.py
new file mode 100644
index 000000000..511a0ca3a
--- /dev/null
+++ b/migration_wordpress/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import controllers
+from . import models
\ No newline at end of file
diff --git a/migration_wordpress/__manifest__.py b/migration_wordpress/__manifest__.py
new file mode 100644
index 000000000..fce0682f1
--- /dev/null
+++ b/migration_wordpress/__manifest__.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Wordpress Migration",
+ 'version': "1.0.1",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Copy data (pages, media) from wordpress CMS into Odoo",
+ 'description': "Copy data (pages, media) from wordpress CMS into Odoo",
+ 'license':'LGPL-3',
+ 'data': [
+ 'data/res.groups.csv',
+ 'security/ir.model.access.csv',
+ 'views/migration_import_wordpress_views.xml',
+ 'views/menus.xml',
+ ],
+ 'demo': [],
+ 'depends': ['website'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/migration_wordpress/controllers/__init__.py b/migration_wordpress/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/migration_wordpress/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/migration_wordpress/controllers/main.py b/migration_wordpress/controllers/main.py
new file mode 100644
index 000000000..719609608
--- /dev/null
+++ b/migration_wordpress/controllers/main.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import werkzeug
+
+import odoo.http as http
+import odoo
+from odoo.http import request
+
+def binary_content(xmlid=None, model='ir.attachment', id=None, field='datas', unique=False, filename=None, filename_field='datas_fname', download=False, mimetype=None, default_mimetype='application/octet-stream', access_token=None, env=None):
+ return request.registry['ir.http'].binary_content(xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype=default_mimetype, access_token=access_token, env=env)
+
+class ImageResizeHackController(http.Controller):
+
+ def force_contenttype(self, headers, contenttype='image/png'):
+ dictheaders = dict(headers)
+ dictheaders['Content-Type'] = contenttype
+ return list(dictheaders.items())
+
+ @http.route('/web/image2//x/', type="http", auth="public")
+ def content_image(self, xmlid=None, model='ir.attachment', id=None, field='datas', filename_field='datas_fname', unique=None, filename=None, mimetype=None, download=None, width=0, height=0, crop=False, access_token=None):
+
+ status, headers, content = binary_content(xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png', access_token=access_token)
+
+ if status == 304:
+ return werkzeug.wrappers.Response(status=304, headers=headers)
+ elif status == 301:
+ return werkzeug.utils.redirect(content, code=301)
+ elif status != 200 and download:
+ return request.not_found()
+
+ height = int(height or 0)
+ width = int(width or 0)
+
+ if crop and (width or height):
+ content = crop_image(content, type='center', size=(width, height), ratio=(1, 1))
+
+ elif content and (width or height):
+ # resize maximum 500*500
+
+ content = odoo.tools.image_resize_image(base64_source=content, size=(width or None, height or None), encoding='base64', filetype='PNG')
+ # resize force png as filetype
+ headers = self.force_contenttype(headers, contenttype='image/png')
+
+ if content:
+ image_base64 = base64.b64decode(content)
+ else:
+ image_base64 = self.placeholder(image='placeholder.png') # could return (contenttype, content) in master
+ headers = self.force_contenttype(headers, contenttype='image/png')
+
+ headers.append(('Content-Length', len(image_base64)))
+ response = request.make_response(image_base64, headers)
+ response.status_code = status
+ return response
+
+
+
+
+
+
+
+ status, headers, content = binary_content(xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field, download=download, mimetype=mimetype, default_mimetype='image/png')
+ if status == 304:
+ return werkzeug.wrappers.Response(status=304, headers=headers)
+ elif status == 301:
+ return werkzeug.utils.redirect(content, code=301)
+ elif status != 200 and download:
+ return request.not_found()
+
+ if content and (width or height):
+ content = odoo.tools.image_resize_image(base64_source=content, size=(width or None, height or None), encoding='base64', filetype='PNG')
+ # resize force png as filetype
+ headers = self.force_contenttype(headers, contenttype='image/png')
+
+ if content:
+ image_base64 = base64.b64decode(content)
+ else:
+ image_base64 = self.placeholder(image='placeholder.png') # could return (contenttype, content) in master
+ headers = self.force_contenttype(headers, contenttype='image/png')
+
+ headers.append(('Content-Length', len(image_base64)))
+ response = request.make_response(image_base64, headers)
+ response.status_code = status
+ return response
\ No newline at end of file
diff --git a/migration_wordpress/data/res.groups.csv b/migration_wordpress/data/res.groups.csv
new file mode 100644
index 000000000..fd256b46a
--- /dev/null
+++ b/migration_wordpress/data/res.groups.csv
@@ -0,0 +1,2 @@
+"id","name","comment"
+"wordpress_migration_group","Wordpress Migration Manager","Can Import Wordpress websites into the Odoo CMS"
\ No newline at end of file
diff --git a/migration_wordpress/doc/changelog.rst b/migration_wordpress/doc/changelog.rst
new file mode 100644
index 000000000..f24895a85
--- /dev/null
+++ b/migration_wordpress/doc/changelog.rst
@@ -0,0 +1,7 @@
+v1.0.1
+======
+* Fix issue with resized images not rendering
+
+v1.0
+====
+* Port to v11
\ No newline at end of file
diff --git a/migration_wordpress/doc/index.rst b/migration_wordpress/doc/index.rst
new file mode 100644
index 000000000..59e349c62
--- /dev/null
+++ b/migration_wordpress/doc/index.rst
@@ -0,0 +1,18 @@
+Configuration
+1. Go Settings->Users & Companies->Users
+2. Select a user you want to give permission to import Wordpress websites
+3. Tick the "Wordpress Migration Manager" tickbox
+4. Save the record and the new "Migration" top level menu should appear
+
+Import Entire Media Library
+1. Go to Migration top level menu
+2. Create a new record and enter the URL of your Wordpress website (no credentials needed)
+3. Hit "Import Media", this will download all images into the Odoo media library
+*NOTE* Only the orginal image size is transferred
+
+Import Website pages
+1. Go to Migration top level menu
+2. Create a new record and enter the URL of your Wordpress website (no credentials needed)
+3. Hit "Import Pages", this will copy the raw content of the page, transforming any image / link URLs in the process.
+*NOTE* Due to theme styles and javascript not being transferred over, most pages will NOT retain thier original appearance.
+In most cases a redesign will be neccassary however this module still saves time having to manually transfer resources.
\ No newline at end of file
diff --git a/migration_wordpress/models/__init__.py b/migration_wordpress/models/__init__.py
new file mode 100644
index 000000000..43dd600c8
--- /dev/null
+++ b/migration_wordpress/models/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import migration_import_wordpress
+from . import website_page
\ No newline at end of file
diff --git a/migration_wordpress/models/migration_import_wordpress.py b/migration_wordpress/models/migration_import_wordpress.py
new file mode 100644
index 000000000..a1f207f02
--- /dev/null
+++ b/migration_wordpress/models/migration_import_wordpress.py
@@ -0,0 +1,196 @@
+# -*- coding: utf-8 -*-
+
+import requests
+import logging
+_logger = logging.getLogger(__name__)
+import json
+import base64
+from lxml import html, etree
+
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError, UserError
+from odoo.http import request
+
+class MigrationImportWordpress(models.Model):
+
+ _name = "migration.import.wordpress"
+ _rec_name = "wordpress_url"
+
+ wordpress_url = fields.Char(string="Wordpress URL")
+ wordpress_page_ids = fields.One2many('website.page', 'wordpress_id', string="Wordpress Pages")
+ wordpress_imported_media = fields.Many2many('ir.attachment', string="Imported Media")
+ wordpress_imported_user_ids = fields.Many2many('res.users', string="Imported Users")
+
+ def transfer_user(self, user_json):
+ """ For now this is only used by the blog so we can credit the original author """
+
+ external_identifier = "import_user_" + str(user_json['id'])
+
+ #Create an external ID so we don't reimport the same user again
+ wordpress_user = self.env['ir.model.data'].xmlid_to_object('wordpress_import.' + external_identifier)
+ if wordpress_user:
+ #For now we don't reimport the users
+ _logger.error("User already exists")
+ else:
+ #Since we don't seem to get the username, email or password from the API we just create a stub user
+ wordpress_user = self.env['res.users'].create({'login': 'wordpress_' + str(user_json['id']), 'notify_email': 'none', 'email': 'wordpress_' + str(user_json['id']) + "@example.fake.au", 'name': user_json['name'], 'active':True})
+
+ #We need to keep track of any imported users
+ self.wordpress_imported_user_ids = [(4,wordpress_user.id)]
+
+ self.env['ir.model.data'].create({'module': "wordpress_import", 'name': external_identifier, 'model': 'res.users', 'res_id': wordpress_user.id })
+
+ return wordpress_user
+
+ def transfer_media(self, media_json):
+ """ Media can be imported from many places such as when importing pages, media library, blog posts or posts of any type """
+
+ if 'code' in media_json:
+ #Access denied so can not import image
+ return False
+
+ url = media_json['guid']['rendered']
+
+ filename = url.split('/')[-1]
+
+ external_identifier = "import_media_" + str(media_json['id'])
+
+ #Create an external ID so we don't reimport the same media again
+ media_attachment = self.env['ir.model.data'].xmlid_to_object('wordpress_import.' + external_identifier)
+ if media_attachment:
+ #For now we don't reimport media to conserve bandwidth and speed up reimports
+ media_attachment.name = filename
+ else:
+ #Download the image and creat a public attachment
+ image_data = base64.b64encode( requests.get(url).content )
+ media_attachment = self.env['ir.attachment'].create({'name':filename, 'type':'binary', 'datas':image_data, 'datas_fname': filename, 'res_model': 'ir.ui.view', 'public': True})
+
+ #We need to keep track of any imported media
+ self.wordpress_imported_media = [(4,media_attachment.id)]
+
+ self.env['ir.model.data'].create({'module': "wordpress_import", 'name': external_identifier, 'model': 'ir.attachment', 'res_id': media_attachment.id })
+
+ return media_attachment
+
+ def pagination_requests(self, url):
+ """Repeats the request multiple time until it has all pages"""
+
+ response_string = requests.get(url + "?per_page=100&page=1")
+ combined_json_data = json.loads(response_string.text)
+
+ if "X-WP-TotalPages" in response_string.headers:
+ total_pages = int(response_string.headers['X-WP-TotalPages'])
+
+ if total_pages > 1:
+ for page in range(2, total_pages + 1 ):
+ response_string = requests.get(url + "?per_page=100&page=" + str(page) )
+ combined_json_data = combined_json_data + json.loads(response_string.text)
+
+ return combined_json_data
+
+ def transform_post_content(self, content, media_json_data):
+ """ Changes Wordpress content of any post type(page, blog, custom) to better fit in with the Odoo CMS, includes localising hyperlinks and media """
+
+ root = html.fromstring(content)
+ image_tags = root.xpath("//img")
+ if len(image_tags) != 0:
+ for image_tag in image_tags:
+
+ media_attachment = False
+
+ #Get the full size image by looping through all media until you find the one with this url
+ for media_json in media_json_data:
+ if 'sizes' in media_json['media_details']:
+ for key, value in media_json['media_details']['sizes'].items():
+ if value['source_url'] == image_tag.attrib['src'] or value['source_url'] == image_tag.attrib['src'].replace("/",'\/'):
+ media_attachment = self.transfer_media(media_json)
+ else:
+ if media_json['guid']['rendered'] == image_tag.attrib['src'] or media_json['guid']['rendered'] == image_tag.attrib['src'].replace("/",'\/'):
+ media_attachment = self.transfer_media(media_json)
+
+ if media_attachment:
+ if "width" in image_tag.attrib and "height" in image_tag.attrib:
+ image_tag.attrib['src'] = "/web/image2/" + str(media_attachment.id) + "/" + image_tag.attrib['width'] + "x" + image_tag.attrib['height'] + "/" + str(media_attachment.name)
+ else:
+ image_tag.attrib['src'] = "/web/image/" + str(media_attachment.id)
+
+ #Reimplement image resposiveness the Odoo way
+ if "class" in image_tag.attrib:
+ image_tag.attrib['class'] = "img-responsive " + image_tag.attrib['class']
+ else:
+ image_tag.attrib['class'] = "img-responsive"
+
+ #This gets moved into the src
+ if "width" in image_tag.attrib:
+ image_tag.attrib.pop("width")
+
+ if "height" in image_tag.attrib:
+ image_tag.attrib.pop("height")
+
+ #We only import the original image, not all size variants so this is meaningless
+ if "srcset" in image_tag.attrib:
+ image_tag.attrib.pop("srcset")
+
+ if "sizes" in image_tag.attrib:
+ image_tag.attrib.pop("sizes")
+
+ #Modify anchor tags and map pages to the new url
+ anchor_tags = root.xpath("//a")
+ if len(anchor_tags) != 0:
+ for anchor_tag in anchor_tags:
+ #Only modify local links
+ if "href" in anchor_tag.attrib:
+ if self.wordpress_url in anchor_tag.attrib['href']:
+ page_slug = anchor_tag.attrib['href'].split("/")[-2]
+ anchor_tag.attrib['href'] = "/" + page_slug
+
+ transformed_content = etree.tostring(root, encoding='unicode')
+
+ return transformed_content
+
+ def import_media(self):
+
+ media_json_data = self.pagination_requests(self.wordpress_url + "/wp-json/wp/v2/media")
+
+ for media_json in media_json_data:
+ self.transfer_media(media_json)
+
+ def import_pages(self):
+
+ page_json_data = self.pagination_requests(self.wordpress_url + "/wp-json/wp/v2/pages")
+
+ #Also get media since we will be importing the images in the post
+ media_json_data = self.pagination_requests(self.wordpress_url + "/wp-json/wp/v2/media")
+
+ for page_json in page_json_data:
+
+ title = page_json['title']['rendered']
+ slug = page_json['slug']
+ content = page_json['content']['rendered']
+
+ wraped_content = ""
+ wraped_content += "\n"
+ wraped_content += " \n"
+ wraped_content += " \n"
+ wraped_content += "
\n"
+ wraped_content += " \n"
+ wraped_content += ""
+
+ transformed_content = "\n" + self.transform_post_content(wraped_content, media_json_data)
+ external_identifier = "import_post_" + str(page_json['id'])
+
+ #Create an external ID so we don't reimport the same page again
+ page_view = self.env['ir.model.data'].xmlid_to_object('wordpress_import.' + external_identifier)
+ if page_view:
+ #If the page has already been all we do is update it
+ page_view.arch_base = transformed_content
+ else:
+
+ #Create the view first + external ID
+ new_view = self.env['ir.ui.view'].create({'name':slug, 'key':'website.' + slug, 'type': 'qweb', 'arch': transformed_content})
+ self.env['ir.model.data'].create({'module': "wordpress_import", 'name': external_identifier, 'model': 'ir.ui.view', 'res_id': new_view.id })
+
+ #Now we create the page
+ self.env['website.page'].create({'wordpress_id': self.id, 'name': title, 'view_id': new_view.id, 'url': '/' + slug})
\ No newline at end of file
diff --git a/migration_wordpress/models/website_page.py b/migration_wordpress/models/website_page.py
new file mode 100644
index 000000000..ce6e6275b
--- /dev/null
+++ b/migration_wordpress/models/website_page.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class WebsitePageMigrationWordpress(models.Model):
+
+ _inherit = "website.page"
+
+ wordpress_id = fields.Many2one('migration.import.wordpress', string="Wordpress Import")
\ No newline at end of file
diff --git a/migration_wordpress/security/ir.model.access.csv b/migration_wordpress/security/ir.model.access.csv
new file mode 100644
index 000000000..00f5f4974
--- /dev/null
+++ b/migration_wordpress/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_migration_import_wordpress","access migration.import.wordpress","model_migration_import_wordpress","wordpress_migration_group",1,1,1,1
\ No newline at end of file
diff --git a/migration_wordpress/static/description/1.jpg b/migration_wordpress/static/description/1.jpg
new file mode 100644
index 000000000..e88d5cbbe
Binary files /dev/null and b/migration_wordpress/static/description/1.jpg differ
diff --git a/migration_wordpress/static/description/index.html b/migration_wordpress/static/description/index.html
new file mode 100644
index 000000000..7dc927d57
--- /dev/null
+++ b/migration_wordpress/static/description/index.html
@@ -0,0 +1,15 @@
+
+
Description
+
Copies content from Wordpress CMS into Odoo
+
*NOTE* Uses version 2 of the REST API which requires that the Wordpress website you are importing is atleast 4.7
+
*NOTE* Theme HTML, CSS and plugin functionality does not get moved over this can result in very different appearences between the two websites
+
*NOTE* Certain security plugins can block the REST API e.g. (Wordfence Security, etc)
+
+
+
Import Media Library
+
Copies all media from the Wordpress media library into the Odoo media library
+
+
Import Pages
+
Copies the page content from Wordpress, importing images in the process and updating hyperlinks to there Odoo equivalent
+
Plugins and CSS do not get moved over so some degree of reworking the content for Odoo is still neccassary
+
\ No newline at end of file
diff --git a/migration_wordpress/views/menus.xml b/migration_wordpress/views/menus.xml
new file mode 100644
index 000000000..56307e405
--- /dev/null
+++ b/migration_wordpress/views/menus.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/migration_wordpress/views/migration_import_wordpress_views.xml b/migration_wordpress/views/migration_import_wordpress_views.xml
new file mode 100644
index 000000000..1d7a775f4
--- /dev/null
+++ b/migration_wordpress/views/migration_import_wordpress_views.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+ migration.import.wordpress form view
+ migration.import.wordpress
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ migration.import.wordpress tree view
+ migration.import.wordpress
+
+
+
+
+
+
+
+
+ Wordpress Import
+ migration.import.wordpress
+ form
+ tree,form
+
+
+
+
\ No newline at end of file
diff --git a/migration_wordpress_blog/__init__.py b/migration_wordpress_blog/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/migration_wordpress_blog/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/migration_wordpress_blog/__manifest__.py b/migration_wordpress_blog/__manifest__.py
new file mode 100644
index 000000000..b5b9cdfd5
--- /dev/null
+++ b/migration_wordpress_blog/__manifest__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Wordpress Migration - Blog Posts",
+ 'version': "1.0.2",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Copies Wordpress blog posts and comments into Odoo",
+ 'description': "Copies Wordpress blog posts and comments into Odoo",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/migration_import_wordpress_views.xml',
+ ],
+ 'demo': [],
+ 'depends': ['migration_wordpress', 'website_blog'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/migration_wordpress_blog/doc/changelog.rst b/migration_wordpress_blog/doc/changelog.rst
new file mode 100644
index 000000000..044ec50df
--- /dev/null
+++ b/migration_wordpress_blog/doc/changelog.rst
@@ -0,0 +1,11 @@
+v1.0.2
+======
+* Fix import user issue introduced in previous version
+
+v1.0.1
+======
+* Transfer over cover image( featured image )
+
+v1.0
+====
+* Port to v11
\ No newline at end of file
diff --git a/migration_wordpress_blog/doc/index.rst b/migration_wordpress_blog/doc/index.rst
new file mode 100644
index 000000000..71e1b546b
--- /dev/null
+++ b/migration_wordpress_blog/doc/index.rst
@@ -0,0 +1,6 @@
+Import Blog Posts
+1. Go to Migration top level menu
+2. Create a new record and enter the URL of your Wordpress website (no credentials needed)
+3. Hit "Import Blog Posts", this will copy the raw content of the page, transforming any image / link URLs in the process.
+*NOTE* Due to theme styles and javascript not being transferred over, most pages will NOT retain thier original appearance.
+In most cases a redesign will be neccassary however this module still saves time having to manually transfer resources.
\ No newline at end of file
diff --git a/migration_wordpress_blog/models/__init__.py b/migration_wordpress_blog/models/__init__.py
new file mode 100644
index 000000000..2e494a444
--- /dev/null
+++ b/migration_wordpress_blog/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import migration_import_wordpress
\ No newline at end of file
diff --git a/migration_wordpress_blog/models/migration_import_wordpress.py b/migration_wordpress_blog/models/migration_import_wordpress.py
new file mode 100644
index 000000000..a35fd1f03
--- /dev/null
+++ b/migration_wordpress_blog/models/migration_import_wordpress.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+
+import requests
+import logging
+_logger = logging.getLogger(__name__)
+import json
+import html
+
+from odoo import api, fields, models
+
+class MigrationImportWordpressBlog(models.Model):
+
+ _inherit = "migration.import.wordpress"
+
+ blog_post_ids = fields.Many2many('blog.post', string="Imported Blog Posts")
+
+ def import_posts(self):
+ _logger.error("Import Posts")
+
+ #Get Posts
+ blog_json_data = self.pagination_requests(self.wordpress_url + "/wp-json/wp/v2/posts")
+
+ #Also get media since we will be importing the images in the post
+ media_json_data = self.pagination_requests(self.wordpress_url + "/wp-json/wp/v2/media")
+
+ #Get Posts
+ tax_response_string = requests.get(self.wordpress_url + "/wp-json/wp/v2/posts")
+ tax_json_data = json.loads(tax_response_string.text)
+
+ for blog_json in blog_json_data:
+ title = html.unescape(blog_json['title']['rendered'])
+
+ slug = blog_json['slug']
+
+ content = blog_json['content']['rendered']
+ status = blog_json['status']
+
+ feature_media_id = blog_json['featured_media']
+
+ featured_image = False
+ if feature_media_id != "0":
+ response_string = requests.get(self.wordpress_url + "/wp-json/wp/v2/media/" + str(feature_media_id) )
+ featured_image_json_data = json.loads(response_string.text)
+ featured_image = self.transfer_media( featured_image_json_data )
+
+ wraped_content = ""
+ wraped_content += "
"
+
+ transformed_content = self.transform_post_content(wraped_content, media_json_data)
+
+ #Translate Wordpress published status to the Odoo one
+ published = False
+ if status == "publish":
+ published = True
+
+ external_identifier = "import_post_" + str(blog_json['id'])
+
+ #Create an external ID so we don't reimport the same post again
+ blog_post = self.env['ir.model.data'].xmlid_to_object('wordpress_import.' + external_identifier)
+ if blog_post:
+ #Update the blog post
+ blog_post.content = transformed_content
+
+ if featured_image:
+ blog_post.cover_properties = '{"background-image": "url(/web/image/' + str(featured_image.id) + ')", "resize_class": "cover container-fluid cover_full", "background-color": "oe_black", "opacity": 0.2}'
+ else:
+ #We also get the Wordpress user and import it if neccassary
+ wordpress_user = self.env['ir.model.data'].xmlid_to_object('wordpress_import.import_user_' + str(blog_json['author']) )
+
+ if wordpress_user is None:
+ user_response_string = requests.get(self.wordpress_url + "/wp-json/wp/v2/users/" + str(blog_json['author']) )
+ user_json = json.loads(user_response_string.text)
+ wordpress_user = self.transfer_user(user_json)
+
+ #Create the blog post if it does not exist
+ blog_post = self.env['blog.post'].sudo(wordpress_user.id).create({'author_id': wordpress_user.partner_id.id, 'write_uid': wordpress_user.id, 'blog_id':1, 'name':title, 'content': transformed_content, 'website_published': published})
+
+ if featured_image:
+ blog_post.cover_properties = '{"background-image": "url(/web/image/' + str(featured_image.id) + ')", "resize_class": "cover container-fluid cover_full", "background-color": "oe_black", "opacity": 0.2}'
+
+ self.env['ir.model.data'].create({'module': "wordpress_import", 'name': external_identifier, 'model': 'blog.post', 'res_id': blog_post.id })
+ self.blog_post_ids = [(4,blog_post.id)]
diff --git a/migration_wordpress_blog/static/description/1.jpg b/migration_wordpress_blog/static/description/1.jpg
new file mode 100644
index 000000000..e88d5cbbe
Binary files /dev/null and b/migration_wordpress_blog/static/description/1.jpg differ
diff --git a/migration_wordpress_blog/static/description/index.html b/migration_wordpress_blog/static/description/index.html
new file mode 100644
index 000000000..6dee1760e
--- /dev/null
+++ b/migration_wordpress_blog/static/description/index.html
@@ -0,0 +1,9 @@
+
+
Description
+
Extends the core module to also import blog posts
+
+
+Import Blog Posts
+
Copies the blog content from Wordpress, importing images in the process and updating hyperlinks to there Odoo equivalent
+
Plugins and CSS do not get moved over so some degree of reworking the content for Odoo is still neccassary
+
\ No newline at end of file
diff --git a/migration_wordpress_blog/views/migration_import_wordpress_views.xml b/migration_wordpress_blog/views/migration_import_wordpress_views.xml
new file mode 100644
index 000000000..3366f47fa
--- /dev/null
+++ b/migration_wordpress_blog/views/migration_import_wordpress_views.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+ migration.import.wordpress form view inherit blog
+ migration.import.wordpress
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/odoo_framework_unwrap/__init__.py b/odoo_framework_unwrap/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/odoo_framework_unwrap/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/odoo_framework_unwrap/__manifest__.py b/odoo_framework_unwrap/__manifest__.py
new file mode 100644
index 000000000..99e600ed1
--- /dev/null
+++ b/odoo_framework_unwrap/__manifest__.py
@@ -0,0 +1,23 @@
+{
+ 'name': "Odoo Framework Unwrap",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Converts an Odoo module into non framework code",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/module_convert_views.xml',
+ 'views/menus.xml',
+ 'data/module.convert.database.csv',
+ 'data/module.convert.language.csv',
+ 'data/module.convert.connect.csv',
+ ],
+ 'demo': [],
+ 'depends': ['web'],
+ 'external_dependencies' : {
+ 'python' : ['pyodbc'],
+ },
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/odoo_framework_unwrap/data/module.convert.connect.csv b/odoo_framework_unwrap/data/module.convert.connect.csv
new file mode 100644
index 000000000..7a9ae051e
--- /dev/null
+++ b/odoo_framework_unwrap/data/module.convert.connect.csv
@@ -0,0 +1,2 @@
+"id","name","driver","connection_string"
+"mysql","MySQL Standard Connect","MySQL","DRIVER=$driver;Server=$server;Database=$database;Uid=$username;Pwd=$password;Charset=utf8;"
\ No newline at end of file
diff --git a/odoo_framework_unwrap/data/module.convert.database.csv b/odoo_framework_unwrap/data/module.convert.database.csv
new file mode 100644
index 000000000..1045a7c8e
--- /dev/null
+++ b/odoo_framework_unwrap/data/module.convert.database.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"database_mysql","MySQL","mysql"
diff --git a/odoo_framework_unwrap/data/module.convert.language.csv b/odoo_framework_unwrap/data/module.convert.language.csv
new file mode 100644
index 000000000..038448635
--- /dev/null
+++ b/odoo_framework_unwrap/data/module.convert.language.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"language_php","PHP","php"
diff --git a/odoo_framework_unwrap/doc/changelog.rst b/odoo_framework_unwrap/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/odoo_framework_unwrap/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/odoo_framework_unwrap/models/__init__.py b/odoo_framework_unwrap/models/__init__.py
new file mode 100644
index 000000000..3fa0e43dc
--- /dev/null
+++ b/odoo_framework_unwrap/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import module_convert
\ No newline at end of file
diff --git a/odoo_framework_unwrap/models/module_convert.py b/odoo_framework_unwrap/models/module_convert.py
new file mode 100644
index 000000000..23d651ba9
--- /dev/null
+++ b/odoo_framework_unwrap/models/module_convert.py
@@ -0,0 +1,128 @@
+# -*- coding: utf-8 -*
+
+import pyodbc
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo import api, fields, models
+from odoo.exceptions import UserError
+
+class ModuleConvert(models.Model):
+
+ _name = "module.convert"
+ _description = "Module Convert"
+
+ module_id = fields.Many2one('ir.module.module', string="Module")
+ database_id = fields.Many2one('module.convert.database', string="Database")
+ language_id = fields.Many2one('module.convert.language', string="Language")
+ mode = fields.Selection([('overwrite','Overwrite')], default="overwrite", string="Mode")
+ dsn_id = fields.Many2one('module.convert.dsn', string="Driver")
+ connection_string_template_id = fields.Many2one('module.convert.connect', string="Connection Template")
+ connection_string = fields.Char(string="Connection String", help="See https://www.connectionstrings.com if you want a connection string other then MySQL")
+ connect_server = fields.Char(string="Server")
+ connect_database = fields.Char(string="Database")
+ connect_username = fields.Char(string="Username")
+ connect_password = fields.Char(string="Password")
+
+ @api.onchange('connection_string_template_id','dsn_id','connect_server','connect_database','connect_username','connect_password')
+ def _onchange_dsn_id(self):
+ if self.dsn_id and self.connection_string_template_id.connection_string:
+ cs = self.connection_string_template_id.connection_string
+
+ cs = cs.replace("$driver", self.dsn_id.driver)
+
+ if self.connect_server:
+ cs = cs.replace("$server", self.connect_server)
+
+ if self.connect_database:
+ cs = cs.replace("$database", self.connect_database)
+
+ if self.connect_username:
+ cs = cs.replace("$username", self.connect_username)
+
+ if self.connect_password:
+ cs = cs.replace("$password", self.connect_password)
+
+ self.connection_string = cs
+
+ def check_data_sources(self):
+ sources = pyodbc.dataSources()
+ dsns = sources.keys()
+ for dsn in dsns:
+ if self.env['module.convert.dsn'].search_count([('name','=',dsn)]) == 0:
+ self.env['module.convert.dsn'].create({'name': dsn, 'driver': sources[dsn]})
+
+ def test_connection(self):
+ conn = pyodbc.connect(self.connection_string)
+ conn.close()
+
+ #pyodbc will provide most of the error reporting so if you got here I can assume everything went well
+ raise UserError("Connection Successful")
+
+ def convert_models(self):
+
+ conn = pyodbc.connect(self.connection_string)
+ cursor = conn.cursor()
+
+ # Only convert models that are exclusively owned by the selected module (modules field is not searchable)
+ for model_data in self.env['ir.model.data'].search([('module', '=', self.module_id.name), ('model', '=', 'ir.model')]):
+ model = self.env['ir.model'].browse(model_data.res_id)
+ if model.modules == self.module_id.name:
+
+ if self.mode == "overwrite":
+ cursor.execute("DROP TABLE IF EXISTS `" + model.model.replace(".","_") + "`;")
+ sql = self.env['module.convert.database.' + self.database_id.internal_name].convert_model(model, self.module_id.name)
+ cursor.execute(sql)
+
+class ModuleConvertDSN(models.Model):
+
+ _name = "module.convert.dsn"
+
+ name = fields.Char(string="Name")
+ driver = fields.Char(string="Driver")
+ connection_string = fields.Char(string="Connect String")
+
+class ModuleConvertConnect(models.Model):
+
+ _name = "module.convert.connect"
+
+ name = fields.Char(string="Name")
+ driver = fields.Char(string="Driver")
+ connection_string = fields.Char(string="Connect String")
+
+class ModuleConvertDatabase(models.Model):
+
+ _name = "module.convert.database"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Internal Name")
+
+class ModuleConvertDatabaseMySQL(models.Model):
+
+ _name = "module.convert.database.mysql"
+
+ def convert_model(self, model, module_name):
+ sql = ""
+ table_name = model.model.replace(".","_")
+
+ sql += "CREATE TABLE `" + table_name + "` ("
+
+ # Only convert fields that were created by the selected module
+ for field in self.env['ir.model.fields'].search([('model_id', '=', model.id)]):
+ if module_name in field.modules.split(","):
+ if field.name == "id":
+ sql += "id INT(11) UNSIGNED AUTO_INCREMENT PRIMARY KEY,"
+ elif field.ttype == "char":
+ #psql varchar to mySQl varchar
+ sql += "`" + field.name + "` varchar(255) NOT NULL,"
+
+ sql = sql[:-1]
+ sql += ") ENGINE=InnoDB DEFAULT CHARSET=utf8;"
+ return sql
+
+class ModuleConvertLanguage(models.Model):
+
+ _name = "module.convert.language"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Internal Name")
\ No newline at end of file
diff --git a/odoo_framework_unwrap/static/description/index.html b/odoo_framework_unwrap/static/description/index.html
new file mode 100644
index 000000000..8d0023f3b
--- /dev/null
+++ b/odoo_framework_unwrap/static/description/index.html
@@ -0,0 +1,50 @@
+
+
Description
+Converts an Odoo module into non framework code such as PHP and MySQL
+*IMPORTANT* This module is designed for developers, do not use it to convert Odoo module or other community modules without permission
+*IMPORTANT* The module only converts content within the selected module and skips content that inherits other modules
+
+What this modules currently does
+
+
Connects to MySQL database via odbc
+
Creates MySQL tables from models created by the selected module
+
Converts Odoo char fields to MySQL varchar datatype
Extract and copy files under lib folder to /usr/lib/x86_64-linux-gnu/odbc/
+
Edit /etc/odbcinst.ini file and specify driver path
+
+MySQL 5 example
+
+[MySQL]
+Description = ODBC for MySQL
+Driver = /usr/lib/x86_64-linux-gnu/odbc/libmyodbc5a.so
+Setup = /usr/lib/x86_64-linux-gnu/odbc/libodbcmyS.so
+FileUsage = 1
+
+
+
Edit the /etc/odbc.ini file, actual connection details are provided inside Odoo
+
+
+[my-connector]
+Description = MySQL connection to database
+Driver = MySQL
+
+
+
In Odoo hit the "Check Data Sources" button so the newly setup driver can be selected
+
Finally hit the "Test Connection" button to make sure everything works, you may need to specify that the MySQL user can connect from the Odoo server host IP
+
+
\ No newline at end of file
diff --git a/odoo_framework_unwrap/views/menus.xml b/odoo_framework_unwrap/views/menus.xml
new file mode 100644
index 000000000..7062d3102
--- /dev/null
+++ b/odoo_framework_unwrap/views/menus.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/odoo_framework_unwrap/views/module_convert_views.xml b/odoo_framework_unwrap/views/module_convert_views.xml
new file mode 100644
index 000000000..ae6aa5a42
--- /dev/null
+++ b/odoo_framework_unwrap/views/module_convert_views.xml
@@ -0,0 +1,51 @@
+
+
+
+
+ module.convert view form
+ module.convert
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ See https://www.connectionstrings.com if you want to manually create your own connection string
+
+
+
+
+
+ module.convert view tree
+ module.convert
+
+
+
+
+
+
+
+
+
+
+ Module Convert
+ module.convert
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/__init__.py b/sem_seo_toolkit/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/sem_seo_toolkit/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/sem_seo_toolkit/__manifest__.py b/sem_seo_toolkit/__manifest__.py
new file mode 100644
index 000000000..f9d9bfc7a
--- /dev/null
+++ b/sem_seo_toolkit/__manifest__.py
@@ -0,0 +1,42 @@
+{
+ 'name': "Search Engine Marketing Toolkit",
+ 'version': "1.1.3",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Set of tools to help with Search Engine Marketing / Optimisation",
+ 'description': "Set of tools to help with Search Engine Marketing / Optimisation",
+ 'license':'LGPL-3',
+ 'data': [
+ 'data/sem.depend.xml',
+ 'data/sem.check.category.xml',
+ 'data/sem.metric.xml',
+ 'data/sem.check.xml',
+ 'data/sem.search.engine.xml',
+ 'data/res.groups.xml',
+ 'data/sem.search.device.xml',
+ 'views/sem_client_website_page_views.xml',
+ 'views/sem_client_website_views.xml',
+ 'views/sem_client_listing_views.xml',
+ 'views/sem_client_views.xml',
+ 'views/sem_metric_views.xml',
+ 'views/sem_check_views.xml',
+ 'views/sem_check_category_views.xml',
+ 'views/sem_report_website_page_views.xml',
+ 'views/sem_report_website_views.xml',
+ 'views/sem_report_competition_views.xml',
+ 'views/sem_report_search_views.xml',
+ 'views/sem_report_ranking_views.xml',
+ 'views/sem_report_google_business_views.xml',
+ 'views/sem_search_context_views.xml',
+ 'views/sem_search_context_wizard_views.xml',
+ 'views/sem_search_device_views.xml',
+ 'views/sem_search_results_views.xml',
+ 'views/sem_track_views.xml',
+ 'views/sem_settings_views.xml',
+ 'views/menus.xml',
+ 'views/sem_seo_toolkit_templates.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'depends': ['google_account'],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/sem_seo_toolkit/controllers/__init__.py b/sem_seo_toolkit/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/sem_seo_toolkit/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/sem_seo_toolkit/controllers/main.py b/sem_seo_toolkit/controllers/main.py
new file mode 100644
index 000000000..9a7ba1b01
--- /dev/null
+++ b/sem_seo_toolkit/controllers/main.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+
+import werkzeug
+import json
+import logging
+_logger = logging.getLogger(__name__)
+
+import odoo
+from odoo.http import request
+import odoo.http as http
+from odoo.tools.misc import file_open
+
+class SemSeoTollkitController(http.Controller):
+
+ @http.route('/sem/tracking.js', type='http', auth="public")
+ def sem_tracking(self, **kw):
+ return http.Response(
+ werkzeug.wsgi.wrap_file(
+ request.httprequest.environ,
+ file_open('sem_seo_toolkit/static/src/js/track.js', 'rb')
+ ),
+ content_type='application/javascript; charset=utf-8',
+ )
+
+ @http.route('/sem/track', type='http', auth="public", csrf=False, cors="*")
+ def sem_track(self, *kw):
+ json_data = json.loads(request.httprequest.data.decode())
+ request.env['sem.track'].create({'session_id': json_data['session_id'], 'user_agent': request.httprequest.user_agent, 'start_url': request.httprequest.referrer, 'referrer': json_data['document_referrer'], 'ip': request.httprequest.headers.environ['REMOTE_ADDR']})
+ return ""
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/res.groups.xml b/sem_seo_toolkit/data/res.groups.xml
new file mode 100644
index 000000000..5cfbf9784
--- /dev/null
+++ b/sem_seo_toolkit/data/res.groups.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Search Engine Marketer
+ Can generate and view SEM reports
+
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.check.category.xml b/sem_seo_toolkit/data/sem.check.category.xml
new file mode 100644
index 000000000..6dbda9f37
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.check.category.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+ Content Quality
+
+
+
+ Performance
+
+
+
+ Security
+
+
+
+ Visibility
+
+
+
+ Misc
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.check.xml b/sem_seo_toolkit/data/sem.check.xml
new file mode 100644
index 000000000..99074d499
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.check.xml
@@ -0,0 +1,158 @@
+
+
+
+
+
+
+
+ Title Exists
+ title_exists
+ Checks if a HTML title tag exists.
+Check fails if zero or more then one title tags are found or title tag is empty.
+
+ page
+ 1
+
+
+
+ Has Meta Description
+ has_meta_description
+ Checks if a HTML meta description tag exists.
+Check fails if zero or more then one meta description tags are found or meta description is empty.
+
+ page
+ 1
+
+
+
+ Has Canonical Tag
+ has_canonical_tag
+ Checks if a HTML canonical tag exists.
+Check fails if zero or more then one canonical tags are found or canonical tag is empty.
+ page
+ 1
+
+
+
+ Valid Links
+ valid_links
+ Checks all anchor tags to see if the href returns HTTP 200.
+Check fails if any link does not return a HTTP 200 including 3** redirects.
+
+ page
+ 1
+
+
+
+ Valid Images
+ valid_images
+ Checks all img tags to see if the src returns HTTP 200.
+Check fails if any img does not return a HTTP 200 or does not have a src attribute.
+
+ page
+ 1
+
+
+
+ Images Have alt Attribute
+ images_have_alt
+ Checks all img tags to see if they have an alt attribute.
+Check fails if any img does not have an alt attribute or the alt is empty.
+
+ page
+ 1
+
+
+
+
+
+ Page Load Time
+ page_load_time
+ Simulates a page load using Google Chrome and checks if the domComplete time is less then 2 seconds from navigationStart event.
+Check fails if the time difference is more then 2 seconds.
+
+ page
+
+ 1
+
+
+
+ Non Optimised Images
+ non_optimised_images
+ Checks all images to see if there is a difference between natural size vs display size, e.g. natural 800x800 pixel image styled to display as 400x400 image.
+Check fails if any image has been shrinked of streched via css.
+
+ page
+
+ 1
+
+
+
+
+
+ HTTPS
+ https
+ Checks if supplied URL after redirects starts with https://.
+Check fails if page starts with any other protocol mainly http://.
+
+ page
+ 1
+
+
+
+ Mixed Content
+ mixed_content
+ Checks all images and links to see if they use http when website is https.
+Check fails if any image or link is using http:// on a https:// website.
+
+ page
+ 1
+
+
+
+
+
+ Sitemap Exists
+ sitemap_exists
+ Checks if the file sitemap.xml or sitemap_index.xml exists (does not validate).
+Check fails if neither file exist.
+
+ domain
+ 1
+
+
+
+ Indexing Allowed
+ index_allowed
+ Checks see if if a noindex exists for any web crawlers.
+Check fails if any noindex meta tag exists.
+
+ page
+ 1
+
+
+
+ Google Domain Indexed
+ google_domain_indexed
+ Checks if a Google search for site:[domain] returns any results
+Check fails if results are zero.
+
+ domain
+
+ 1
+
+
+
+
+
+ Google Analytics Installed
+ google_analytics
+ Checks for any of the Google Analytics .js files are referenced in the source code.
+Check fails if none are found.
+
+ page
+ 1
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.depend.xml b/sem_seo_toolkit/data/sem.depend.xml
new file mode 100644
index 000000000..23e2fa483
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.depend.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Selenium / Google Chrome
+
+
+
+ Google CSE
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.metric.xml b/sem_seo_toolkit/data/sem.metric.xml
new file mode 100644
index 000000000..8ab85f385
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.metric.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Page Load Time
+ page_load_time
+ Simulates a page load using Google Chrome and returns the time between navigationStart and domComplete events.
+
+ 1
+
+
+
+ HTTP Requests
+ http_requests
+ Number of Files that are loaded
+
+ 1
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.search.device.xml b/sem_seo_toolkit/data/sem.search.device.xml
new file mode 100644
index 000000000..ab12d80b3
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.search.device.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Desktop
+ Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/47.0.2526.111 Safari/537.36
+
+
+
+ Tablet
+ Mozilla/5.0+(iPad;+CPU+OS+7_0+like+Mac+OS+X)+AppleWebKit/537.51.1+(KHTML,+like+Gecko)+Version/7.0+Mobile/11A465+Safari/9537.53
+
+
+
+ Mobile
+ Mozilla/5.0+(iPhone;+CPU+iPhone+OS+7_0+like+Mac+OS+X)+AppleWebKit/537.51.1+(KHTML,+like+Gecko)+Version/7.0+Mobile/11A465+Safari/9537.53
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/data/sem.search.engine.xml b/sem_seo_toolkit/data/sem.search.engine.xml
new file mode 100644
index 000000000..bad4df50f
--- /dev/null
+++ b/sem_seo_toolkit/data/sem.search.engine.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ Google
+ google
+
+
+
+ Microsoft Bing
+ bing
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/doc/changelog.rst b/sem_seo_toolkit/doc/changelog.rst
new file mode 100644
index 000000000..480212152
--- /dev/null
+++ b/sem_seo_toolkit/doc/changelog.rst
@@ -0,0 +1,48 @@
+v1.1.3
+======
+* Canonical URL check
+
+v1.1.2
+======
+* Bug fix for Google my Business reports when a listing was on multiple accounts.
+* Skip unverified and closed listings from the report
+
+v1.1.1
+======
+* Ranking reports reintroduced
+
+v1.1.0
+======
+* Major database restructure (non back compatible)
+* Move keywords into search context
+* See search results directly from a search context
+* Removal of ranking reports (for now...)
+* Listings and Google My Business reports
+* Create many search contexts at once by entering keyword list, devices and geo targets
+
+v1.0.3
+======
+* Many Bug fixes
+* SEO check an entire site or atleast all pages in the page list and view results in a single report
+* Ranking report pdf
+* Get page list from XML Sitemap
+
+v1.0.2
+======
+* Renamed Geo Targets to Search Context as it now includes device user agent
+* Added ranking report (requires manual rank extraction for Google)
+* Check website button has been moved to check webpage since it doesn't currently recursively go through each page
+* Smart button navigation
+* Added Bing API for geo coding
+
+v1.0.1
+======
+* Many bug fixes
+* Removed competitor reports as they are inaccurate without localising the search results
+* Add metrics as a method to compare against competitors
+* Made link valid check 5 times faster through the use of multithreading
+* Various internal changes in preperation for Bing search intergration
+
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/sem_seo_toolkit/migrations/11.0.1.1.0/pre-migrate.py b/sem_seo_toolkit/migrations/11.0.1.1.0/pre-migrate.py
new file mode 100644
index 000000000..2dfa2ca1a
--- /dev/null
+++ b/sem_seo_toolkit/migrations/11.0.1.1.0/pre-migrate.py
@@ -0,0 +1,2 @@
+def migrate(cr, version):
+ cr.execute('SELECT can, not, upgrade FROM version')
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/__init__.py b/sem_seo_toolkit/models/__init__.py
new file mode 100644
index 000000000..07686eaf6
--- /dev/null
+++ b/sem_seo_toolkit/models/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+
+from . import google_ads
+from . import google_business
+from . import sem_tools
+from . import sem_depend
+from . import sem_client
+from . import sem_client_website_page
+from . import sem_client_listing
+from . import sem_search_engine
+from . import sem_search_context
+from . import sem_search_device
+from . import sem_search_results
+from . import sem_metric
+from . import sem_check
+from . import sem_check_category
+from . import sem_report
+from . import sem_search_engine
+from . import sem_track
+from . import sem_settings
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/google_ads.py b/sem_seo_toolkit/models/google_ads.py
new file mode 100644
index 000000000..2ad53d74f
--- /dev/null
+++ b/sem_seo_toolkit/models/google_ads.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class GoogleAds(models.Model):
+
+ _name = "google.ads"
+ _description = "Google Ads"
+
+ @api.model
+ def set_all_tokens(self, authorization_code):
+ all_token = self.env['google.service']._get_google_token_json(authorization_code, 'ads')
+ self.env['ir.config_parameter'].set_param('google_ads_refresh_token', all_token.get('refresh_token') )
+ self.env['ir.config_parameter'].set_param('google_ads_access_token', all_token.get('access_token') )
+
+ @api.model
+ def need_authorize(self):
+ return self.env['ir.config_parameter'].get_param('google_ads_refresh_token') is False
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/google_business.py b/sem_seo_toolkit/models/google_business.py
new file mode 100644
index 000000000..a11e63243
--- /dev/null
+++ b/sem_seo_toolkit/models/google_business.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class GoogleBusiness(models.Model):
+
+ _name = "google.business"
+ _description = "Google Business"
+
+ @api.model
+ def set_all_tokens(self, authorization_code):
+ all_token = self.env['google.service']._get_google_token_json(authorization_code, 'business')
+ self.env['ir.config_parameter'].set_param('google_business_refresh_token', all_token.get('refresh_token') )
+ self.env['ir.config_parameter'].set_param('google_business_access_token', all_token.get('access_token') )
+
+ @api.model
+ def need_authorize(self):
+ return self.env['ir.config_parameter'].get_param('google_business_refresh_token') is False
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_check.py b/sem_seo_toolkit/models/sem_check.py
new file mode 100644
index 000000000..154a4e5fa
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_check.py
@@ -0,0 +1,429 @@
+# -*- coding: utf-8 -*-
+import sys
+from lxml import html
+from lxml import etree
+import cgi
+import requests
+from urllib.parse import urljoin, urlparse
+import threading
+import logging
+_logger = logging.getLogger(__name__)
+from PIL import Image
+from io import BytesIO
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+except:
+ _logger.error("Selenium not installed")
+
+import json
+import datetime
+import time
+import urllib.request
+
+from odoo import api, fields, models, _
+
+class SemCheck(models.Model):
+
+ _name = "sem.check"
+ _order = "sequence asc"
+
+ sequence = fields.Integer(string="Sequence")
+ name = fields.Char(string="Name")
+ function_name = fields.Char(string="Function Name")
+ description = fields.Text(string="Description")
+ category_id = fields.Many2one('sem.check.category', string="Category")
+ active = fields.Boolean(string="Active")
+ depend_ids = fields.Many2many('sem.depend', string="Dependencies")
+ check_level = fields.Selection([('domain', 'Domain'), ('page', 'Page')], string="Check Level", help="Domain means only executes once\nPage is every page run the check")
+
+ def _seo_check_keyword_in_title(self, driver, url, parsed_html):
+
+ title_tags = parsed_html.xpath("//title")
+
+ if len(title_tags) == 0:
+ #Fail 1: No title tag
+ return (False, _("No title tag detected"))
+
+ if len(title_tags) > 1:
+ #Fail 2: More then one title tag
+ return (False, _("Multiple title tags detected"))
+
+ if len(title_tags) == 1:
+ title_tag = title_tags[0]
+ if keyword.lower() in title_tag.text.lower():
+ #Pass 1: Keyword is substring of title
+ return (True, "")
+ else:
+ #Fail 3: More then one title tag
+ return (False, _("Keyword not in title \"%s\"") % title_tag.text)
+
+ def _seo_check_title_exists(self, driver, url, parsed_html):
+
+ title_tags = parsed_html.xpath("//title")
+
+ if len(title_tags) == 0:
+ #Fail 1: No title tag
+ return (False, _("No title tag detected"))
+
+ if len(title_tags) > 1:
+ #Fail 2: More then one title tag
+ return (False, _("Multiple title tags detected"))
+
+ if len(title_tags) == 1:
+ #Pass 1: Only 1 title tag
+ if len(title_tags[0].text) > 0:
+ return (True, "")
+ else:
+ #Fail 3: Title tag is empty
+ return (False, _("Title tag is empty"))
+
+ def _seo_check_has_meta_description(self, driver, url, parsed_html):
+
+ meta_descriptions = parsed_html.xpath("//meta[@name='description']")
+
+ if len(meta_descriptions) == 0:
+ #Fail 1: No meta description
+ return (False, _("No Meta description detected"))
+
+ if len(meta_descriptions) > 1:
+ #Fail 2: More then one meta description
+ return (False, _("Multiple Meta descriptions detected"))
+
+ if len(meta_descriptions) == 1:
+ #Pass 1: Only 1 meta description
+ if len(meta_descriptions[0].attrib['content']) > 0:
+ return (True, "")
+ else:
+ #Fail 3: Meta description is empty
+ return (False, _("Empty Meta description"))
+
+ def _seo_check_has_canonical_tag(self, driver, url, parsed_html):
+
+ canonical_tag = parsed_html.xpath("//link[@rel='canonical']")
+
+ if len(canonical_tag) == 0:
+ #Fail 1: No canonical tag
+ return (False, _("No Canonical Tag detected"))
+
+ if len(canonical_tag) > 1:
+ #Fail 2: More then one canonical tag
+ return (False, _("Multiple canonical tags detected"))
+
+ if len(canonical_tag) == 1:
+ #Pass 1: Only 1 canonical tag
+ if len(canonical_tag[0].attrib['href']) > 0:
+ return (True, canonical_tag[0].attrib['href'])
+ else:
+ #Fail 3: Canonical Tag is empty
+ return (False, _("Empty canonical tag"))
+
+ def _seo_check_valid_links(self, driver, url, parsed_html):
+ anchor_tags = parsed_html.xpath("//a[@href]")
+
+ #Pass 1: Page has atleast 1 link that returns http 200
+ check_pass = True
+ check_notes = ""
+ http_statuses = []
+
+ if len(anchor_tags) == 0:
+ #Fail 1: No links on page, signs the page has no navigation
+ return (False, _("No Links found on page"))
+
+ urls = []
+ link_threads = []
+ request_limit = 5
+ for anchor_tag in anchor_tags:
+ href = anchor_tag.attrib['href']
+ if href.startswith("mailto:") or href.startswith("tel:"):
+ continue
+
+ absolute_url = urljoin(url, anchor_tag.attrib['href'])
+ urls.append(absolute_url)
+
+ # Divide the links between the threads
+ if len(urls) >= len(anchor_tags) / request_limit:
+ link_check_thread = threading.Thread(target=self.resource_check, args=(list(urls), http_statuses))
+ link_threads.append(link_check_thread)
+ link_check_thread.start()
+ urls.clear()
+
+ # Remainder goes into last thread
+ if len(urls) > 0:
+ link_check_thread = threading.Thread(target=self.resource_check, args=(list(urls), http_statuses))
+ link_threads.append(link_check_thread)
+ link_check_thread.start()
+ urls.clear()
+
+ # Wait for all threads to finish
+ for link_thread in link_threads:
+ link_thread.join()
+
+ for anchor_status in http_statuses:
+ # Redirects are still a fail as it requires extra time to fetch the other page
+ if anchor_status['status_code'] != 200:
+ check_pass = False
+ check_notes += "(" + str(anchor_status['status_code']) + ") " + anchor_status['absolute_url'] + " "
+
+ return (check_pass, check_notes)
+
+ def resource_check(self, urls, http_statuses):
+ headers = {'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.95 Safari/537.36'}
+ for url in urls:
+ try:
+ # TODO use get if head is rejected
+ request_response = requests.head(url, headers=headers)
+ http_statuses.append({'absolute_url': url, 'status_code': request_response.status_code})
+ except:
+ #Fail 3: Assume it is 404 if requests can not connect
+ http_statuses.append({'absolute_url': url, 'status_code': 404})
+
+ def _seo_check_valid_images(self, driver, url, parsed_html):
+ img_tags = parsed_html.xpath("//img")
+
+ #TODO look into using Selenium as it will already have attempted to download all images and may have the http status codes
+
+ #Pass 1: Page has no images or all images return http 200
+ check_pass = True
+ check_notes = ""
+
+ urls = []
+ img_threads = []
+ http_statuses = []
+ request_limit = 5
+ for img_tag in img_tags:
+ if 'src' in img_tag.attrib:
+ img_url_absolute = urljoin(url, img_tag.attrib['src'])
+
+ urls.append(img_url_absolute)
+
+ # Divide the imgs between the threads
+ if len(urls) >= len(img_tags) / request_limit:
+ img_check_thread = threading.Thread(target=self.resource_check, args=(list(urls), http_statuses))
+ img_threads.append(img_check_thread)
+ img_check_thread.start()
+ urls.clear()
+
+ else:
+ #Fail 4: Images with no src are invalid
+ check_pass = False
+ check_notes += _("Image has no src attribute") + " " + cgi.escape(html.tostring(img_tag).decode()) + " "
+
+ # Remainder goes into last thread
+ if len(urls) > 0:
+ img_check_thread = threading.Thread(target=self.resource_check, args=(list(urls), http_statuses))
+ img_threads.append(img_check_thread)
+ img_check_thread.start()
+ urls.clear()
+
+ # Wait for all threads to finish
+ for link_thread in img_threads:
+ link_thread.join()
+
+ for img_status in http_statuses:
+ # Redirects are still a fail as it requires extra time to fetch the other image
+ if img_status['status_code'] != 200:
+ check_pass = False
+ check_notes += "(" + str(img_status['status_code']) + ") " + img_status['absolute_url'] + " "
+
+ return (check_pass, check_notes)
+
+ def _seo_check_images_have_alt(self, driver, url, parsed_html):
+ img_tags = parsed_html.xpath("//img")
+
+ #Pass 1: Page has no images or all images have a non empty alt tag
+ check_pass = True
+ check_notes = ""
+
+ for img_tag in img_tags:
+ if 'alt' in img_tag.attrib:
+ if img_tag.attrib['alt'] == "":
+ #Fail 1: Any image has an empty alt attribute
+ check_pass = False
+ check_notes += _("Empty alt tag") + " " + cgi.escape(html.tostring(img_tag).decode()) + " "
+ else:
+ #Fail 2: Any image does not have alt attribute
+ check_pass = False
+ check_notes += _("Missing alt tag") + " " + cgi.escape(html.tostring(img_tag).decode()) + " "
+
+ return (check_pass, check_notes)
+
+ def _seo_check_page_load_time(self, driver, url, parsed_html):
+
+ navigation_start = driver.execute_script("return window.performance.timing.navigationStart")
+ dom_complete = driver.execute_script("return window.performance.timing.domComplete")
+
+ navigation_start_datetime = datetime.datetime.fromtimestamp(navigation_start/1000)
+ dom_complete_datetime = datetime.datetime.fromtimestamp(dom_complete/1000)
+
+ dom_content_loaded = round((dom_complete_datetime - navigation_start_datetime).total_seconds(), 2)
+
+ if dom_content_loaded <= 2.0:
+ return (True, _("%s seconds") % dom_content_loaded)
+ else:
+ return (False, _("%s seconds") % dom_content_loaded)
+
+ def _seo_check_non_optimised_images(self, driver, url, parsed_html):
+
+ check_pass = True
+ check_notes = ""
+
+ images = driver.find_elements_by_tag_name('img')
+ for image in images:
+ display_width = image.value_of_css_property('width')
+ display_height = image.value_of_css_property('width')
+
+ img_url_absolute = urljoin(url, image.get_attribute('src'))
+
+ data = requests.get(img_url_absolute).content
+ im = Image.open(BytesIO(data))
+ natural_width, natural_height = im.size
+
+ # I can really only use pixel comparasion
+ if "px" in display_width and "px" in display_height:
+ if int(display_width[:-2]) != natural_width or int(display_height[:-2]) != natural_height:
+ check_pass = False
+ check_notes += img_url_absolute + " Display: " + str(display_width[:-2]) + "x" + str(display_height[:-2]) + "px, Natural: " + str(natural_width) + "x" + str(natural_height) + "px "
+
+ return (check_pass, check_notes)
+
+ def _seo_check_https(self, driver, url, parsed_html):
+ if url.startswith("https://"):
+ #Pass 1: URL starts with https:// valid ceritifcate and mixed content are different checks
+ return (True, "")
+ else:
+ #Fail 1: URL does not start with https:// assume http:// but is still invalid if someone checks an ftp site...
+ return (False, "")
+
+ def _seo_check_mixed_content(self, driver, url, parsed_html):
+ #Pass 1: page has no images / links OR URL is http:// OR URL is https:// and all images / links use https://
+ check_pass = True
+ check_notes = ""
+
+ if url.startswith("https://"):
+ # Check all image tags
+ image_tags = parsed_html.xpath("//img")
+ for image_tag in image_tags:
+ if 'src' in image_tag.attrib:
+ if image_tag.attrib['src'].startswith("http://"):
+ check_pass = False
+ check_notes += _("Mixed Content \"%s\"") % image_tag.attrib['src'] + " "
+
+ # Check all link tags
+ link_tags = parsed_html.xpath("//a")
+ for link_tag in link_tags:
+ if 'href' in link_tag.attrib:
+ if link_tag.attrib['href'].startswith("http://"):
+ check_pass = False
+ check_notes += _("Mixed Content \"%s\"") % link_tag.attrib['href'] + " "
+
+ return (check_pass, check_notes)
+
+ def _seo_check_sitemap_exists(self, driver, url, parsed_html):
+
+ parsed_uri = urlparse(url)
+ domain = '{uri.scheme}://{uri.netloc}/'.format(uri=parsed_uri)
+
+ try:
+ sitemap_request_response = requests.head(domain + "sitemap.xml")
+ #Pass 1: File sitemap.xml
+ if sitemap_request_response.status_code == 200:
+ return (True, "")
+ except:
+ pass
+
+ try:
+ sitemap_request_response = requests.head(domain + "sitemap_index.xml")
+ #Pass 2: File sitemap_index.xml exists
+ if sitemap_request_response.status_code == 200:
+ return (True, "")
+ except:
+ pass
+
+ #Fail 1: Neither sitemap.xml or sitemap_index.xml exist
+ return (False, "")
+
+ def _seo_check_index_allowed(self, driver, url, parsed_html):
+
+ meta_robots_indexes = parsed_html.xpath("//meta[@name='robots']")
+
+ for meta_robots_index in meta_robots_indexes:
+ if 'noindex' in meta_robots_index.attrib['content']:
+ #Fail 1: Generic robots noindex
+ return (False, _("robots meta noindex detected"))
+
+ meta_googlebot_indexes = parsed_html.xpath("//meta[@name='googlebot']")
+
+ for meta_googlebot_index in meta_googlebot_indexes:
+ if 'noindex' in meta_googlebot_index.attrib['content']:
+ #Fail 2: googlebot specific noindex
+ return (False, _("googlebot meta noindex detected"))
+
+ #TODO Fail 3: http header X-Robots-Tag
+
+ #Pass 1: No noindex has been detected
+ return (True, "")
+
+ def _seo_check_google_domain_indexed(self, driver, url, parsed_html):
+
+ key = self.env['ir.default'].get('sem.settings', 'google_cse_key')
+ cx = self.env['ir.default'].get('sem.settings', 'google_search_engine_id')
+
+ # Skip the test if Google CSE is not setup
+ if key == False or cx == False:
+ return False
+
+ parsed_uri = urlparse(url)
+ domain = '{uri.scheme}://{uri.netloc}/'.format(uri=parsed_uri)
+ index_request_response = requests.get("https://www.googleapis.com/customsearch/v1?key=" + key + "&cx=" + cx + "&q=site:" + domain)
+
+ json_data = json.loads(index_request_response.text)
+
+ search_results = json_data['queries']['request'][0]['totalResults']
+ if int(search_results) > 0:
+ #Pass 1: Atleast one page has been indexed by Google
+ return (True, _("Google Index Rough Estimate %s") % search_results)
+
+ #Fail 1: Returns 0 results
+ return (False, "")
+
+ def _seo_check_google_analytics(self, driver, url, parsed_html):
+
+ check_pass = False
+ check_notes = ""
+
+ html_source = html.tostring(parsed_html).decode()
+
+ if "google-analytics.com/ga.js" in html_source:
+ check_pass = True
+ check_notes += _("Google Analytics Detected") + " "
+
+ if "stats.g.doubleclick.net/dc.js" in html_source:
+ check_pass = True
+ check_notes += _("Google Analytics Remarketing Detected") + " "
+
+ if "google-analytics.com/analytics.js" in html_source:
+ check_pass = True
+ check_notes += _("Google Universal Analytics Detected") + " "
+
+ if "googletagmanager.com/gtag/js" in html_source:
+ check_pass = True
+ check_notes += _("Google Analytics Global Site Tag Detected") + " "
+
+ if "google-analytics.com/ga_exp.js" in html_source:
+ check_pass = True
+ check_notes += _("Google Analytics Experiments Detected") + " "
+
+ if "googletagmanager.com/gtm.js" in html_source:
+ # Might not have anaytics installed but count it anyway
+ check_pass = True
+ check_notes += _("Google Tag Manager Detected") + " "
+
+ return (check_pass, check_notes)
+
+ @api.model
+ def create(self, values):
+ sequence=self.env['ir.sequence'].next_by_code('sem.check')
+ values['sequence']=sequence
+ return super(SemCheck, self).create(values)
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_check_category.py b/sem_seo_toolkit/models/sem_check_category.py
new file mode 100644
index 000000000..8a46cf571
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_check_category.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemCheckCategory(models.Model):
+
+ _name = "sem.check.category"
+ _order = "sequence asc"
+
+ sequence = fields.Integer(string="Sequence")
+ name = fields.Char(string="Name")
+ check_ids = fields.One2many('sem.check', 'category_id', string="SEO Checks")
+
+ @api.model
+ def create(self, values):
+ sequence=self.env['ir.sequence'].next_by_code('sem.check.category')
+ values['sequence']=sequence
+ return super(SemCheckCategory, self).create(values)
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_client.py b/sem_seo_toolkit/models/sem_client.py
new file mode 100644
index 000000000..2c74ba7f4
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_client.py
@@ -0,0 +1,195 @@
+# -*- coding: utf-8 -*-
+import sys
+import requests
+from lxml import html
+import json
+import datetime
+from urllib.parse import urljoin, urlparse
+import base64
+import time
+from lxml import etree
+import logging
+_logger = logging.getLogger(__name__)
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+ from selenium.webdriver.common.by import By
+except:
+ _logger.error("Selenium not installed")
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+
+class SemClient(models.Model):
+
+ _name = "sem.client"
+
+ name = fields.Char(related="partner_id.name", string="Name")
+ partner_id = fields.Many2one('res.partner', string="Contact")
+ website_ids = fields.One2many('sem.client.website', 'client_id', string="Websites")
+ website_count = fields.Integer(compute='_compute_website_count', string="Website Count")
+
+ @api.depends('website_ids')
+ def _compute_website_count(self):
+ for seo_client in self:
+ seo_client.website_count = len(seo_client.website_ids)
+
+class SemClientWebsite(models.Model):
+
+ _name = "sem.client.website"
+ _rec_name = "url"
+
+ client_id = fields.Many2one('sem.client', string="Client")
+ url = fields.Char(string="URL")
+ page_ids = fields.One2many('sem.client.website.page', 'website_id', string="Web Pages")
+ page_count = fields.Integer(compute='_compute_page_count', string="Page Count")
+ search_context_ids = fields.Many2many('sem.search.context', string="Search Contexts")
+
+ @api.depends('page_ids')
+ def _compute_page_count(self):
+ for webpage in self:
+ webpage.page_count = len(webpage.page_ids)
+
+ def read_sitemap(self):
+
+ parsed_uri = urlparse(self.url)
+ domain = '{uri.scheme}://{uri.netloc}/'.format(uri=parsed_uri)
+
+ sitemap_index_request_response = requests.get(domain + "sitemap_index.xml")
+ sitemap_index_root = etree.fromstring(sitemap_index_request_response.text.encode("utf-8"))
+
+ # TODO deal better with namespaces and sitemaps that have the loc not in the first position
+ for sitemap in sitemap_index_root:
+ sitemap_url = sitemap[0].text
+
+ sitemap_request_response = requests.get(sitemap_url)
+ sitemap_root = etree.fromstring(sitemap_request_response.text.encode("utf-8"))
+
+ # Add to the websites page list if the url is not already there
+ for sitemap_url_parent in sitemap_root:
+ sitemap_url = sitemap_url_parent[0].text
+ if self.env['sem.client.website.page'].search_count([('website_id','=',self.id), ('url','=',sitemap_url)]) == 0:
+ self.env['sem.client.website.page'].create({'website_id': self.id, 'url': sitemap_url})
+
+ @api.multi
+ def check_website(self):
+ self.ensure_one()
+
+ sem_website_report = self.env['sem.report.website'].create({'website_id': self.id})
+
+ try:
+ chrome_options = Options()
+ chrome_options.add_argument("--headless")
+ driver = webdriver.Chrome(chrome_options = chrome_options)
+ driver.get(self.url)
+ parsed_html = html.fromstring(driver.page_source)
+ except:
+ # Fall back to requests and skip some checks that need Selenium / Google Chrome
+ driver = False
+ parsed_html = html.fromstring(requests.get(self.url).text)
+
+ for seo_check in self.env['sem.check'].search([('active', '=', True), ('check_level', '=', 'domain')]):
+ method = '_seo_check_%s' % (seo_check.function_name,)
+ action = getattr(seo_check, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ try:
+ start = time.time()
+ check_result = action(driver, self.url, parsed_html)
+ end = time.time()
+ diff = end - start
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(seo_check.name)
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ continue
+
+ if check_result != False:
+ self.env['sem.report.website.check'].create({'website_report_id': sem_website_report.id, 'check_id': seo_check.id, 'check_pass': check_result[0], 'notes': check_result[1], 'time': diff})
+
+ try:
+ driver.quit()
+ except:
+ pass
+
+ # Run page level checks then attach them to this report as children so we can create one massive pdf with each page
+ for page in self.page_ids:
+ if page.active:
+ sem_report = page.check_webpage()
+ sem_report.website_report_id = sem_website_report.id
+
+ return {
+ 'name': 'Website Report',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.report.website',
+ 'type': 'ir.actions.act_window',
+ 'res_id': sem_website_report.id
+ }
+
+ @api.multi
+ def ranking_report(self):
+ self.ensure_one()
+
+ ranking_report = self.env['sem.report.ranking'].create({'website_id': self.id})
+
+ for search_context in self.search_context_ids:
+ search_results = search_context.get_search_results()
+
+ rank = "-"
+ url = ""
+ # Quick way of finding url that starts with the domain
+ found_result = self.env['sem.search.results.result'].search([('results_id','=', search_results.id), ('url','=like', self.url + '%')], limit=1)
+ if found_result:
+ rank = found_result.position
+ url = found_result.url
+
+ self.env['sem.report.ranking.result'].create({'ranking_id': ranking_report.id, 'search_context_id': search_context.id, 'search_result_id': found_result.id or False, 'rank': rank, 'url': url})
+
+ return {
+ 'name': 'Ranking Report',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.report.ranking',
+ 'res_id': ranking_report.id,
+ 'type': 'ir.actions.act_window'
+ }
+
+ @api.multi
+ def search_report(self):
+ self.ensure_one()
+
+ search_report = self.env['sem.report.search'].create({'website_id': self.id})
+
+ for search_context in self.search_context_ids:
+ search_insight = search_context.get_insight()
+ search_insight['search_id'] = search_report.id
+ search_insight['search_context_id'] = search_context.id
+ self.env['sem.report.search.result'].create(search_insight)
+
+ return {
+ 'name': 'Search Report',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.report.search',
+ 'res_id': search_report.id,
+ 'type': 'ir.actions.act_window'
+ }
+
+ @api.multi
+ def add_search_context(self):
+ self.ensure_one()
+
+ return {
+ 'name': 'Create Search Context',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.search.context.wizard',
+ 'target': 'new',
+ 'context': {'default_type': 'search', 'default_website_id': self.id},
+ 'type': 'ir.actions.act_window'
+ }
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_client_listing.py b/sem_seo_toolkit/models/sem_client_listing.py
new file mode 100644
index 000000000..423f37547
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_client_listing.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemClientListing(models.Model):
+
+ _name = "sem.client.listing"
+
+ client_id = fields.Many2one('sem.client', string="SEM Client")
+ search_engine_id = fields.Many2one('sem.search.engine', string="Search Engine")
+ name = fields.Char(string="Name")
+ listing_external_id = fields.Char(string="Listing External ID", help="This is the unique name or ID of the listing in the search engine")
+ media_ids = fields.One2many('sem.client.listing.media', 'listing_id', string="Media")
+ search_context_ids = fields.Many2many('sem.search.context', string="Search Contexts")
+
+ @api.multi
+ def add_search_context(self):
+ self.ensure_one()
+
+ return {
+ 'name': 'Create Search Context',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.search.context.wizard',
+ 'target': 'new',
+ 'context': {'default_type': 'map', 'default_search_engine_id': self.search_engine_id.id},
+ 'type': 'ir.actions.act_window'
+ }
+
+class SemClientListingMedia(models.Model):
+
+ _name = "sem.client.listing.media"
+
+ listing_id = fields.Many2one('sem.client.listing', string="Listing")
+ image = fields.Binary(string="Image")
+ media_external_id = fields.Char(string="Media External ID", help="This is the unique name or ID of the media")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_client_website_page.py b/sem_seo_toolkit/models/sem_client_website_page.py
new file mode 100644
index 000000000..a5d63bbda
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_client_website_page.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+
+import sys
+import requests
+from lxml import html
+import time
+import logging
+_logger = logging.getLogger(__name__)
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+ from selenium.webdriver.common.by import By
+except:
+ _logger.error("Selenium not installed")
+
+from odoo import api, fields, models
+
+class SemClientWebsitePage(models.Model):
+
+ _name = "sem.client.website.page"
+ _rec_name = "url"
+
+ website_id = fields.Many2one('sem.client.website', string="Website")
+ url = fields.Char(string="URL")
+ active = fields.Boolean(string="Active", default=True)
+ page_report_ids = fields.One2many('sem.report.website.page', 'page_id', string="Page Reports")
+
+ @api.onchange('website_id')
+ def _onchange_website_id(self):
+ if self.website_id.url:
+ self.url = self.website_id.url
+
+ @api.multi
+ def check_webpage_wrapper(self):
+ self.ensure_one()
+
+ webpage_report = self.check_webpage()
+
+ return {
+ 'name': 'Webpage Report',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.report.website.page',
+ 'type': 'ir.actions.act_window',
+ 'res_id': webpage_report.id
+ }
+
+ @api.multi
+ def check_webpage(self):
+ self.ensure_one()
+
+ webpage_report = self.env['sem.report.website.page'].create({'page_id': self.id, 'url': self.url})
+
+ try:
+ chrome_options = Options()
+ chrome_options.add_argument("--headless")
+ driver = webdriver.Chrome(chrome_options = chrome_options)
+ driver.get(self.url)
+ parsed_html = html.fromstring(driver.page_source)
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ # Fall back to requests and skip some checks that need Selenium / Google Chrome
+ driver = False
+ parsed_html = html.fromstring(requests.get(self.url).text)
+
+ for seo_metric in self.env['sem.metric'].search([('active', '=', True)]):
+ method = '_seo_metric_%s' % (seo_metric.function_name,)
+ action = getattr(seo_metric, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ try:
+ start = time.time()
+ metric_result = action(driver, self.url, parsed_html)
+ end = time.time()
+ diff = end - start
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(seo_metric.name)
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ continue
+
+ if metric_result != False:
+ self.env['sem.report.website.metric'].create({'website_report_page_id': webpage_report.id, 'metric_id': seo_metric.id, 'value': metric_result, 'time': diff})
+
+ for seo_check in self.env['sem.check'].search([('active', '=', True), ('check_level', '=', 'page')]):
+ method = '_seo_check_%s' % (seo_check.function_name,)
+ action = getattr(seo_check, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ try:
+ start = time.time()
+ check_result = action(driver, self.url, parsed_html)
+ end = time.time()
+ diff = end - start
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(seo_check.name)
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ continue
+
+ if check_result != False:
+ self.env['sem.report.website.check'].create({'website_report_page_id': webpage_report.id, 'check_id': seo_check.id, 'check_pass': check_result[0], 'notes': check_result[1], 'time': diff})
+
+ try:
+ driver.quit()
+ except:
+ pass
+
+ return webpage_report
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_depend.py b/sem_seo_toolkit/models/sem_depend.py
new file mode 100644
index 000000000..fa898ff22
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_depend.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemDepend(models.Model):
+
+ _name = "sem.depend"
+
+ name = fields.Char(string="Name")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_metric.py b/sem_seo_toolkit/models/sem_metric.py
new file mode 100644
index 000000000..6ea4e1249
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_metric.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+import sys
+from lxml import html
+from lxml import etree
+import cgi
+import requests
+from urllib.parse import urljoin, urlparse
+import logging
+_logger = logging.getLogger(__name__)
+from PIL import Image
+from io import BytesIO
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+except:
+ _logger.error("Selenium not installed")
+
+import json
+import datetime
+import urllib.request
+
+from odoo import api, fields, models, _
+
+class SemMetric(models.Model):
+
+ _name = "sem.metric"
+
+ sequence = fields.Integer(string="Sequence")
+ name = fields.Char(string="Name")
+ function_name = fields.Char(string="Function Name")
+ description = fields.Text(string="Description")
+ depend_ids = fields.Many2many('sem.depend', string="Dependencies")
+ active = fields.Boolean(string="Active")
+
+ def _seo_metric_page_load_time(self, driver, url, parsed_html):
+
+ navigation_start = driver.execute_script("return window.performance.timing.navigationStart")
+ dom_complete = driver.execute_script("return window.performance.timing.domComplete")
+
+ navigation_start_datetime = datetime.datetime.fromtimestamp(navigation_start/1000)
+ dom_complete_datetime = datetime.datetime.fromtimestamp(dom_complete/1000)
+
+ return round((dom_complete_datetime - navigation_start_datetime).total_seconds(), 2)
+
+ def _seo_metric_http_requests(self, driver, url, parsed_html):
+
+ http_requests = driver.execute_script("var performance = window.performance || window.mozPerformance || window.msPerformance || window.webkitPerformance || {}; var network = performance.getEntries() || {}; return network;")
+ resource_counter = 0
+ for performance_entry in http_requests:
+ if performance_entry['entryType'] == "resource":
+ resource_counter += 1
+ return resource_counter
+
+ @api.model
+ def create(self, values):
+ sequence=self.env['ir.sequence'].next_by_code('sem.metric')
+ values['sequence']=sequence
+ return super(SemMetric, self).create(values)
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_report.py b/sem_seo_toolkit/models/sem_report.py
new file mode 100644
index 000000000..fd6fa3285
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_report.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemReportWebsite(models.Model):
+
+ _name = "sem.report.website"
+
+ website_id = fields.Many2one('sem.client.website', string="Website")
+ check_ids = fields.One2many('sem.report.website.check', 'website_report_id', string="Domain Level Checks")
+ page_ids = fields.One2many('sem.report.website.page', 'website_report_id', string="Pages")
+
+class SemReportWebsitePage(models.Model):
+
+ _name = "sem.report.website.page"
+
+ website_report_id = fields.Many2one('sem.report.website', string="Report Website")
+ page_id = fields.Many2one('sem.client.website.page', string="Page")
+ url = fields.Char(string="URL")
+ check_ids = fields.One2many('sem.report.website.check', 'website_report_page_id', string="Page Level Checks")
+ metric_ids = fields.One2many('sem.report.website.metric', 'website_report_page_id', string="Page Level Metrics")
+
+class SemReportWebsiteCheck(models.Model):
+
+ _name = "sem.report.website.check"
+
+ website_report_id = fields.Many2one('sem.report.website', string="Website Report")
+ website_report_page_id = fields.Many2one('sem.report.website.page', string="Page Report")
+ check_id = fields.Many2one('sem.check', string="SEO Check")
+ check_pass = fields.Boolean(string="Pass", help="Did it pass or fail this check/test")
+ notes = fields.Html(sanitize=False, string="Notes", help="Any additional information")
+ time = fields.Float(string="Processing Time")
+
+class SemReportWebsiteMetric(models.Model):
+
+ _name = "sem.report.website.metric"
+
+ website_report_page_id = fields.Many2one('sem.report.website.page', string="Page Report")
+ metric_id = fields.Many2one('sem.metric', string="Metric")
+ value = fields.Char(string="Value")
+ time = fields.Float(string="Processing Time")
+
+class SemReportCompetition(models.Model):
+
+ _name = "sem.report.competition"
+
+ search_results_id = fields.Many2one('sem.search.results', string="Search Results")
+ competition_ids = fields.One2many('sem.report.competition.result', 'competition_id', string="Competitors")
+
+class SemReportCompetitionResult(models.Model):
+
+ _name = "sem.report.competition.result"
+
+ competition_id = fields.Many2one('sem.report.competition', string="Competition Report")
+ search_result_id = fields.Many2one('sem.search.results.result', string="Search Result")
+ report_id = fields.Many2one('sem.report.website.page', string="Page Report")
+
+class SemReportRanking(models.Model):
+
+ _name = "sem.report.ranking"
+
+ website_id = fields.Many2one('sem.client.website', string="Website")
+ result_ids = fields.One2many('sem.report.ranking.result', 'ranking_id', string="Results")
+
+class SemReportRankingResult(models.Model):
+
+ _name = "sem.report.ranking.result"
+
+ ranking_id = fields.Many2one('sem.report.ranking', string="Ranking Report")
+ search_context_id = fields.Many2one('sem.search.context', string="Search Context")
+ search_result_id = fields.Many2one('sem.search.results.result', string="Search Result")
+ rank = fields.Char(string="Rank")
+ url = fields.Char(string="URL")
+
+class SemReportSearch(models.Model):
+
+ _name = "sem.report.search"
+
+ website_id = fields.Many2one('sem.client.website', string="Client Website")
+ result_ids = fields.One2many('sem.report.search.result', 'search_id', string="Results")
+
+class SemReportSearchResult(models.Model):
+
+ _name = "sem.report.search.result"
+
+ search_id = fields.Many2one('sem.report.search', string="Search Report")
+ search_context_id = fields.Many2one('sem.search.context', string="Search Context")
+ monthly_searches = fields.Char(string="Monthly Searches")
+ competition = fields.Char(string="Competition")
+
+class SemReportGoogleBusiness(models.Model):
+
+ _name = "sem.report.google.business"
+
+ listing_id = fields.Many2one('sem.client.listing', string="Listing")
+ start_date = fields.Date(string="Report Start Period")
+ end_date = fields.Date(string="Report End Period")
+ media_ids = fields.One2many('sem.report.google.business.media', 'report_id')
+ queries_direct = fields.Integer(string="Queries Direct")
+ queries_indirect = fields.Integer(string="Queries Indirect")
+ queries_chain = fields.Integer(string="Queries Chain")
+ views_maps = fields.Integer(string="Views Maps")
+ views_search = fields.Integer(string="Views Search")
+ actions_website = fields.Integer(string="Actions Website")
+ actions_phone = fields.Integer(string="Actions Phone")
+ actions_driving_directions = fields.Integer(string="Actions Driving Directions")
+ photos_views_merchant = fields.Integer(string="Photos Views Merchant")
+ photos_views_customers = fields.Integer(string="Photos Views Customers")
+ photos_count_merchant = fields.Integer(string="Photos Count Merchant")
+ photos_count_customers = fields.Integer(string="Photos Count Customers")
+ local_post_views_search = fields.Integer(string="Local Post Views Search")
+ local_post_actions_call_to_action = fields.Integer(string="Local Post Actions Call to Action")
+
+class SemReportGoogleBusinessMedia(models.Model):
+
+ _name = "sem.report.google.business.media"
+
+ report_id = fields.Many2one('sem.report.google.business', string="GMB Report")
+ media_id = fields.Many2one('sem.client.listing.media', string="Media")
+ image = fields.Binary(related="media_id.image", string="Image")
+ view_count = fields.Integer(string="View Count")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_search_context.py b/sem_seo_toolkit/models/sem_search_context.py
new file mode 100644
index 000000000..6d36ef833
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_search_context.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemSearchContext(models.Model):
+
+ _name = "sem.search.context"
+
+ type = fields.Selection([('search','Search'),('map','Map')], string="Type")
+ keyword = fields.Char(string="Keyword")
+ search_engine_id = fields.Many2one('sem.search.engine', string="Search Engine")
+ geo_target_name = fields.Char(string="Geo Target Name")
+ location_id = fields.Char(string="Location ID", help="The ID of the location within the search engine")
+ latitude = fields.Char(string="Latitude")
+ longitude = fields.Char(string="Longitude")
+ accuracy_radius_meters = fields.Char(string="Accuracy Radius (Meters)")
+ device_id = fields.Many2one('sem.search.device', string="Device")
+ map_zoom_level = fields.Char(string="Map Zoom Level")
+ search_result_ids = fields.One2many('sem.search.results', 'search_context_id', string="Search Results")
+
+ @api.multi
+ def perform_search(self):
+ self.ensure_one()
+ search_results = self.get_search_results()
+ return {
+ 'name': 'Search Results',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.search.results',
+ 'type': 'ir.actions.act_window',
+ 'res_id': search_results.id
+ }
+
+ def get_search_results(self):
+ search_results_dict = self.search_engine_id.perform_search(self)
+ search_results_dict['search_context_id'] = self.id
+ search_results = self.env['sem.search.results'].create(search_results_dict)
+ return search_results
+
+ def get_insight(self):
+ return self.search_engine_id.get_insight(self)
+
+class SemSearchContextWizard(models.TransientModel):
+
+ _name = "sem.search.context.wizard"
+
+ website_id = fields.Many2one('sem.client.website', string="Website")
+ type = fields.Selection([('search','Search'),('map','Map')], string="Type")
+ search_engine_id = fields.Many2one('sem.search.engine', string="Search Engine")
+ keyword_ids = fields.One2many('sem.search.context.wizard.keyword', 'wizard_id', string="Keywords")
+ device_ids = fields.Many2many('sem.search.device', string="Devices")
+ location_string = fields.Char(string="Find Geo Target")
+ suggestion_id = fields.Many2one('sem.search.context.wizard.suggestion', string="Suggestions")
+ geo_target_ids = fields.Many2many('sem.search.context.wizard.geo', string="Geo Targets")
+ map_zoom_level = fields.Char(string="Map Zoom Level")
+
+ @api.onchange('location_string')
+ def _onchange_location_string(self):
+ if self.search_engine_id and self.location_string:
+
+ # Get the list of locations that the search engine supports using it's API
+ geo_targets = self.search_engine_id.find_geo_targets(self.location_string)
+
+ # Now add the records to the pool so they can be selected using the geo_target_ids fields
+ for geo_target in geo_targets:
+ self.env['sem.search.context.wizard.suggestion'].create(geo_target)
+
+ @api.onchange('suggestion_id')
+ def _onchange_suggestion_id(self):
+ if self.suggestion_id:
+ # Create a geo target with the same information
+ wizrd_geo_target = self.env['sem.search.context.wizard.geo'].create({'name': self.suggestion_id.name, 'location_id': self.suggestion_id.location_id, 'latitude': self.suggestion_id.latitude, 'longitude': self.suggestion_id.longitude})
+
+ self.geo_target_ids = [(4, wizrd_geo_target.id)]
+
+ # Clear the suggestions for reuse
+ self.location_string = False
+ self.suggestion_id = False
+
+ def add_search_contexts(self):
+
+ for keyword in self.keyword_ids:
+ for device in self.device_ids:
+ for geo_target in self.geo_target_ids:
+ # Create a large amount of search contexts
+ local_search_context = self.env['sem.search.context'].create({'search_engine_id': self.search_engine_id.id, 'type': self.type, 'location_id': geo_target.location_id, 'device_id': device.id, 'keyword': keyword.name, 'geo_target_name': geo_target.name, 'latitude': geo_target.latitude, 'longitude': geo_target.longitude, 'accuracy_radius_meters': geo_target.accuracy_radius_meters})
+
+ # Also add the geo targets to the website
+ self.website_id.search_context_ids = [(4, local_search_context.id)]
+
+class SemSearchContextWizardKeyword(models.TransientModel):
+
+ _name = "sem.search.context.wizard.keyword"
+
+ wizard_id = fields.Many2one('sem.search.context.wizard', string="Wizard")
+ name = fields.Char(string="Name")
+
+class SemSearchContextWizardSuggestion(models.TransientModel):
+
+ _name = "sem.search.context.wizard.suggestion"
+
+ name = fields.Char(string="Name")
+ location_id = fields.Char(string="The reference ID of the location in the Search Engine database")
+ latitude = fields.Char(string="Latitude")
+ longitude = fields.Char(string="Longitude")
+ accuracy_radius_meters = fields.Char(string="Accuracy Radius (Meters)")
+
+class SemSearchContextWizardGeo(models.TransientModel):
+
+ _name = "sem.search.context.wizard.geo"
+
+ name = fields.Char(string="Name")
+ location_id = fields.Char(string="The reference ID of the location in the Search Engine database")
+ latitude = fields.Char(string="Latitude")
+ longitude = fields.Char(string="Longitude")
+ accuracy_radius_meters = fields.Char(string="Accuracy Radius (Meters)")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_search_device.py b/sem_seo_toolkit/models/sem_search_device.py
new file mode 100644
index 000000000..33bc49965
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_search_device.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemSearchDevice(models.Model):
+
+ _name = "sem.search.device"
+
+ name = fields.Char(string="Name")
+ user_agent = fields.Char(string="User Agent")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_search_engine.py b/sem_seo_toolkit/models/sem_search_engine.py
new file mode 100644
index 000000000..f18231a01
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_search_engine.py
@@ -0,0 +1,179 @@
+# -*- coding: utf-8 -*-
+
+import requests
+import json
+import base64
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo.exceptions import UserError
+from odoo import api, fields, models, _
+
+class SemSearchEngine(models.Model):
+
+ _name = "sem.search.engine"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Function Name")
+
+ def find_geo_targets(self, location_string):
+ method = '_find_geo_targets_%s' % (self.internal_name,)
+ action = getattr(self, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ return action(location_string)
+
+ def _find_geo_targets_google(self, location_string):
+
+ if self.env['google.ads'].need_authorize():
+ raise UserError(_("Need to authorize with Google Ads first"))
+
+ #Get a new access token
+ refresh_token = self.env['ir.config_parameter'].get_param('google_ads_refresh_token')
+ all_token = self.env['google.service']._refresh_google_token_json(refresh_token, 'ads')
+ access_token = all_token.get('access_token')
+
+ manager_customer_id = self.env['ir.default'].get('sem.settings', 'google_ads_manager_account_customer_id').replace("-","")
+ developer_token = self.env['ir.default'].get('sem.settings', 'google_ads_developer_token')
+ headers = {'login-customer-id': manager_customer_id, 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + access_token, 'developer-token': developer_token}
+
+ payload = json.dumps({
+ "location_names":
+ {
+ "names":
+ [
+ location_string
+ ]
+ }
+ })
+
+ response_string = requests.post("https://googleads.googleapis.com/v2/geoTargetConstants:suggest", data=payload, headers=headers)
+
+ response_string_json = json.loads(response_string.text)
+
+ geo_targets = []
+
+ if "geoTargetConstantSuggestions" in response_string_json:
+ for geo_target_constant in response_string_json['geoTargetConstantSuggestions']:
+ geo_target_constant = geo_target_constant['geoTargetConstant']
+ geo_targets.append({'location_id': geo_target_constant['id'], 'name': geo_target_constant['canonicalName'].replace(",",", ")})
+
+ return geo_targets
+
+ def _find_geo_targets_bing(self, location_string):
+ bing_api_key = self.env['ir.default'].get('sem.settings', 'bing_maps_api_key')
+ request_response = requests.get("http://dev.virtualearth.net/REST/v1/Locations/" + location_string + "?key=" + bing_api_key)
+ response_json = json.loads(request_response.text)
+ geo_targets = []
+ for resource_set in response_json['resourceSets']:
+ for resource in resource_set['resources']:
+ latitude = resource['point']['coordinates'][0]
+ longitude = resource['point']['coordinates'][1]
+ geo_targets.append({'latitude': latitude, 'longitude': longitude, 'name': resource['name'], 'accuracy_radius_meters': 22})
+
+ return geo_targets
+
+ def perform_search(self, search_context):
+ method = '_perform_search_%s' % (self.internal_name,)
+ action = getattr(self, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ return action(search_context)
+
+ def _perform_search_google(self, search_context):
+ # As the Google CSE API doesn't allow very context sensitive results we instead provide a link
+ # This link aids agents in manually entering search result information
+ url = "https://www.google.com/search?q="
+ url += search_context.keyword.lower()
+ url += "&num=100&ip=0.0.0.0&source_ip=0.0.0.0&ie=UTF-8&oe=UTF-8&hl=en&adtest=on&noj=1&igu=1"
+
+ key = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
+
+ uule = "w+CAIQICI"
+ uule += key[len(search_context.geo_target_name) % len(key)]
+ uule += base64.b64encode(bytes(search_context.geo_target_name, "utf-8")).decode()
+ url += "&uule=" + uule
+ url += "&adtest-useragent=" + search_context.device_id.user_agent
+
+ return {'search_url': url}
+
+ def _perform_search_bing(self, search_context):
+ bing_web_search_api_key = self.env['ir.default'].get('sem.settings', 'bing_web_search_api_key')
+ headers = {'Ocp-Apim-Subscription-Key': bing_web_search_api_key, 'User-Agent': search_context.device_id.user_agent, 'X-Search-Location': "lat:" + search_context.latitude + ";long:" + search_context.longitude + ";re:" + search_context.accuracy_radius_meters}
+ response = requests.get("https://api.cognitive.microsoft.com/bing/v7.0/search?q=" + search_context.keyword + "&count=50", headers=headers)
+ response_json = json.loads(response.text)
+ search_results = []
+
+ position_counter = 0
+ for webpage in response_json['webPages']['value']:
+ position_counter += 1
+ link_url = webpage['url'].replace("\\","")
+ search_results.append( (0, 0, {'position': position_counter, 'name': webpage['name'], 'url': link_url, 'snippet': webpage['snippet']}) )
+
+ # Return the full results as well as ranking information if the site was found
+ return {'raw_data': response.text, 'result_ids': search_results}
+
+ def get_insight(self, search_context):
+ method = '_get_insight_%s' % (self.internal_name,)
+ action = getattr(self, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ return action(search_context)
+
+ def _get_insight_google(self, search_context):
+
+ if self.env['google.ads'].need_authorize():
+ raise UserError(_("Need to authorize with Google Ads first"))
+
+ #Get a new access token
+ refresh_token = self.env['ir.config_parameter'].get_param('google_ads_refresh_token')
+ all_token = self.env['google.service']._refresh_google_token_json(refresh_token, 'ads')
+ access_token = all_token.get('access_token')
+
+ manager_customer_id = self.env['ir.default'].get('sem.settings', 'google_ads_manager_account_customer_id').replace("-","")
+ developer_token = self.env['ir.default'].get('sem.settings', 'google_ads_developer_token')
+ headers = {'login-customer-id': manager_customer_id, 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + access_token, 'developer-token': developer_token}
+
+ # TODO allow for other languages
+ language = 'languageConstants/1000' #English
+
+ # TODO allow this to be configured
+ keyword_plan_network = "GOOGLE_SEARCH"
+
+ payload = json.dumps({
+ "language": language,
+ "geo_target_constants":
+ [
+ "geoTargetConstants/" + search_context.location_id
+ ],
+ "keyword_plan_network": keyword_plan_network,
+ "keyword_seed":
+ {
+ "keywords":
+ [
+ search_context.keyword
+ ]
+ }
+ })
+
+ response_string = requests.post("https://googleads.googleapis.com/v2/customers/" + manager_customer_id + ":generateKeywordIdeas", data=payload, headers=headers)
+
+ response_string_json = json.loads(response_string.text)
+
+ monthly_searches = "-"
+ competition = "-"
+
+ if "results" in response_string_json:
+ exact_match = response_string_json['results'][0]
+ if exact_match['text'] == search_context.keyword.lower():
+ monthly_searches = exact_match['keywordIdeaMetrics']['avgMonthlySearches']
+ if 'competition' in exact_match['keywordIdeaMetrics']:
+ competition = exact_match['keywordIdeaMetrics']['competition']
+
+ return {'monthly_searches': monthly_searches, 'competition': competition}
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_search_results.py b/sem_seo_toolkit/models/sem_search_results.py
new file mode 100644
index 000000000..bd2fe4e9a
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_search_results.py
@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemSearchResults(models.Model):
+
+ _name = "sem.search.results"
+
+ search_context_id = fields.Many2one('sem.search.context', string="Search Context")
+ raw_data = fields.Text(string="Raw Data")
+ search_url = fields.Char(string="Search URL")
+ result_ids = fields.One2many('sem.search.results.result', 'results_id', string="Search Results")
+
+ @api.multi
+ def competition_report(self):
+ self.ensure_one()
+
+ competition_report = self.env['sem.report.competition'].create({'search_results_id': self.id})
+
+ for search_result in self.result_ids:
+ seo_report = self.env['sem.tools'].check_url(search_result.url)
+ self.env['sem.report.competition.result'].create({'competition_id': competition_report.id, 'search_result_id': search_result.id, 'report_id': seo_report.id})
+
+ return {
+ 'name': 'Competition Report',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sem.report.competiton',
+ 'type': 'ir.actions.act_window',
+ 'res_id': competition_report.id
+ }
+
+class SemSearchResultsResult(models.Model):
+
+ _name = "sem.search.results.result"
+
+ results_id = fields.Many2one('sem.search.results', string="Search Results Container")
+ position = fields.Integer(string="Position")
+ name = fields.Char(string="Name")
+ url = fields.Char(string="URL")
+ snippet = fields.Char(string="Snippet")
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_settings.py b/sem_seo_toolkit/models/sem_settings.py
new file mode 100644
index 000000000..690bd9c76
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_settings.py
@@ -0,0 +1,190 @@
+# -*- coding: utf-8 -*-
+
+import argparse
+import sys
+import requests
+import json
+import datetime
+import base64
+import logging
+_logger = logging.getLogger(__name__)
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+ from selenium.webdriver.common.by import By
+except:
+ _logger.error("Selenium not installed")
+
+from odoo.http import request
+from odoo import api, fields, models
+from odoo.tools.translate import _
+
+class SEMSettings(models.Model):
+
+ _name = "sem.settings"
+ _inherit = 'res.config.settings'
+
+ def _default_tracking_code(self):
+ return "\n"
+
+ google_cse_key = fields.Char(string="Google CSE Key")
+ google_search_engine_id = fields.Char(string="Google CSE Search Engine ID")
+ google_ads_developer_token = fields.Char(string="Google Ads Developer Token")
+ google_ads_client_id = fields.Char(string="Google Ads Client ID")
+ google_ads_client_secret = fields.Char(string="Google Ads Client Secret")
+ google_ads_manager_account_customer_id = fields.Char(string="Google Ads Manager Account Customer ID")
+ google_my_business_client_id = fields.Char(string="Google My Business Client ID")
+ google_my_business_client_secret = fields.Char(string="Google My Business Client Secret")
+ bing_web_search_api_key = fields.Char(string="Bing Web Search v7 API Key")
+ bing_maps_api_key = fields.Char(string="Bing Maps API Key")
+ google_maps_api_key = fields.Char(string="Google Maps API Key")
+ tracking_code = fields.Text(string="Tracking Code", default=_default_tracking_code)
+
+ def test_google_maps_geocode(self):
+ google_maps_api_key = self.env['ir.default'].get('sem.settings', 'google_maps_api_key')
+ request_response = requests.get("https://maps.googleapis.com/maps/api/geocode/json?address=1600+Amphitheatre+Parkway,+Mountain+View,+CA&key=" + google_maps_api_key)
+ _logger.error(request_response.text)
+ request_response_json = json.loads(request_response.text)
+
+ @api.multi
+ def google_ads_authorize(self):
+ self.ensure_one()
+
+ return_url = request.httprequest.host_url + "web"
+ url = self.env['google.service']._get_authorize_uri(return_url, 'ads', scope='https://www.googleapis.com/auth/adwords')
+ return {'type': 'ir.actions.act_url', 'url': url, 'target': 'self'}
+
+ @api.multi
+ def google_my_business_authorize(self):
+ self.ensure_one()
+
+ return_url = request.httprequest.host_url + "web"
+ url = self.env['google.service']._get_authorize_uri(return_url, 'business', scope='https://www.googleapis.com/auth/business.manage')
+ return {'type': 'ir.actions.act_url', 'url': url, 'target': 'self'}
+
+ @api.multi
+ def google_my_business_report(self):
+ self.ensure_one()
+
+ if self.env['google.business'].need_authorize():
+ raise UserError(_("Need to authorize with Google My Business first"))
+
+ #Get a new access token
+ refresh_token = self.env['ir.config_parameter'].get_param('google_business_refresh_token')
+ all_token = self.env['google.service']._refresh_google_token_json(refresh_token, 'business')
+ access_token = all_token.get('access_token')
+
+ headers = {'Authorization': 'Bearer ' + access_token}
+ account_response = requests.get("https://mybusiness.googleapis.com/v4/accounts", headers=headers)
+ account_response_json = json.loads(account_response.text)
+ google_search_engine = self.env['ir.model.data'].get_object('sem_seo_toolkit','seo_search_engine_google')
+ end_date = datetime.datetime.utcnow()
+ start_date = datetime.datetime.now() - datetime.timedelta(30)
+
+ locations = []
+ location_ids = []
+ for account in account_response_json['accounts']:
+ account_name = account['name']
+ location_response = requests.get("https://mybusiness.googleapis.com/v4/" + account_name + "/locations", headers=headers)
+ location_response_json = json.loads(location_response.text)
+
+ # Just add all locations to a list so we can feed them all into the one reportInsights call
+ for location in location_response_json['locations']:
+ # Only include locations that are open and verified, and non duplicate
+ location_id = location['name'].split("locations/")[1]
+ if location['openInfo']['status'] == 'OPEN' and 'isVerified' in location['locationState'] and location_id not in location_ids:
+
+ locations.append(location['name'])
+ location_ids.append(location_id)
+
+ # Create or find a local listing entry so we can track performance since last report
+ local_listing_search = self.env['sem.client.listing'].search([('search_engine_id','=', google_search_engine.id), ('listing_external_id','=', location['name'])])
+ if len(local_listing_search) == 0:
+ local_listing = self.env['sem.client.listing'].create({'search_engine_id': google_search_engine.id, 'name': location['locationName'], 'listing_external_id': location['name']})
+ else:
+ local_listing = local_listing_search[0]
+
+ location_report = self.env['sem.report.google.business'].create({'listing_id': local_listing.id, 'start_date': start_date, 'end_date': end_date})
+
+ media_response = requests.get("https://mybusiness.googleapis.com/v4/" + location['name'] + "/media", headers=headers)
+ media_response_json = json.loads(media_response.text)
+
+ if 'mediaItems' in media_response_json:
+ for media_item in media_response_json['mediaItems']:
+
+ # Create or find a local copy of the media so we can use it in the report
+ local_media_search = self.env['sem.client.listing.media'].search([('media_external_id','=', media_item['name'])])
+ if len(local_media_search) == 0:
+ google_thumbnail_image = base64.b64encode( requests.get(media_item['thumbnailUrl']).content )
+ local_media = self.env['sem.client.listing.media'].create({'listing_id': local_listing.id, 'image': google_thumbnail_image, 'media_external_id': media_item['name']})
+ else:
+ local_media = local_media_search[0]
+
+ if 'viewCount' in media_item['insights']:
+ self.env['sem.report.google.business.media'].create({'report_id': location_report.id, 'media_id': local_media.id, 'view_count': media_item['insights']['viewCount']})
+
+ # All metrics within last 30 days
+ # TODO feed 10 locations in at a time as that is the limit (https://developers.google.com/my-business/reference/rest/v4/accounts.locations/reportInsights)
+ zulu_current_time = end_date.isoformat('T') + "Z"
+ zulu_past = start_date.isoformat('T') + "Z"
+ payload = json.dumps({
+ "locationNames": locations,
+ "basicRequest": {
+ "metricRequests": [
+ {
+ "metric": "ALL"
+ }
+ ],
+ "timeRange": {
+ "startTime": zulu_past,
+ "endTime": zulu_current_time
+ }
+ }
+ })
+
+ # Since the metric information is done in bulk we update the records as we needed the report already created for the media metrics
+ insight_response = requests.post("https://mybusiness.googleapis.com/v4/" + account_name + "/locations:reportInsights", data=payload, headers=headers)
+ insight_response_json = json.loads(insight_response.text)
+ for location_insight in insight_response_json['locationMetrics']:
+ location_report = self.env['sem.report.google.business'].search([('listing_id.listing_external_id', '=', location_insight['locationName'])])
+ update_vals = {}
+ for metric_value in location_insight['metricValues']:
+ # Don't include the AGGREGATED_TOTAL
+ if 'metric' in metric_value:
+ update_vals[metric_value['metric'].lower()] = metric_value['totalValue']['value']
+
+ location_report.update(update_vals)
+
+ @api.multi
+ def set_values(self):
+ super(SEMSettings, self).set_values()
+ self.env['ir.default'].set('sem.settings', 'google_cse_key', self.google_cse_key)
+ self.env['ir.default'].set('sem.settings', 'google_search_engine_id', self.google_search_engine_id)
+ self.env['ir.default'].set('sem.settings', 'google_ads_developer_token', self.google_ads_developer_token)
+ self.env['ir.config_parameter'].set_param('google_ads_client_id', self.google_ads_client_id)
+ self.env['ir.config_parameter'].set_param('google_ads_client_secret', self.google_ads_client_secret)
+ self.env['ir.default'].set('sem.settings', 'google_ads_manager_account_customer_id', self.google_ads_manager_account_customer_id)
+ self.env['ir.default'].set('sem.settings', 'bing_web_search_api_key', self.bing_web_search_api_key)
+ self.env['ir.default'].set('sem.settings', 'bing_maps_api_key', self.bing_maps_api_key)
+ self.env['ir.config_parameter'].set_param('google_business_client_id', self.google_my_business_client_id)
+ self.env['ir.config_parameter'].set_param('google_business_client_secret', self.google_my_business_client_secret)
+ self.env['ir.default'].set('sem.settings', 'google_maps_api_key', self.google_maps_api_key)
+
+ @api.model
+ def get_values(self):
+ res = super(SEMSettings, self).get_values()
+ res.update(
+ google_cse_key=self.env['ir.default'].get('sem.settings', 'google_cse_key'),
+ google_search_engine_id=self.env['ir.default'].get('sem.settings', 'google_search_engine_id'),
+ google_ads_developer_token=self.env['ir.default'].get('sem.settings', 'google_ads_developer_token'),
+ google_ads_client_id=self.env['ir.config_parameter'].get_param('google_ads_client_id'),
+ google_ads_client_secret=self.env['ir.config_parameter'].get_param('google_ads_client_secret'),
+ google_ads_manager_account_customer_id=self.env['ir.default'].get('sem.settings', 'google_ads_manager_account_customer_id'),
+ bing_web_search_api_key=self.env['ir.default'].get('sem.settings', 'bing_web_search_api_key'),
+ bing_maps_api_key=self.env['ir.default'].get('sem.settings', 'bing_maps_api_key'),
+ google_my_business_client_id=self.env['ir.config_parameter'].get_param('google_business_client_id'),
+ google_my_business_client_secret=self.env['ir.config_parameter'].get_param('google_business_client_secret'),
+ google_maps_api_key=self.env['ir.default'].get('sem.settings', 'google_maps_api_key')
+ )
+ return res
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_tools.py b/sem_seo_toolkit/models/sem_tools.py
new file mode 100644
index 000000000..6e4a1336c
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_tools.py
@@ -0,0 +1,91 @@
+# -*- coding: utf-8 -*-
+
+import sys
+import requests
+from lxml import html
+import time
+import logging
+_logger = logging.getLogger(__name__)
+
+try:
+ from selenium import webdriver
+ from selenium.webdriver.chrome.options import Options
+ from selenium.webdriver.common.by import By
+except:
+ _logger.error("Selenium not installed")
+
+from odoo import api, fields, models
+
+class SemTools(models.TransientModel):
+
+ _name = "sem.tools"
+
+ @api.model
+ def check_url(self, url):
+
+ webpage_report = self.env['sem.report.website.page'].create({'url': url})
+
+ try:
+ chrome_options = Options()
+ chrome_options.add_argument("--headless")
+ driver = webdriver.Chrome(chrome_options = chrome_options)
+ driver.get(url)
+ parsed_html = html.fromstring(driver.page_source)
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ # Fall back to requests and skip some checks that need Selenium / Google Chrome
+ driver = False
+ parsed_html = html.fromstring(requests.get(url).text)
+
+ for seo_metric in self.env['sem.metric'].search([('active', '=', True)]):
+ method = '_seo_metric_%s' % (seo_metric.function_name,)
+ action = getattr(seo_metric, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ try:
+ start = time.time()
+ metric_result = action(driver, url, parsed_html)
+ end = time.time()
+ diff = end - start
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(seo_metric.name)
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ continue
+
+ if metric_result != False:
+ self.env['sem.report.website.metric'].create({'website_report_page_id': webpage_report.id, 'metric_id': seo_metric.id, 'value': metric_result, 'time': diff})
+
+ for seo_check in self.env['sem.check'].search([('active', '=', True), ('check_level', '=', 'page')]):
+ method = '_seo_check_%s' % (seo_check.function_name,)
+ action = getattr(seo_check, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ try:
+ start = time.time()
+ check_result = action(driver, url, parsed_html)
+ end = time.time()
+ diff = end - start
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(seo_check.name)
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+ continue
+
+ if check_result != False:
+ self.env['sem.report.website.check'].create({'website_report_page_id': webpage_report.id, 'check_id': seo_check.id, 'check_pass': check_result[0], 'notes': check_result[1], 'time': diff})
+
+ try:
+ driver.quit()
+ except:
+ pass
+
+ return webpage_report
\ No newline at end of file
diff --git a/sem_seo_toolkit/models/sem_track.py b/sem_seo_toolkit/models/sem_track.py
new file mode 100644
index 000000000..61ecc8d5e
--- /dev/null
+++ b/sem_seo_toolkit/models/sem_track.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class SemTrack(models.Model):
+
+ _name = "sem.track"
+
+ session_id = fields.Char(string="Session ID")
+ referrer = fields.Char(string="Referrer")
+ user_agent = fields.Char(string="User Agent")
+ start_url = fields.Char(string="URL")
+ ip = fields.Char(string="IP Address")
\ No newline at end of file
diff --git a/sem_seo_toolkit/security/ir.model.access.csv b/sem_seo_toolkit/security/ir.model.access.csv
new file mode 100644
index 000000000..79a0eb05e
--- /dev/null
+++ b/sem_seo_toolkit/security/ir.model.access.csv
@@ -0,0 +1,29 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_google_ads,access google.ads,model_google_ads,sem_group,1,0,0,0
+access_google_business,access google.business,model_google_business,sem_group,1,0,0,0
+access_sem_client,access sem.client,model_sem_client,sem_group,1,1,1,1
+access_sem_client_website,access sem.client.website,model_sem_client_website,sem_group,1,0,1,0
+access_sem_client_website_page,access sem.client.website.page,model_sem_client_website_page,sem_group,1,0,0,0
+access_sem_client_listing,access sem.client.listing,model_sem_client_listing,sem_group,1,0,0,0
+access_sem_client_listing_media,access sem.client.listing.media,model_sem_client_listing_media,sem_group,1,0,0,0
+access_sem_track,access sem.track,model_sem_track,sem_group,1,0,0,0
+access_sem_search_context,access sem.search.context,model_sem_search_context,sem_group,1,0,0,0
+access_sem_search_device,access sem.search.device,model_sem_search_device,sem_group,1,1,1,1
+access_sem_search_engine,access sem.search.engine,model_sem_search_engine,sem_group,1,0,0,0
+access_sem_search_results,access sem.search.results,model_sem_search_results,sem_group,1,0,0,0
+access_sem_search_results_result,access sem.search.results.result,model_sem_search_results_result,sem_group,1,0,0,0
+access_sem_depend,access sem.depend,model_sem_depend,sem_group,1,0,0,0
+access_sem_metric,access sem.metric,model_sem_metric,sem_group,1,1,1,0
+access_sem_check,access sem.check,model_sem_check,sem_group,1,1,1,0
+access_sem_check_category,access sem.check.category,model_sem_check_category,sem_group,1,1,1,1
+access_sem_report_google_business,access sem.report.google.business,model_sem_report_google_business,sem_group,1,0,0,0
+access_sem_report_google_business_media,access sem.report.google.business.media,model_sem_report_google_business_media,sem_group,1,0,0,0
+access_sem_report_website,access sem.report.website,model_sem_report_website,sem_group,1,0,1,0
+access_sem_report_website_page,access sem.report.website.page,model_sem_report_website_page,sem_group,1,0,1,0
+access_sem_report_website_check,access sem.report.website.check,model_sem_report_website_check,sem_group,1,0,0,0
+access_sem_report_website_metric,access sem.report.website.metric,model_sem_report_website_metric,sem_group,1,0,0,0
+access_sem_report_search,access sem.report.search,model_sem_report_search,sem_group,1,0,1,0
+access_sem_report_search_result,access sem.report.search.result,model_sem_report_search_result,sem_group,1,0,0,0
+access_ir_rule,access ir.rule,base.model_ir_rule,sem_group,1,0,0,0
+access_ir_config_parameter,access ir.config_parameter,base.model_ir_config_parameter,sem_group,1,0,0,0
+access_sem_track_public,access sem.track,model_sem_track,,0,0,1,0
\ No newline at end of file
diff --git a/sem_seo_toolkit/static/description/1.JPG b/sem_seo_toolkit/static/description/1.JPG
new file mode 100644
index 000000000..d7371c004
Binary files /dev/null and b/sem_seo_toolkit/static/description/1.JPG differ
diff --git a/sem_seo_toolkit/static/description/2.JPG b/sem_seo_toolkit/static/description/2.JPG
new file mode 100644
index 000000000..16cb2a429
Binary files /dev/null and b/sem_seo_toolkit/static/description/2.JPG differ
diff --git a/sem_seo_toolkit/static/description/3.JPG b/sem_seo_toolkit/static/description/3.JPG
new file mode 100644
index 000000000..b3f3aa35e
Binary files /dev/null and b/sem_seo_toolkit/static/description/3.JPG differ
diff --git a/sem_seo_toolkit/static/description/4.JPG b/sem_seo_toolkit/static/description/4.JPG
new file mode 100644
index 000000000..e748f119c
Binary files /dev/null and b/sem_seo_toolkit/static/description/4.JPG differ
diff --git a/sem_seo_toolkit/static/description/5.JPG b/sem_seo_toolkit/static/description/5.JPG
new file mode 100644
index 000000000..8d85b6ba6
Binary files /dev/null and b/sem_seo_toolkit/static/description/5.JPG differ
diff --git a/sem_seo_toolkit/static/description/index.html b/sem_seo_toolkit/static/description/index.html
new file mode 100644
index 000000000..e1a55acb5
--- /dev/null
+++ b/sem_seo_toolkit/static/description/index.html
@@ -0,0 +1,33 @@
+
+
Description
+Set of tools to help with Search Engine Marketing / Optimisation
+
+*NOTE* Due to the nature of the module and it's interaction with search engines multiple dependencies are needed for full functionality, here is a breakdown
+Google CSE: is needed for the "Google Domain Indexed" SEO check
+Google Ads: used in getting search insight information such as the number of times a keyword is searched in a particular search context
+Bing Web Search v7: returns context senitive search results for search context
+Bing Maps: used for finding latitude and longitude when creating search context
+Selenium: controls web browsers and is used for the "Page Load Time" and "Non Optimised Images" SEO checks
+Google Chrome Headless: the only web browser the module currently supports, needed alongside Selenium
+Selenium Chrome Driver: needed to programmatically control Google Chrome
+
+If a dependency is not setup it will just skip the SEO check
+
Selenium and Google Chrome Installation (Ubuntu 64 bit)
+
+
+
+
+ sem.search.context.wizard.geo tree view
+ sem.search.context.wizard.geo
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/views/sem_search_device_views.xml b/sem_seo_toolkit/views/sem_search_device_views.xml
new file mode 100644
index 000000000..79b78aca5
--- /dev/null
+++ b/sem_seo_toolkit/views/sem_search_device_views.xml
@@ -0,0 +1,35 @@
+
+
+
+
+ sem.search.device form view
+ sem.search.device
+
+
+
+
+
+
+
+
+
+
+
+ sem.search.device tree view
+ sem.search.device
+
+
+
+
+
+
+
+
+
+ Devices
+ sem.search.device
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/views/sem_search_results_views.xml b/sem_seo_toolkit/views/sem_search_results_views.xml
new file mode 100644
index 000000000..903513d08
--- /dev/null
+++ b/sem_seo_toolkit/views/sem_search_results_views.xml
@@ -0,0 +1,46 @@
+
+
+
+
+ sem.search.results form view
+ sem.search.results
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sem.search.results tree view
+ sem.search.results
+
+
+
+
+
+
+
+
+
+ Search Results
+ sem.search.results
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/views/sem_seo_toolkit_templates.xml b/sem_seo_toolkit/views/sem_seo_toolkit_templates.xml
new file mode 100644
index 000000000..d2f03c3d8
--- /dev/null
+++ b/sem_seo_toolkit/views/sem_seo_toolkit_templates.xml
@@ -0,0 +1,191 @@
+
+
+
+
+
+
+
+
Website Report
+
Website:
+
Domain Checks
+
+
Check Name
Result
Notes
+
+
+
+
+
+ ✔
+
+
+ ✖
+
+
+
+
+
+
+
+
+
+
URL:
+
Page Checks
+
+
Check Name
Result
Notes
+
+
+
+
+
+ ✔
+
+
+ ✖
+
+
+
+
+
+
+
Page Metrics
+
+
Metric Name
Value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Webpage Report Report
+
URL:
+
Checks
+
+
Check Name
Result
Notes
+
+
+
+
+
+ ✔
+
+
+ ✖
+
+
+
+
+
+
+
Metrics
+
+
Metric Name
Value
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Google My Business Report
+
+
Listing stats from last 30 days ( - )
+
+
Metric
Value
Description
+
Queries Direct
Customers who find your listing searching for your business name or address
+
Queries Indirect
Customers who find your listing searching for a category, product, or service
+
Queries Chain
The number of times your listing was shown as a result of a search for the chain it belongs to, or a brand it sells
+
Views Maps
The number of times your listing was viewed on Google Maps
+
Views Search
The number of times your listing was viewed on Google Search
+
Actions Website
The number of times the website was clicked
+
Actions Phone
The number of times the phone number was clicked
+
Actions Driving Directions
The number of times driving directions were requested
+
Photos Views Merchant
The number of views on media items uploaded by you
+
Photos Views Customers
The number of views on media items uploaded by customers
+
Photos Count Merchant
The total number of media items that are currently live that have been uploaded by you
+
Photos Count Customers
The total number of media items that are currently live that have been uploaded by customers
+
Local Post Views Search
The number of times the local post was viewed on Google Search
+
Local Post Actions Call To Action
The number of times the call to action button was clicked on Google
+
+
Media Stats (Lifetime)
+
+
Image
View Count
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ranking Report
+
URL:
+
Results
+
+
Search Engine
Keyword
Device
Location
Rank
URL
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Search Report
+
Website:
+
Results
+
+
Search Engine
Keyword
Device
Location
Monthly Searches
Competition
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/views/sem_settings_views.xml b/sem_seo_toolkit/views/sem_settings_views.xml
new file mode 100644
index 000000000..106fe320b
--- /dev/null
+++ b/sem_seo_toolkit/views/sem_settings_views.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+ sem.settings form view
+ sem.settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SEM Settings
+ sem.settings
+ form
+ inline
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit/views/sem_track_views.xml b/sem_seo_toolkit/views/sem_track_views.xml
new file mode 100644
index 000000000..195671138
--- /dev/null
+++ b/sem_seo_toolkit/views/sem_track_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ sem.track form view
+ sem.track
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ sem.track tree view
+ sem.track
+
+
+
+
+
+
+
+
+
+
+ Website Analytics
+ sem.track
+ form
+ tree,form
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/__init__.py b/sem_seo_toolkit_reflect/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/sem_seo_toolkit_reflect/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/__manifest__.py b/sem_seo_toolkit_reflect/__manifest__.py
new file mode 100644
index 000000000..b95e3278d
--- /dev/null
+++ b/sem_seo_toolkit_reflect/__manifest__.py
@@ -0,0 +1,15 @@
+{
+ 'name': "Search Engine Marketing Toolkit - Reflect",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Applies a few SEO tweaks to the Odoo Website CMS",
+ 'description': "Applies a few SEO tweaks to the Odoo Website CMS",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/website_page_views.xml',
+ 'views/sem_seo_toolkit_reflect_templates.xml',
+ ],
+ 'depends': ['sem_seo_toolkit', 'website'],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/doc/changelog.rst b/sem_seo_toolkit_reflect/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/sem_seo_toolkit_reflect/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/models/__init__.py b/sem_seo_toolkit_reflect/models/__init__.py
new file mode 100644
index 000000000..3d7a2b226
--- /dev/null
+++ b/sem_seo_toolkit_reflect/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import website_page
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/models/website_page.py b/sem_seo_toolkit_reflect/models/website_page.py
new file mode 100644
index 000000000..fa40a1d1c
--- /dev/null
+++ b/sem_seo_toolkit_reflect/models/website_page.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class WebsitePage(models.Model):
+
+ _inherit = "website.page"
+
+ canonical_url = fields.Char(string="Canonical Url")
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/static/description/index.html b/sem_seo_toolkit_reflect/static/description/index.html
new file mode 100644
index 000000000..13336614e
--- /dev/null
+++ b/sem_seo_toolkit_reflect/static/description/index.html
@@ -0,0 +1,4 @@
+
+
Description
+Applies a few SEO tweaks to the Odoo Website CMS
+
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/views/sem_seo_toolkit_reflect_templates.xml b/sem_seo_toolkit_reflect/views/sem_seo_toolkit_reflect_templates.xml
new file mode 100644
index 000000000..29bb02f9f
--- /dev/null
+++ b/sem_seo_toolkit_reflect/views/sem_seo_toolkit_reflect_templates.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit_reflect/views/website_page_views.xml b/sem_seo_toolkit_reflect/views/website_page_views.xml
new file mode 100644
index 000000000..6c5511489
--- /dev/null
+++ b/sem_seo_toolkit_reflect/views/website_page_views.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ website.page form view inheriot reflect
+ website.page
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/__init__.py b/sem_seo_toolkit_website/__init__.py
new file mode 100644
index 000000000..457bae27e
--- /dev/null
+++ b/sem_seo_toolkit_website/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import controllers
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/__manifest__.py b/sem_seo_toolkit_website/__manifest__.py
new file mode 100644
index 000000000..e391af7a7
--- /dev/null
+++ b/sem_seo_toolkit_website/__manifest__.py
@@ -0,0 +1,15 @@
+{
+ 'name': "Search Engine Marketing Toolkit - Website",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Allows people to perform SEO reports from the website",
+ 'description': "Allows people to perform SEO reports from the website",
+ 'license':'LGPL-3',
+ 'data': [
+ 'data/website.menu.csv',
+ 'views/sem_seo_toolkit_website_templates.xml',
+ ],
+ 'depends': ['sem_seo_toolkit', 'website'],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/controllers/__init__.py b/sem_seo_toolkit_website/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/sem_seo_toolkit_website/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/controllers/main.py b/sem_seo_toolkit_website/controllers/main.py
new file mode 100644
index 000000000..1ba6add64
--- /dev/null
+++ b/sem_seo_toolkit_website/controllers/main.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+
+import requests
+from lxml import html
+
+import odoo.http as http
+from odoo.http import request
+from odoo.exceptions import UserError
+
+class SemSeoToolkitWebsiteController(http.Controller):
+
+ @http.route('/seo/report', type='http', auth="public", website=True)
+ def seo_report(self, **kwargs):
+
+ if 'url' in request.params:
+
+ # We don't verify because one of the checks is valid certificate
+ try:
+ request_response = requests.get(request.params['url'], verify=False)
+ except:
+ return "Can not access website"
+
+ sem_report = request.env['sem.report.seo'].sudo().create({'url': request.params['url']})
+
+ # Domain level checks
+ for seo_check in request.env['sem.check'].sudo().search([('active', '=', True), ('keyword_required', '=', False), ('check_level', '=', 'domain')]):
+ method = '_seo_check_%s' % (seo_check.function_name,)
+ action = getattr(seo_check, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ parsed_html = html.fromstring(request_response.text)
+
+ check_result = action(request_response, parsed_html, False)
+ request.env['sem.report.seo.check'].sudo().create({'report_id': sem_report.id, 'check_id': seo_check.id, 'check_pass': check_result[0], 'notes': check_result[1]})
+
+ # Page level checks
+ for seo_check in request.env['sem.check'].sudo().search([('active', '=', True), ('keyword_required', '=', False), ('check_level', '=', 'page')]):
+ method = '_seo_check_%s' % (seo_check.function_name,)
+ action = getattr(seo_check, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented' % (method,))
+
+ parsed_html = html.fromstring(request_response.text)
+
+ check_result = action(request_response, parsed_html, False)
+ request.env['sem.report.seo.check'].sudo().create({'report_id': sem_report.id, 'check_id': seo_check.id, 'check_pass': check_result[0], 'notes': check_result[1]})
+
+ return http.request.render('sem_seo_toolkit_website.seo_report', {'sem_report': sem_report})
+ else:
+ return http.request.render('sem_seo_toolkit_website.seo_report', {})
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/data/website.menu.csv b/sem_seo_toolkit_website/data/website.menu.csv
new file mode 100644
index 000000000..ca77e15d1
--- /dev/null
+++ b/sem_seo_toolkit_website/data/website.menu.csv
@@ -0,0 +1,2 @@
+"id","name","url","parent_id/id"
+"seo_report_website_menu","SEO Report","/seo/report","website.main_menu"
diff --git a/sem_seo_toolkit_website/doc/changelog.rst b/sem_seo_toolkit_website/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/sem_seo_toolkit_website/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/static/description/1.JPG b/sem_seo_toolkit_website/static/description/1.JPG
new file mode 100644
index 000000000..648d929e8
Binary files /dev/null and b/sem_seo_toolkit_website/static/description/1.JPG differ
diff --git a/sem_seo_toolkit_website/static/description/index.html b/sem_seo_toolkit_website/static/description/index.html
new file mode 100644
index 000000000..154f3ac10
--- /dev/null
+++ b/sem_seo_toolkit_website/static/description/index.html
@@ -0,0 +1,5 @@
+
+
Description
+Allows people to perform SEO reports from the website
+
+
\ No newline at end of file
diff --git a/sem_seo_toolkit_website/views/sem_seo_toolkit_website_templates.xml b/sem_seo_toolkit_website/views/sem_seo_toolkit_website_templates.xml
new file mode 100644
index 000000000..69fc24fc0
--- /dev/null
+++ b/sem_seo_toolkit_website/views/sem_seo_toolkit_website_templates.xml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
SEO Report
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Checks
+
+
Check Name
Result
Notes
+
+
+
+
+
+ ✔
+
+
+ ✖
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/sms_frame/__init__.py b/sms_frame/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/sms_frame/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/sms_frame/__manifest__.py b/sms_frame/__manifest__.py
new file mode 100644
index 000000000..672e499f6
--- /dev/null
+++ b/sms_frame/__manifest__.py
@@ -0,0 +1,31 @@
+{
+ 'name': "SMS Framework",
+ 'version': "1.0.6",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary':'Allows you to send and receive smses from multiple gateways',
+ 'description':'Allows you to send and receive smses from multiple gateways',
+ 'license':'LGPL-3',
+ 'data': [
+ 'data/ir.cron.xml',
+ 'data/ir.model.access.csv',
+ 'data/sms.gateway.csv',
+ 'data/mail.message.subtype.csv',
+ 'views/sms_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/sms_message_views.xml',
+ 'views/sms_template_views.xml',
+ 'views/sms_account_views.xml',
+ 'views/sms_number_views.xml',
+ 'views/sms_compose_views.xml',
+ 'views/ir_actions_server_views.xml',
+ 'views/ir_actions_todo.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'depends': ['mail','base_automation'],
+ 'images':[
+ 'static/description/3.jpg',
+ ],
+ 'qweb': ['static/src/xml/sms_compose.xml'],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/sms_frame/controllers/__init__.py b/sms_frame/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/sms_frame/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/sms_frame/controllers/main.py b/sms_frame/controllers/main.py
new file mode 100644
index 000000000..603359479
--- /dev/null
+++ b/sms_frame/controllers/main.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+import openerp.http as http
+from openerp.http import request
+import base64
+import odoo
+import logging
+_logger = logging.getLogger(__name__)
+
+def binary_content(xmlid=None, model='ir.attachment', id=None, field='datas', unique=False, filename=None, filename_field='datas_fname', download=False, mimetype=None, default_mimetype='application/octet-stream', env=None):
+ return request.registry['ir.http'].binary_content(
+ xmlid=xmlid, model=model, id=id, field=field, unique=unique, filename=filename, filename_field=filename_field,
+ download=download, mimetype=mimetype, default_mimetype=default_mimetype, env=env)
+
+class TwilioController(http.Controller):
+
+ @http.route('/sms/twilio/mms//', type="http", auth="public", csrf=False)
+ def sms_twilio_mms(self, attachment_id, filename):
+ """Disable public access to MMS after Twilio has fetched it"""
+
+ attachment = request.env['ir.attachment'].browse( int(attachment_id) )
+
+ if attachment.public == True and attachment.mms == True:
+
+ status, headers, content = binary_content(model='ir.attachment', id=attachment.id, field='datas')
+
+ content_base64 = base64.b64decode(attachment.datas)
+ headers.append(('Content-Length', len(content_base64)))
+
+ #Special expection
+ _logger.error(attachment.datas_fname)
+ if ".mp4" in str(attachment.datas_fname):
+ headers.append(('Content-Type', 'video/mp4'))
+
+ response = request.make_response(content_base64, headers)
+
+ #Disable public access since the mms could contain confidential information
+ attachment.sudo().write({'public': False})
+
+ return response
+
+ else:
+ return "Access Denied"
+
+ @http.route('/sms/twilio/receipt', type="http", auth="public", csrf=False)
+ def sms_twilio_receipt(self, **kwargs):
+ """Update the state of a sms message, don't trust the posted data"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ request.env['sms.gateway.twilio'].sudo().delivary_receipt(values['AccountSid'], values['MessageSid'])
+
+ return ""
+
+ @http.route('/sms/twilio/receive', type="http", auth="public", csrf=False)
+ def sms_twilio_receive(self, **kwargs):
+ """Fetch the new message directly from Twilio, don't trust posted data"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ twilio_account = request.env['sms.account'].sudo().search([('twilio_account_sid','=', values['AccountSid'])])
+ request.env['sms.gateway.twilio'].sudo().check_messages(twilio_account.id, values['MessageSid'])
+
+ return ""
\ No newline at end of file
diff --git a/sms_frame/data/ir.cron.xml b/sms_frame/data/ir.cron.xml
new file mode 100644
index 000000000..0134d8035
--- /dev/null
+++ b/sms_frame/data/ir.cron.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+ SMS Check
+
+ code
+ model.check_all_messages()
+
+
+ 1
+ hours
+ -1
+
+
+
+
+ SMS Queue Check
+
+ code
+ model.process_sms_queue(20)
+
+
+ 1
+ minutes
+ -1
+
+
+
+
+
\ No newline at end of file
diff --git a/sms_frame/data/ir.model.access.csv b/sms_frame/data/ir.model.access.csv
new file mode 100644
index 000000000..37d5482fe
--- /dev/null
+++ b/sms_frame/data/ir.model.access.csv
@@ -0,0 +1,13 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_sms_account_user,sms.account.user,model_sms_account,base.group_user,1,0,0,0
+access_sms_account_system,sms.account.system,model_sms_account,base.group_system,1,1,1,1
+access_sms_compose_user,sms.compose.user,model_sms_compose,base.group_user,1,1,1,0
+access_sms_compose_system,sms.compose.system,model_sms_compose,base.group_system,1,1,1,0
+access_sms_gateway_user,sms.gateway.user,model_sms_gateway,base.group_user,1,0,0,0
+access_sms_gateway_system,sms.gateway.system,model_sms_gateway,base.group_system,1,1,1,1
+access_sms_message_user,sms.message.user,model_sms_message,base.group_user,1,0,1,0
+access_sms_message_system,sms.message.system,model_sms_message,base.group_system,1,1,1,1
+access_sms_number_user,sms.number.user,model_sms_number,base.group_user,1,0,0,0
+access_sms_number_system,sms.number.system,model_sms_number,base.group_system,1,1,1,1
+access_sms_template_user,sms.template.user,model_sms_template,base.group_user,1,1,1,0
+access_sms_template_system,sms.template.system,model_sms_template,base.group_system,1,1,1,1
diff --git a/sms_frame/data/mail.message.subtype.csv b/sms_frame/data/mail.message.subtype.csv
new file mode 100644
index 000000000..31153b231
--- /dev/null
+++ b/sms_frame/data/mail.message.subtype.csv
@@ -0,0 +1,2 @@
+"id","name","internal","default","sequence"
+"sms_subtype","SMS","1","0","2"
\ No newline at end of file
diff --git a/sms_frame/data/sms.gateway.csv b/sms_frame/data/sms.gateway.csv
new file mode 100644
index 000000000..b825caefc
--- /dev/null
+++ b/sms_frame/data/sms.gateway.csv
@@ -0,0 +1,2 @@
+"id","gateway_model_name","name"
+"sms_twilio","sms.gateway.twilio","TWILIO"
\ No newline at end of file
diff --git a/sms_frame/doc/changelog.rst b/sms_frame/doc/changelog.rst
new file mode 100644
index 000000000..89709462d
--- /dev/null
+++ b/sms_frame/doc/changelog.rst
@@ -0,0 +1,27 @@
+v1.0.6
+======
+* Type cast fix
+
+v1.0.5
+======
+* Fix delivery receipts
+
+v1.0.4
+======
+* Rework auto e.164 converesion so it works if the data is imported, mass edited through module or created through a website form
+
+v1.0.3
+======
+* Resolve get record name when model does not have name field
+
+v1.0.2
+======
+* Fix Send SMS automated action not appearing in list
+
+v1.0.1
+======
+* Fix real time sms receive bug
+
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/sms_frame/models/__init__.py b/sms_frame/models/__init__.py
new file mode 100644
index 000000000..7efb90e0c
--- /dev/null
+++ b/sms_frame/models/__init__.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from . import res_partner
+from . import sms_account
+from . import res_country
+from . import ir_actions_server
+from . import sms_number
+from . import sms_message
+from . import sms_gateway
+from . import sms_compose
+from . import sms_template
+from . import sms_gateway_twilio
+from . import ir_attachment
\ No newline at end of file
diff --git a/sms_frame/models/ir_actions_server.py b/sms_frame/models/ir_actions_server.py
new file mode 100644
index 000000000..0fbd29c8f
--- /dev/null
+++ b/sms_frame/models/ir_actions_server.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class IrActionsServer(models.Model):
+
+ _inherit = 'ir.actions.server'
+
+ state = fields.Selection(selection_add=[('sms', 'Send SMS')])
+ sms_template_id = fields.Many2one('sms.template',string="SMS Template")
+
+ @api.model
+ def run_action_sms(self, action, eval_context=None):
+ if not action.sms_template_id:
+ return False
+ self.env['sms.template'].send_sms(action.sms_template_id.id, self.env.context.get('active_id'))
+ return False
diff --git a/sms_frame/models/ir_attachment.py b/sms_frame/models/ir_attachment.py
new file mode 100644
index 000000000..943219d58
--- /dev/null
+++ b/sms_frame/models/ir_attachment.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class IrAttachmentSMS(models.Model):
+
+ _inherit = "ir.attachment"
+
+ mms = fields.Boolean(string="MMS")
\ No newline at end of file
diff --git a/sms_frame/models/res_country.py b/sms_frame/models/res_country.py
new file mode 100644
index 000000000..5c79238a6
--- /dev/null
+++ b/sms_frame/models/res_country.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class ResCountrySms(models.Model):
+
+ _inherit = "res.country"
+
+ mobile_prefix = fields.Char(string="Mobile Prefix")
\ No newline at end of file
diff --git a/sms_frame/models/res_partner.py b/sms_frame/models/res_partner.py
new file mode 100644
index 000000000..a8035285a
--- /dev/null
+++ b/sms_frame/models/res_partner.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models
+
+class ResPartnerSms(models.Model):
+
+ _inherit = "res.partner"
+
+ @api.multi
+ def sms_action(self):
+ self.ensure_one()
+
+ default_mobile = self.env['sms.number'].search([])[0]
+
+ return {
+ 'name': 'SMS Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'sms.compose',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': {'default_from_mobile_id': default_mobile.id,'default_to_number':self.mobile, 'default_record_id':self.id,'default_model':'res.partner'}
+ }
+
+ def e164_convert(self, phone_code, mobile):
+ if mobile:
+ if phone_code:
+ if mobile.startswith("0"):
+ return "+" + str(phone_code) + mobile[1:].replace(" ","")
+ elif mobile.startswith("+"):
+ return mobile.replace(" ","")
+ else:
+ return "+" + str(phone_code) + mobile.replace(" ","")
+ else:
+ return mobile.replace(" ","")
+
+ @api.model
+ def create(self, vals):
+ if 'country_id' in vals and 'mobile' in vals:
+ phone_code = self.env['res.country'].browse(int(vals['country_id'])).phone_code
+ vals['mobile'] = self.e164_convert(phone_code, vals['mobile'])
+ return super(ResPartnerSms, self).create(vals)
+
+ @api.multi
+ def write(self, vals):
+ if 'country_id' in vals and 'mobile' in vals:
+ phone_code = self.env['res.country'].browse(int(vals['country_id'])).phone_code
+ vals['mobile'] = self.e164_convert(phone_code, vals['mobile'])
+ if 'country_id' in vals and 'mobile' not in vals:
+ phone_code = self.env['res.country'].browse(int(vals['country_id'])).phone_code
+ vals['mobile'] = self.e164_convert(phone_code, self.mobile)
+ if 'country_id' not in vals and 'mobile' in vals:
+ vals['mobile'] = self.e164_convert(self.country_id.phone_code, vals['mobile'])
+ return super(ResPartnerSms, self).write(vals)
\ No newline at end of file
diff --git a/sms_frame/models/sms_account.py b/sms_frame/models/sms_account.py
new file mode 100644
index 000000000..41d002c20
--- /dev/null
+++ b/sms_frame/models/sms_account.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class SmsAccount(models.Model):
+
+ _name = "sms.account"
+
+ name = fields.Char(string='Account Name', required=True)
+ account_gateway_id = fields.Many2one('sms.gateway', string="Account Gateway", required=True)
+ gateway_model = fields.Char(string="Gateway Model", related="account_gateway_id.gateway_model_name")
+
+ def send_message(self, from_number, to_number, sms_content, my_model_name='', my_record_id=0, media=None, queued_sms_message=None, media_filename=None):
+ """Send a message from this account"""
+ return self.env[self.gateway_model].send_message(self.id, from_number, to_number, sms_content, my_model_name, my_record_id, media, queued_sms_message, media_filename=media_filename)
+
+
+ @api.model
+ def check_all_messages(self):
+ """Check for any messages that might have been missed during server downtime"""
+ my_accounts = self.env['sms.account'].search([])
+ for sms_account in my_accounts:
+ self.env[sms_account.account_gateway_id.gateway_model_name].check_messages(sms_account.id)
\ No newline at end of file
diff --git a/sms_frame/models/sms_compose.py b/sms_frame/models/sms_compose.py
new file mode 100644
index 000000000..5b84483ee
--- /dev/null
+++ b/sms_frame/models/sms_compose.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*
+from datetime import datetime
+import logging
+_logger = logging.getLogger(__name__)
+import base64
+from openerp import api, fields, models
+
+class sms_response():
+ delivary_state = ""
+ response_string = ""
+ human_read_error = ""
+ mms_url = ""
+ message_id = ""
+
+class SmsCompose(models.Model):
+
+ _name = "sms.compose"
+
+ error_message = fields.Char(readonly=True)
+ record_id = fields.Integer()
+ model = fields.Char()
+ sms_template_id = fields.Many2one('sms.template', string="Template")
+ from_mobile_id = fields.Many2one('sms.number', required=True, string="From Mobile")
+ to_number = fields.Char(required=True, string='To Mobile Number', readonly=True)
+ sms_content = fields.Text(string='SMS Content')
+ media_id = fields.Binary(string="Media (MMS)")
+ media_filename = fields.Char(string="Media Filename")
+ delivery_time = fields.Datetime(string="Delivery Time")
+
+ @api.onchange('sms_template_id')
+ def _onchange_sms_template_id(self):
+ """Prefills from mobile, sms_account and sms_content but allow them to manually change the content after"""
+ if self.sms_template_id.id != False:
+
+ sms_rendered_content = self.env['sms.template'].render_template(self.sms_template_id.template_body, self.sms_template_id.model_id.model, self.record_id)
+
+ self.from_mobile_id = self.sms_template_id.from_mobile_verified_id.id
+ self.media_id = self.sms_template_id.media_id
+ self.media_filename = self.sms_template_id.media_filename
+ self.sms_content = sms_rendered_content
+
+ @api.multi
+ def send_entity(self):
+ """Attempt to send the sms, if any error comes back show it to the user and only log the smses that successfully sent"""
+ self.ensure_one()
+
+ gateway_model = self.from_mobile_id.account_id.account_gateway_id.gateway_model_name
+
+ if self.delivery_time:
+ #Create the queued sms
+ my_model = self.env['ir.model'].search([('model','=',self.model)])
+ sms_message = self.env['sms.message'].create({'record_id': self.record_id,'model_id':my_model[0].id,'account_id':self.from_mobile_id.account_id.id,'from_mobile':self.from_mobile_id.mobile_number,'to_mobile':self.to_number,'sms_content':self.sms_content,'status_string':'-', 'direction':'O','message_date':self.delivery_time, 'status_code':'queued', 'by_partner_id':self.env.user.partner_id.id})
+
+ sms_subtype = self.env['ir.model.data'].get_object('sms_frame', 'sms_subtype')
+ attachments = []
+
+ if self.media_id:
+ attachments.append((self.media_filename, base64.b64decode(self.media_id)) )
+
+ self.env[self.model].search([('id','=', self.record_id)]).message_post(body=self.sms_content, subject="SMS Sent", message_type="comment", subtype_id=sms_subtype.id, attachments=attachments)
+
+ return True
+ else:
+ my_sms = self.from_mobile_id.account_id.send_message(self.from_mobile_id.mobile_number, self.to_number, self.sms_content, self.model, self.record_id, self.media_id, media_filename=self.media_filename)
+
+ #use the human readable error message if present
+ error_message = ""
+ if my_sms.human_read_error != "":
+ error_message = my_sms.human_read_error
+ else:
+ error_message = my_sms.response_string
+
+ #display the screen with an error code if the sms/mms was not successfully sent
+ if my_sms.delivary_state == "failed":
+ return {
+ 'type':'ir.actions.act_window',
+ 'res_model':'sms.compose',
+ 'view_type':'form',
+ 'view_mode':'form',
+ 'target':'new',
+ 'context':{'default_to_number':self.to_number,'default_record_id':self.record_id,'default_model':self.model, 'default_error_message':error_message}
+ }
+ else:
+
+ my_model = self.env['ir.model'].search([('model','=',self.model)])
+
+ #for single smses we only record succesful sms, failed ones reopen the form with the error message
+ sms_message = self.env['sms.message'].create({'record_id': self.record_id,'model_id':my_model[0].id,'account_id':self.from_mobile_id.account_id.id,'from_mobile':self.from_mobile_id.mobile_number,'to_mobile':self.to_number,'sms_content':self.sms_content,'status_string':my_sms.response_string, 'direction':'O','message_date':datetime.utcnow(), 'status_code':my_sms.delivary_state, 'sms_gateway_message_id':my_sms.message_id, 'by_partner_id':self.env.user.partner_id.id})
+
+ sms_subtype = self.env['ir.model.data'].get_object('sms_frame', 'sms_subtype')
+ attachments = []
+
+ if self.media_id:
+ attachments.append((self.media_filename, base64.b64decode(self.media_id)) )
+
+ self.env[self.model].search([('id','=', self.record_id)]).message_post(body=self.sms_content, subject="SMS Sent", message_type="comment", subtype_id=sms_subtype.id, attachments=attachments)
\ No newline at end of file
diff --git a/sms_frame/models/sms_gateway.py b/sms_frame/models/sms_gateway.py
new file mode 100644
index 000000000..948ccd5fa
--- /dev/null
+++ b/sms_frame/models/sms_gateway.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class SmsGateway(models.Model):
+
+ _name = "sms.gateway"
+
+ name = fields.Char(required=True, string='Gateway Name')
+ gateway_model_name = fields.Char(required='True', string='Gateway Model Name')
\ No newline at end of file
diff --git a/sms_frame/models/sms_gateway_twilio.py b/sms_frame/models/sms_gateway_twilio.py
new file mode 100644
index 000000000..b130fce3c
--- /dev/null
+++ b/sms_frame/models/sms_gateway_twilio.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+import requests
+from datetime import datetime
+from lxml import etree
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp.http import request
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+
+class sms_response():
+ delivary_state = ""
+ response_string = ""
+ human_read_error = ""
+ mms_url = ""
+ message_id = ""
+
+class SmsGatewayTwilio(models.Model):
+
+ _name = "sms.gateway.twilio"
+ _description = "Twilio SMS Gateway"
+
+ api_url = fields.Char(string='API URL')
+
+ def send_message(self, sms_gateway_id, from_number, to_number, sms_content, my_model_name='', my_record_id=0, media=None, queued_sms_message=None, media_filename=False):
+ """Actual Sending of the sms"""
+ sms_account = self.env['sms.account'].search([('id','=',sms_gateway_id)])
+
+ #format the from number before sending
+ format_from = from_number
+ if " " in format_from: format_from.replace(" ", "")
+
+ #format the to number before sending
+ format_to = to_number
+ if " " in format_to: format_to.replace(" ", "")
+
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+
+ media_url = ""
+ #Create an attachment for the mms now since we need a url now
+ if media:
+
+ attachment_id = self.env['ir.attachment'].sudo().create({'name': 'mms ' + str(my_record_id), 'type': 'binary', 'datas': media, 'public': True, 'mms': True, 'datas_fname': media_filename})
+
+ #Force the creation of the new attachment before you make the request
+ request.env.cr.commit()
+
+ if media_filename:
+ media_url = base_url + "/sms/twilio/mms/" + str(attachment_id.id) + "/" + media_filename
+ else:
+ media_url = base_url + "/sms/twilio/mms/" + str(attachment_id.id) + "/media." + attachment_id.mimetype.split("/")[1]
+
+
+ #send the sms/mms
+ payload = {'From': format_from, 'To': format_to, 'Body': sms_content, 'StatusCallback': base_url + "/sms/twilio/receipt"}
+
+ if queued_sms_message:
+ for mms_attachment in queued_sms_message.attachment_ids:
+ #For now we only support a single MMS per message but that will change in future versions
+ payload['MediaUrl'] = base_url + "/web/image/" + str(mms_attachment.id) + "/media." + mms_attachment.mimetype.split("/")[1]
+
+ if media:
+ payload['MediaUrl'] = media_url
+
+ response_string = requests.post("https://api.twilio.com/2010-04-01/Accounts/" + str(sms_account.twilio_account_sid) + "/Messages", data=payload, auth=(str(sms_account.twilio_account_sid), str(sms_account.twilio_auth_token)))
+
+ #Analyse the reponse string and determine if it sent successfully other wise return a human readable error message
+ human_read_error = ""
+ root = etree.fromstring(response_string.text.encode("utf-8"))
+ my_elements_human = root.xpath('/TwilioResponse/RestException/Message')
+ if len(my_elements_human) != 0:
+ human_read_error = my_elements_human[0].text
+
+ #The message id is important for delivary reports also set delivary_state=successful
+ sms_gateway_message_id = ""
+ delivary_state = "failed"
+ my_elements = root.xpath('//Sid')
+ if len(my_elements) != 0:
+ sms_gateway_message_id = my_elements[0].text
+ delivary_state = "successful"
+
+ #send a repsonse back saying how the sending went
+ my_sms_response = sms_response()
+ my_sms_response.delivary_state = delivary_state
+ my_sms_response.response_string = response_string.text
+ my_sms_response.human_read_error = human_read_error
+ my_sms_response.message_id = sms_gateway_message_id
+ return my_sms_response
+
+ def check_messages(self, account_id, message_id=""):
+ """Checks for any new messages or if the message id is specified get only that message"""
+ sms_account = self.env['sms.account'].browse(account_id)
+
+ if message_id != "":
+ payload = {}
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + sms_account.twilio_account_sid + "/Messages/" + message_id, data=payload, auth=(str(sms_account.twilio_account_sid), str(sms_account.twilio_auth_token)))
+ twil_xml = response_string.text.encode('utf-8')
+ parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
+ root = etree.fromstring(twil_xml, parser=parser)
+ my_messages = root.xpath('//Message')
+ sms_message = my_messages[0]
+ #only get the inbound ones as we track the outbound ones back to a user profile
+ if sms_message.xpath('//Direction')[0].text == "inbound":
+ self._add_message(sms_message, account_id)
+ else:
+ #get a list of all new inbound message since the last check date
+ payload = {}
+ if sms_account.twilio_last_check_date != False:
+ my_time = datetime.strptime(sms_account.twilio_last_check_date,'%Y-%m-%d %H:%M:%S')
+ payload = {'DateSent>': str(my_time.strftime('%Y-%m-%d'))}
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + sms_account.twilio_account_sid + "/Messages", data=payload, auth=(str(sms_account.twilio_account_sid), str(sms_account.twilio_auth_token)))
+ root = etree.fromstring(response_string.text.encode("utf-8"))
+
+ #get all pages
+ messages_tag = root.xpath('//Messages')
+
+ #Loop through all pages until you have reached the end
+ while True:
+
+ my_messages = messages_tag[0].xpath('//Message')
+ for sms_message in my_messages:
+
+ #only get the inbound ones as we track the outbound ones back to a user profile
+ if sms_message.find('Direction').text == "inbound":
+ self._add_message(sms_message, account_id)
+
+ #get the next page if there is one
+ next_page_uri = messages_tag[0].attrib['nextpageuri']
+ if next_page_uri != "":
+ response_string = requests.get("https://api.twilio.com" + messages_tag[0].attrib['nextpageuri'], data=payload, auth=(str(sms_account.twilio_account_sid), str(sms_account.twilio_auth_token)))
+ root = etree.fromstring(response_string.text.encode("utf-8"))
+ messages_tag = root.xpath('//Messages')
+
+ #End the loop if there are no more pages
+ if next_page_uri == "":
+ break
+
+ sms_account.twilio_last_check_date = datetime.utcnow()
+
+ def _add_message(self, sms_message, account_id):
+ """Adds the new sms to the system"""
+ delivary_state = ""
+ if sms_message.find('Status').text == "failed":
+ delivary_state = "failed"
+ elif sms_message.find('Status').text == "sent":
+ delivary_state = "successful"
+ elif sms_message.find('Status').text == "delivered":
+ delivary_state = "DELIVRD"
+ elif sms_message.find('Status').text == "undelivered":
+ delivary_state = "UNDELIV"
+ elif sms_message.find('Status').text == "received":
+ delivary_state = "RECEIVED"
+
+ my_message = self.env['sms.message'].search([('sms_gateway_message_id','=', sms_message.find('Sid').text)])
+ if len(my_message) == 0 and sms_message.find('Direction').text == "inbound":
+
+ target = self.env['sms.message'].find_owner_model(sms_message)
+
+ twilio_gateway_id = self.env['sms.gateway'].search([('gateway_model_name', '=', 'sms.gateway.twilio')])
+
+ discussion_subtype = self.env['ir.model.data'].get_object('mail', 'mt_comment')
+ my_message = ""
+
+ attachments = []
+
+ _logger.error(sms_message.find('NumMedia').text)
+ if int(sms_message.find('NumMedia').text) > 0:
+ sms_account = self.env['sms.account'].browse(account_id)
+
+ for sub_resource in sms_message.find('SubresourceUris'):
+ media_list_url = sub_resource.text
+ _logger.error(media_list_url)
+
+ media_response_string = requests.get("https://api.twilio.com" + media_list_url, auth=(str(sms_account.twilio_account_sid), str(sms_account.twilio_auth_token)))
+
+ media_root = etree.fromstring(media_response_string.text.encode("utf-8"))
+ for media_mms in media_root.xpath('//MediaList/Media'):
+ first_media_url = media_mms.find('Uri').text
+ media_filename = media_mms.find("Sid").text + ".jpg"
+ attachments.append((media_filename, requests.get("https://api.twilio.com" + first_media_url).content) )
+
+ from_record = self.env['res.partner'].sudo().search([('mobile','=', sms_message.find('From').text)])
+
+ if from_record:
+ message_subject = "SMS Received from " + from_record.name
+ else:
+ message_subject = "SMS Received from " + sms_message.find('From').text
+
+ if target['target_model'] == "res.partner":
+ model_id = self.env['ir.model'].search([('model','=', target['target_model'])])
+
+ my_record = self.env[target['target_model']].browse( int(target['record_id'].id) )
+ my_message = my_record.message_post(body=sms_message.find('Body').text, subject=message_subject, subtype_id=discussion_subtype.id, author_id=my_record.id, message_type="comment", attachments=attachments)
+
+ #Notify followers of this partner who are listenings to the 'discussions' subtype
+ for notify_partner in self.env['mail.followers'].search([('res_model','=','res.partner'),('res_id','=',target['record_id'].id), ('subtype_ids','=',discussion_subtype.id)]):
+ my_message.needaction_partner_ids = [(4,notify_partner.partner_id.id)]
+
+ #Create the sms record in history
+ history_id = self.env['sms.message'].create({'account_id': account_id, 'status_code': "RECEIVED", 'from_mobile': sms_message.find('From').text, 'to_mobile': sms_message.find('To').text, 'sms_gateway_message_id': sms_message.find('Sid').text, 'sms_content': sms_message.find('Body').text, 'direction':'I', 'message_date':sms_message.find('DateUpdated').text, 'model_id':model_id.id, 'record_id':int(target['record_id'].id), 'by_partner_id': my_record.id})
+ elif target['target_model'] == "crm.lead":
+ model_id = self.env['ir.model'].search([('model','=', target['target_model'])])
+
+ my_record = self.env[target['target_model']].browse( int(target['record_id'].id) )
+ my_message = my_record.message_post(body=sms_message.find('Body').text, subject=message_subject, subtype_id=discussion_subtype.id, message_type="comment", attachments=attachments)
+
+ #Notify followers of this lead who are listenings to the 'discussions' subtype
+ for notify_partner in self.env['mail.followers'].search([('res_model','=','crm.lead'),('res_id','=',target['record_id'].id), ('subtype_ids','=',discussion_subtype.id)]):
+ my_message.needaction_partner_ids = [(4,notify_partner.partner_id.id)]
+
+ #Create the sms record in history
+ history_id = self.env['sms.message'].create({'account_id': account_id, 'status_code': "RECEIVED", 'from_mobile': sms_message.find('From').text, 'to_mobile': sms_message.find('To').text, 'sms_gateway_message_id': sms_message.find('Sid').text, 'sms_content': sms_message.find('Body').text, 'direction':'I', 'message_date':sms_message.find('DateUpdated').text, 'model_id':model_id.id, 'record_id':int(target['record_id'].id)})
+ else:
+ #Create the sms record in history without the model or record_id
+ history_id = self.env['sms.message'].create({'account_id': account_id, 'status_code': "RECEIVED", 'from_mobile': sms_message.find('From').text, 'to_mobile': sms_message.find('To').text, 'sms_gateway_message_id': sms_message.find('Sid').text, 'sms_content': sms_message.find('Body').text, 'direction':'I', 'message_date':sms_message.find('DateUpdated').text})
+
+ def delivary_receipt(self, account_sid, message_id):
+ """Updates the sms message when it is successfully received by the mobile phone"""
+ my_account = self.env['sms.account'].search([('twilio_account_sid','=', account_sid)])[0]
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + my_account.twilio_account_sid + "/Messages/" + message_id, auth=(str(my_account.twilio_account_sid), str(my_account.twilio_auth_token)))
+
+ twil_xml = response_string.text.encode('utf-8')
+ parser = etree.XMLParser(ns_clean=True, recover=True, encoding='utf-8')
+ root = etree.fromstring(twil_xml, parser=parser)
+
+ #map the Twilio delivary code to the sms delivary states
+ delivary_state = ""
+ if root.xpath('//Status')[0].text == "failed":
+ delivary_state = "failed"
+ elif root.xpath('//Status')[0].text == "sent":
+ delivary_state = "successful"
+ elif root.xpath('//Status')[0].text == "delivered":
+ delivary_state = "DELIVRD"
+ elif root.xpath('//Status')[0].text == "undelivered":
+ delivary_state = "UNDELIV"
+
+ my_message = self.env['sms.message'].search([('sms_gateway_message_id','=', message_id)])
+ if len(my_message) > 0:
+ my_message[0].status_code = delivary_state
+ my_message[0].delivary_error_string = root.xpath('//ErrorMessage')[0].text
+
+class SmsAccountTwilio(models.Model):
+
+ _inherit = "sms.account"
+ _description = "Adds the Twilio specfic gateway settings to the sms gateway accounts"
+
+ twilio_account_sid = fields.Char(string='Account SID')
+ twilio_auth_token = fields.Char(string='Auth Token')
+ twilio_last_check_date = fields.Datetime(string="Last Check Date")
+
+ @api.one
+ def twilio_quick_setup(self):
+ """Configures your Twilio account so inbound messages point to your server, also adds mobile numbers to the system"""
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ if response_string.status_code == 200:
+ response_string_twilio_numbers = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ #go through each twilio number in the account and set the the sms url
+ root = etree.fromstring(response_string_twilio_numbers.text.encode("utf-8"))
+ my_from_number_list = root.xpath('//IncomingPhoneNumber')
+ for my_from_number in my_from_number_list:
+ av_phone = my_from_number.xpath('//PhoneNumber')[0].text
+ sid = my_from_number.xpath('//Sid')[0].text
+
+ #Create a new mobile number
+ if self.env['sms.number'].search_count([('mobile_number','=',av_phone)]) == 0:
+ vsms = self.env['sms.number'].create({'name': av_phone, 'mobile_number': av_phone,'account_id':self.id})
+
+ payload = {'SmsUrl': str(request.httprequest.host_url + "sms/twilio/receive")}
+ requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers/" + sid, data=payload, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ #Check for new messages
+ self.env['sms.gateway.twilio'].check_messages(self.id)
+ else:
+ UserError("Bad Credentials")
\ No newline at end of file
diff --git a/sms_frame/models/sms_message.py b/sms_frame/models/sms_message.py
new file mode 100644
index 000000000..9d2d35476
--- /dev/null
+++ b/sms_frame/models/sms_message.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime
+
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+from openerp import api, fields, models
+
+class SmsMessage(models.Model):
+
+ _name = "sms.message"
+ _order = "message_date desc"
+
+ record_id = fields.Integer(readonly=True, string="Record")
+ account_id = fields.Many2one('sms.account', readonly=True, string="SMS Account")
+ model_id = fields.Many2one('ir.model', readonly=True, string="Model")
+ by_partner_id = fields.Many2one('res.partner', string="By")
+ from_mobile = fields.Char(string="From Mobile", readonly=True)
+ to_mobile = fields.Char(string="To Mobile", readonly=True)
+ sms_content = fields.Text(string="SMS Message", readonly=True)
+ record_name = fields.Char(string="Record Name", compute="_compute_record_name")
+ status_string = fields.Char(string="Response String", readonly=True)
+ status_code = fields.Selection((('RECEIVED','Received'), ('failed', 'Failed to Send'), ('queued', 'Queued'), ('successful', 'Sent'), ('DELIVRD', 'Delivered'), ('EXPIRED','Timed Out'), ('UNDELIV', 'Undelivered')), string='Delivary State', readonly=True)
+ sms_gateway_message_id = fields.Char(string="SMS Gateway Message ID", readonly=True)
+ direction = fields.Selection((("I","INBOUND"),("O","OUTBOUND")), string="Direction", readonly=True)
+ message_date = fields.Datetime(string="Send/Receive Date", readonly=True, help="The date and time the sms is received or sent")
+ media_id = fields.Binary(string="Media(MMS)")
+ attachment_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', 'sms.message')], string="MMS Attachments")
+
+ @api.one
+ @api.depends('to_mobile', 'model_id', 'record_id')
+ def _compute_record_name(self):
+ """Get the name of the record that this sms was sent to"""
+ if self.model_id.model != False and self.record_id != False:
+ my_record_count = self.env[self.model_id.model].search_count([('id','=',self.record_id)])
+ if my_record_count > 0:
+ my_record = self.env[self.model_id.model].search([('id','=',self.record_id)])
+ self.record_name = my_record.name_get()[0][1]
+ else:
+ self.record_name = self.to_mobile
+
+ def find_owner_model(self, sms_message):
+ """Gets the model and record this sms is meant for"""
+ #look for a partner with this number
+ partner_id = self.env['res.partner'].search([('mobile','=', sms_message.find('From').text)])
+ if len(partner_id) > 0:
+ return {'record_id': partner_id[0], 'target_model': "res.partner"}
+ else:
+ return {'record_id': 0, 'target_model': ""}
+
+ @api.model
+ def process_sms_queue(self, queue_limit):
+ #queue_limit = self.env['ir.model.data'].get_object('sms_frame', 'sms_queue_check').args
+ for queued_sms in self.env['sms.message'].search([('status_code','=','queued'), ('message_date','<=', datetime.today().strftime(DEFAULT_SERVER_DATETIME_FORMAT) ) ], limit=queue_limit):
+ gateway_model = queued_sms.account_id.account_gateway_id.gateway_model_name
+ my_sms = queued_sms.account_id.send_message(queued_sms.from_mobile, queued_sms.to_mobile, queued_sms.sms_content, queued_sms.model_id.model, queued_sms.record_id, queued_sms.media_id, queued_sms_message=queued_sms)
+
+ #Mark it as sent to avoid it being sent again
+ queued_sms.status_code = my_sms.delivary_state
+
+ #record the message in the communication log
+ self.env[queued_sms.model_id.model].browse(queued_sms.record_id).message_post(body=queued_sms.sms_content, subject="SMS")
\ No newline at end of file
diff --git a/sms_frame/models/sms_number.py b/sms_frame/models/sms_number.py
new file mode 100644
index 000000000..868207881
--- /dev/null
+++ b/sms_frame/models/sms_number.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class SmsNumber(models.Model):
+
+ _name = "sms.number"
+
+ name = fields.Char(string="Name", translate=True)
+ mobile_number = fields.Char(string="Sender ID", help="A mobile phone number or a 1-11 character alphanumeric name")
+ account_id = fields.Many2one('sms.account', string="Account")
\ No newline at end of file
diff --git a/sms_frame/models/sms_template.py b/sms_frame/models/sms_template.py
new file mode 100644
index 000000000..47d31e822
--- /dev/null
+++ b/sms_frame/models/sms_template.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime
+import functools
+from werkzeug import urls
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models, tools
+
+try:
+ # We use a jinja2 sandboxed environment to render mako templates.
+ # Note that the rendering does not cover all the mako syntax, in particular
+ # arbitrary Python statements are not accepted, and not all expressions are
+ # allowed: only "public" attributes (not starting with '_') of objects may
+ # be accessed.
+ # This is done on purpose: it prevents incidental or malicious execution of
+ # Python code that may break the security of the server.
+ from jinja2.sandbox import SandboxedEnvironment
+ mako_template_env = SandboxedEnvironment(
+ block_start_string="<%",
+ block_end_string="%>",
+ variable_start_string="${",
+ variable_end_string="}",
+ comment_start_string="<%doc>",
+ comment_end_string="%doc>",
+ line_statement_prefix="%",
+ line_comment_prefix="##",
+ trim_blocks=True, # do not output newline after blocks
+ autoescape=True, # XML/HTML automatic escaping
+ )
+ mako_template_env.globals.update({
+ 'str': str,
+ 'quote': urls.url_quote,
+ 'urlencode': urls.url_encode,
+ 'datetime': datetime,
+ 'len': len,
+ 'abs': abs,
+ 'min': min,
+ 'max': max,
+ 'sum': sum,
+ 'filter': filter,
+ 'reduce': functools.reduce,
+ 'map': map,
+ 'round': round,
+
+ # dateutil.relativedelta is an old-style class and cannot be directly
+ # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
+ # is needed, apparently.
+ 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
+ })
+except ImportError:
+ _logger.warning("jinja2 not available, templating features will not work!")
+
+
+class SmsTemplate(models.Model):
+
+ _name = "sms.template"
+
+ name = fields.Char(required=True, string='Template Name', translate=True)
+ model_id = fields.Many2one('ir.model', string='Applies to', help="The kind of document with with this template can be used")
+ model = fields.Char(related="model_id.model", string='Related Document Model', store=True, readonly=True)
+ template_body = fields.Text('Body', translate=True, help="Plain text version of the message (placeholders may be used here)")
+ sms_from = fields.Char(string='From (Mobile)', help="Sender mobile number (placeholders may be used here). If not set, the default value will be the author's mobile number.")
+ sms_to = fields.Char(string='To (Mobile)', help="To mobile number (placeholders may be used here)")
+ account_gateway_id = fields.Many2one('sms.account', string="Account")
+ model_object_field_id = fields.Many2one('ir.model.fields', string="Field", help="Select target field from the related document model.\nIf it is a relationship field you will be able to select a target field at the destination of the relationship.")
+ sub_object_id = fields.Many2one('ir.model', string='Sub-model', readonly=True, help="When a relationship field is selected as first field, this field shows the document model the relationship goes to.")
+ sub_model_object_field_id = fields.Many2one('ir.model.fields', string='Sub-field', help="When a relationship field is selected as first field, this field lets you select the target field within the destination document model (sub-model).")
+ null_value = fields.Char(string='Default Value', help="Optional value to use if the target field is empty")
+ copyvalue = fields.Char(string='Placeholder Expression', help="Final placeholder expression, to be copy-pasted in the desired template field.")
+ lang = fields.Char(string='Language', help="Optional translation language (ISO code) to select when sending out an email. If not set, the english version will be used. This should usually be a placeholder expression that provides the appropriate language, e.g. ${object.partner_id.lang}.", placeholder="${object.partner_id.lang}")
+ from_mobile_verified_id = fields.Many2one('sms.number', string="From Mobile (stored)")
+ from_mobile = fields.Char(string="From Mobile", help="Placeholders are allowed here")
+ media_id = fields.Binary(string="Media(MMS)")
+ media_filename = fields.Char(string="Media Filename")
+ media_ids = fields.Many2many('ir.attachment', string="Media(MMS)[Automated Actions Only]")
+
+ @api.onchange('model_object_field_id')
+ def _onchange_model_object_field_id(self):
+ if self.model_object_field_id.relation:
+ self.sub_object_id = self.env['ir.model'].search([('model','=',self.model_object_field_id.relation)])[0].id
+ else:
+ self.sub_object_id = False
+
+ if self.model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ @api.onchange('sub_model_object_field_id')
+ def _onchange_sub_model_object_field_id(self):
+ if self.sub_model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ @api.onchange('from_mobile_verified_id')
+ def _onchange_from_mobile_verified_id(self):
+ if self.from_mobile_verified_id:
+ self.from_mobile = self.from_mobile_verified_id.mobile_number
+
+ @api.model
+ def send_sms(self, template_id, record_id):
+ """Send the sms using all the details in this sms template, using the specified record ID"""
+ sms_template = self.env['sms.template'].browse( int(template_id) )
+
+ rendered_sms_template_body = self.env['sms.template'].render_template(sms_template.template_body, sms_template.model_id.model, record_id)
+
+ rendered_sms_to = self.env['sms.template'].render_template(sms_template.sms_to, sms_template.model_id.model, record_id)
+
+ gateway_model = sms_template.from_mobile_verified_id.account_id.account_gateway_id.gateway_model_name
+
+ #Queue the SMS message since we can't directly send MMS
+ queued_sms = self.env['sms.message'].create({'record_id': record_id,'model_id': sms_template.model_id.id,'account_id':sms_template.from_mobile_verified_id.account_id.id,'from_mobile':sms_template.from_mobile,'to_mobile':rendered_sms_to,'sms_content':rendered_sms_template_body, 'direction':'O','message_date':datetime.utcnow(), 'status_code': 'queued'})
+
+ #Also create the MMS attachment
+ if sms_template.media_id:
+ self.env['ir.attachment'].sudo().create({'name': 'mms ' + str(queued_sms.id), 'type': 'binary', 'datas': sms_template.media_id, 'public': True, 'res_model': 'sms.message', 'res_id': queued_sms.id})
+
+ #Turn the queue manager on
+ self.env['ir.model.data'].get_object('sms_frame', 'sms_queue_check').active = True
+
+ def render_template(self, template, model, res_id):
+ """Render the given template text, replace mako expressions ``${expr}``
+ with the result of evaluating these expressions with
+ an evaluation context containing:
+
+ * ``user``: browse_record of the current user
+ * ``object``: browse_record of the document record this mail is
+ related to
+ * ``context``: the context passed to the mail composition wizard
+
+ :param str template: the template text to render
+ :param str model: model name of the document record this mail is related to.
+ :param int res_id: id of document records those mails are related to.
+ """
+
+ # try to load the template
+ #try:
+ template = mako_template_env.from_string(tools.ustr(template))
+ #except Exception:
+ # _logger.error("Failed to load template %r", template)
+ # return False
+
+ # prepare template variables
+ user = self.env.user
+ record = self.env[model].browse(res_id)
+
+ variables = {
+ 'user': user
+ }
+
+
+
+ variables['object'] = record
+ try:
+ render_result = template.render(variables)
+ except Exception:
+ _logger.error("Failed to render template %r using values %r" % (template, variables))
+ render_result = u""
+ if render_result == u"False":
+ render_result = u""
+
+ return render_result
+
+ @api.model
+ def build_expression(self, field_name, sub_field_name, null_value):
+ """Returns a placeholder expression for use in a template field,
+ based on the values provided in the placeholder assistant.
+
+ :param field_name: main field name
+ :param sub_field_name: sub field name (M2O)
+ :param null_value: default value if the target value is empty
+ :return: final placeholder expression
+ """
+ expression = ''
+ if field_name:
+ expression = "${object." + field_name
+ if sub_field_name:
+ expression += "." + sub_field_name
+ if null_value:
+ expression += " or '''%s'''" % null_value
+ expression += "}"
+ return expression
diff --git a/sms_frame/security/ir.model.access.csv b/sms_frame/security/ir.model.access.csv
new file mode 100644
index 000000000..07e061bfa
--- /dev/null
+++ b/sms_frame/security/ir.model.access.csv
@@ -0,0 +1,11 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_sms_account_user","access_sms_account","model_sms_account","base.group_user",1,0,0,0
+"access_sms_account_system","access_sms_account","model_sms_account","base.group_system",1,1,1,1
+"access_sms_message_user","access_sms_message","model_sms_message","base.group_user",1,0,1,0
+"access_sms_message_system","access_sms_message","model_sms_message","base.group_system",1,1,1,1
+"access_sms_gateway","access_sms_gateway","model_sms_gateway",,1,0,0,0
+"access_sms_number_system","access_sms_number_system","model_sms_number","base.group_system",1,1,1,1
+"access_sms_number_user","access_sms_number_user","model_sms_number","base.group_user",1,0,0,0
+"access_sms_template","access_sms_template","model_sms_template",,1,1,1,1
+"access_sms_compose","access_sms_compose","model_sms_compose",,1,1,1,1
+"access_sms_gateway_twilio","access_sms_gateway_twilio","model_sms_gateway_twilio",,1,0,0,0
\ No newline at end of file
diff --git a/sms_frame/static/description/1.jpg b/sms_frame/static/description/1.jpg
new file mode 100644
index 000000000..33cecd9dc
Binary files /dev/null and b/sms_frame/static/description/1.jpg differ
diff --git a/sms_frame/static/description/2.jpg b/sms_frame/static/description/2.jpg
new file mode 100644
index 000000000..75f2bdf91
Binary files /dev/null and b/sms_frame/static/description/2.jpg differ
diff --git a/sms_frame/static/description/3.jpg b/sms_frame/static/description/3.jpg
new file mode 100644
index 000000000..b7a3e386a
Binary files /dev/null and b/sms_frame/static/description/3.jpg differ
diff --git a/sms_frame/static/description/4.jpg b/sms_frame/static/description/4.jpg
new file mode 100644
index 000000000..233aff5a4
Binary files /dev/null and b/sms_frame/static/description/4.jpg differ
diff --git a/sms_frame/static/description/5.jpg b/sms_frame/static/description/5.jpg
new file mode 100644
index 000000000..46771ccc7
Binary files /dev/null and b/sms_frame/static/description/5.jpg differ
diff --git a/sms_frame/static/description/6.jpg b/sms_frame/static/description/6.jpg
new file mode 100644
index 000000000..ab447828f
Binary files /dev/null and b/sms_frame/static/description/6.jpg differ
diff --git a/sms_frame/static/description/7.jpg b/sms_frame/static/description/7.jpg
new file mode 100644
index 000000000..66878bea0
Binary files /dev/null and b/sms_frame/static/description/7.jpg differ
diff --git a/sms_frame/static/description/icon.png b/sms_frame/static/description/icon.png
new file mode 100644
index 000000000..2d74cd0b5
Binary files /dev/null and b/sms_frame/static/description/icon.png differ
diff --git a/sms_frame/static/description/index.html b/sms_frame/static/description/index.html
new file mode 100644
index 000000000..e22fee218
--- /dev/null
+++ b/sms_frame/static/description/index.html
@@ -0,0 +1,160 @@
+
+
Description
+Send and receive smses using multiple sms gateways
+Comes with Twilio gateway builtin but is designed to allow mulriple gatewayss
+
+
+
+
SMS Accounts
+
+
+
+
+
+
+
+Each account can use a different sms gateway.
+
+Instructions
+1. Login in as an 'Administration Settings' user and activate developer mode
+2. Go to 'Settings->SMS->Accounts'
+3. Enter the api credentials of your sms gateway of choice
+
+
+
+
+
+
+
+
Stored Mobile Numbers
+
+
+Store and send from multiple sender ids with ease
+
+Instructions
+1. Login in as an 'Administration Settings' user and activate developer mode
+2. Go to 'Settings->SMS->Mobile Numbers'
+3. Enter a meaningfull name and a mobile number/alphanumeric sender
+
+
+
+
+
+
+
+
+
+
+
+
+
Simple Send SMS UI
+
+
+
+
+
+
+
+Send smses with a simple user interface.
+
+Instructions
+1. Setup an sms account and stored mobile number(see above)
+2. Go to any partner
+3. Make sure the partner's mobile number is in e164 format
+3. Click on 'action' menu and 'SMS Partner'
+4. Select one of your stored numbers, enter a message and hit 'Send SMS'
+
+
+
+
+
+
+
+
SMS History
+
+
+Keep track of all the sms messages sent out of the system
+
+Instructions
+1. Login in as an 'Administration Settings' user and activate developer mode
+2. Go to 'Settings->SMS->Messages'
+
+
+
+
+
+
+
+
+
+
+
+
+
SMS Templates
+
+
+
+
+
+
+
+Save massive amounts of time.
+
+Instructions
+1. Login in as an 'Administration Settings' user and activate developer mode
+2. Go to 'Settings->SMS->Templates'
+3. Use dynamic placeholders to make things fun
+4. Employees can create templates too from the compose form
+
+
+
+
+
+
+
+
SMS Action
+
+
+Send an sms when records are created or whatever
+
+Instructions
+1. Login in as an 'Administration Settings' user and activate developer mode
+2. Go to 'Settings->Actions->Server Actions'
+3. Select 'Send SMS' as an action
+
+
+
+
+
+
+
+
+
+
+
+
+
Auto E164
+
+
+
+
+
+
+
+Automaticly converts mobile numbers to E164 standard based on the partner's country.
+
+Instructions
+1. Change the partner's country and the international prefix will automaticially added to the mobile field
+
+
+
+
+
+ sms mass tree view
+ sms.mass
+
+
+
+
+
+
+
+
+
+
+ Send Mass SMS
+ sms.mass
+ form
+ tree,form
+
+
+ Start mass SMS.
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/__init__.py b/voip_sip_webrtc/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/voip_sip_webrtc/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/voip_sip_webrtc/__manifest__.py b/voip_sip_webrtc/__manifest__.py
new file mode 100644
index 000000000..7bf5bb6e9
--- /dev/null
+++ b/voip_sip_webrtc/__manifest__.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Voip Communication",
+ 'version': "1.1.4",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Make video calls with other users inside your system",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/voip_sip_webrtc_templates.xml',
+ 'views/res_users_views.xml',
+ 'views/voip_call_views.xml',
+ 'views/voip_call_template_preview_views.xml',
+ 'views/voip_call_template_views.xml',
+ 'views/voip_ringtone_views.xml',
+ 'views/voip_account_views.xml',
+ 'views/voip_settings_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/voip_media_views.xml',
+ 'views/voip_message_compose_views.xml',
+ 'views/ir_actions_server_views.xml',
+ 'views/voip_message_template_views.xml',
+ 'views/voip_dialog_views.xml',
+ 'views/voip_account_action_views.xml',
+ 'views/voip_account_action_transition_views.xml',
+ 'views/menus.xml',
+ 'security/ir.model.access.csv',
+ 'data/voip_ringtone.xml',
+ 'data/voip.codec.csv',
+ 'data/voip.account.action.type.csv',
+ 'data/ir.cron.xml',
+ 'data/mail.message.subtype.csv',
+ 'data/voip_settings.xml',
+ ],
+ 'demo': [],
+ 'depends': ['web','crm','bus'],
+ 'qweb': ['static/src/xml/*.xml'],
+ 'images':[
+ 'static/description/1.jpg',
+ 'static/description/2.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/voip_sip_webrtc/controllers/__init__.py b/voip_sip_webrtc/controllers/__init__.py
new file mode 100644
index 000000000..afffdb590
--- /dev/null
+++ b/voip_sip_webrtc/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import main
+from . import bus
\ No newline at end of file
diff --git a/voip_sip_webrtc/controllers/bus.py b/voip_sip_webrtc/controllers/bus.py
new file mode 100644
index 000000000..b828e0ede
--- /dev/null
+++ b/voip_sip_webrtc/controllers/bus.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*
+
+from odoo.addons.bus.controllers.main import BusController
+from odoo.http import request
+
+
+class VoipBusController(BusController):
+ # --------------------------
+ # Extends BUS Controller Poll
+ # --------------------------
+ def _poll(self, dbname, channels, last, options):
+ if request.session.uid:
+
+ #Callee receives notication asking to accept or reject the call plus media permission, Caller receives a notification showing how much time left before call is missed
+ channels.append((request.db, 'voip.notification', request.env.user.partner_id.id))
+
+ #Both the caller and callee are notified if the call is accepted, rejected or the call is ended early by the caller, the voip window then shows
+ channels.append((request.db, 'voip.response', request.env.user.partner_id.id))
+
+ #Season Description Procotol
+ channels.append((request.db, 'voip.sdp', request.env.user.partner_id.id))
+
+ #ICE
+ channels.append((request.db, 'voip.ice', request.env.user.partner_id.id))
+
+ #End the call
+ channels.append((request.db, 'voip.end', request.env.user.partner_id.id))
+
+ return super(VoipBusController, self)._poll(dbname, channels, last, options)
diff --git a/voip_sip_webrtc/controllers/main.py b/voip_sip_webrtc/controllers/main.py
new file mode 100644
index 000000000..0f73bee5b
--- /dev/null
+++ b/voip_sip_webrtc/controllers/main.py
@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+import requests
+import json
+import datetime
+import logging
+_logger = logging.getLogger(__name__)
+import werkzeug.utils
+import werkzeug.wrappers
+import werkzeug
+import base64
+import socket
+from ast import literal_eval
+import struct
+
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
+from openerp.tools import ustr
+import openerp.http as http
+from openerp.http import request
+import odoo.addons.web.controllers.main as main
+
+class VoipController(http.Controller):
+
+ @http.route('/voip/window', type="http", auth="user")
+ def voip_window(self):
+ """ Returns a small popup window """
+
+ return request.render("voip_sip_webrtc.voip_window")
+
+ @http.route('/voip/ringtone/.mp3', type="http", auth="user")
+ def voip_ringtone(self, voip_call_id):
+ """Return the ringtone file to be used by javascript"""
+
+ voip_call = request.env['voip.call'].browse( int(voip_call_id) )
+ to_user = request.env['res.users'].search([('partner_id','=',voip_call.partner_id.id)])
+
+ #Check if the callee has a person ringtone set
+ if to_user.voip_ringtone:
+ ringtone_media = to_user.voip_ringtone
+ else:
+ voip_ringtone_id = request.env['ir.default'].get('voip.settings', 'ringtone_id')
+ voip_ringtone = request.env['voip.ringtone'].browse( voip_ringtone_id )
+ ringtone_media = voip_ringtone.media
+
+ headers = []
+ ringtone_base64 = base64.b64decode(ringtone_media)
+ headers.append(('Content-Length', len(ringtone_base64)))
+ response = request.make_response(ringtone_base64, headers)
+
+ return response
+
+ @http.route('/voip/messagebank/', type="http", auth="user")
+ def voip_messagebank(self, voip_call_id):
+ """ Allow listen to call in browser """
+
+ voip_call = request.env['voip.call'].browse( int(voip_call_id) )
+
+ html = ""
+
+ if voip_call.server_stream_data:
+ html += "Server Stream: "
+ html += " \n"
+
+ for voip_client in voip_call.client_ids:
+ html += "Client " + voip_client.name + " Stream "
+ html += " \n"
+
+ return html
+
+ def add_riff_header(self, audio_stream, riff_audio_encoding_value):
+
+ #"RIFF"
+ riff_wrapper = b'\x52\x49\x46\x46'
+ #File Size
+ stream_length = len(audio_stream)
+ riff_wrapper += struct.pack('.wav', type="http", auth="user")
+ def voip_messagebank_client(self, voip_call_client_id):
+ """ Allow listen to call in browser """
+
+ voip_call_client = request.env['voip.call.client'].browse( int(voip_call_client_id) )
+ voip_call = voip_call_client.vc_id
+
+ headers = []
+ audio_stream = base64.b64decode(voip_call_client.audio_stream)
+
+ #Add a RIFF wrapper to the raw file so we can play the audio in the browser, this is just a crude solution for those that don't have transcoding installed
+ if voip_call_client.vc_id.media_filename == "call.raw":
+ riff_wrapper = self.add_riff_header(audio_stream, voip_call.codec_id.riff_audio_encoding_value)
+ media = riff_wrapper + audio_stream
+
+ headers.append(('Content-Length', len(media)))
+ headers.append(('Content-Type', 'audio/x-wav'))
+ response = request.make_response(media, headers)
+
+ return response
+
+ @http.route('/voip/messagebank/server/.wav', type="http", auth="user")
+ def voip_messagebank_server(self, voip_call_id):
+ """ Audio generated by the server """
+
+ voip_call = request.env['voip.call'].browse( int(voip_call_id) )
+
+ headers = []
+ audio_stream = base64.b64decode(voip_call.server_stream_data)
+
+ #Add a RIFF wrapper to the raw file so we can play the audio in the browser, this is just a crude solution for those that don't have transcoding installed
+ if voip_call.media_filename == "call.raw":
+ riff_wrapper = self.add_riff_header(audio_stream, voip_call.codec_id.riff_audio_encoding_value)
+ media = riff_wrapper + audio_stream
+
+ headers.append(('Content-Length', len(media)))
+ headers.append(('Content-Type', 'audio/x-wav'))
+ response = request.make_response(media, headers)
+
+ return response
+
+ @http.route('/voip/miss/.mp3', type="http", auth="user")
+ def voip_miss_message(self, voip_call_id):
+ """ Play the missed call mp3 of the callee """
+
+ voip_call = request.env['voip.call'].browse( int(voip_call_id) )
+ to_user = request.env['res.users'].search([('partner_id','=',voip_call.partner_id.id)])
+
+ if to_user.voip_missed_call:
+ missed_call_media = to_user.voip_missed_call
+
+ headers = []
+ missed_call_media_base64 = base64.b64decode(missed_call_media)
+ headers.append(('Content-Length', len(missed_call_media_base64)))
+ response = request.make_response(missed_call_media_base64, headers)
+
+ return response
+ else:
+ #TODO read blank.mp3 and return it
+ return ""
+
+class DataSetInheritVoip(main.DataSet):
+
+ @http.route(['/web/dataset/call_kw', '/web/dataset/call_kw/'], type='json', auth="user")
+ def call_kw(self, model, method, args, kwargs, path=None):
+ value = super(DataSetInheritVoip, self).call_kw(model, method, args, kwargs, path=None)
+
+ #Doing a write every screen change is bound to be bad for performance
+ #But I need to be able to distinguish between bus.presence having a tab open and actually using the system...
+ try:
+ request.env.user.last_web_client_activity_datetime = datetime.datetime.now()
+ except:
+ pass
+
+ return value
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/ir.cron.xml b/voip_sip_webrtc/data/ir.cron.xml
new file mode 100644
index 000000000..59cdab663
--- /dev/null
+++ b/voip_sip_webrtc/data/ir.cron.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ Clear Messagebank
+
+ code
+ model.clear_messagebank()
+ 24
+ hours
+ -1
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/mail.message.subtype.csv b/voip_sip_webrtc/data/mail.message.subtype.csv
new file mode 100644
index 000000000..f860a79fe
--- /dev/null
+++ b/voip_sip_webrtc/data/mail.message.subtype.csv
@@ -0,0 +1,2 @@
+"id","name","default"
+"voip_call","VOIP Call","0"
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/voip.account.action.type.csv b/voip_sip_webrtc/data/voip.account.action.type.csv
new file mode 100644
index 000000000..39b20f123
--- /dev/null
+++ b/voip_sip_webrtc/data/voip.account.action.type.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"recorded","Recorded Message","recorded_message"
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/voip.codec.csv b/voip_sip_webrtc/data/voip.codec.csv
new file mode 100644
index 000000000..9d3423a78
--- /dev/null
+++ b/voip_sip_webrtc/data/voip.codec.csv
@@ -0,0 +1,4 @@
+"id","name","payload_type","sample_rate","payload_size","sample_interval","supported","encoding","sdp_data","riff_audio_encoding_value"
+"pcmu","G.711 uLaw","0","8000","160","20","1","u-law","a=rtpmap:0 PCMU/8000\r\na=ptime:20\r\n","7"
+"gsm","GSM","3","8000","33","20","1","gsm","","31"
+"g_711_alaw","G.711 aLaw","8","8000","160","20","1","a-law","","6"
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/voip_ringtone.xml b/voip_sip_webrtc/data/voip_ringtone.xml
new file mode 100644
index 000000000..1acb29ea6
--- /dev/null
+++ b/voip_sip_webrtc/data/voip_ringtone.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Old School Ringtone
+ Old School Ringtone.mp3
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/data/voip_settings.xml b/voip_sip_webrtc/data/voip_settings.xml
new file mode 100644
index 000000000..49cb373e6
--- /dev/null
+++ b/voip_sip_webrtc/data/voip_settings.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/doc/changelog.rst b/voip_sip_webrtc/doc/changelog.rst
new file mode 100644
index 000000000..0f3552577
--- /dev/null
+++ b/voip_sip_webrtc/doc/changelog.rst
@@ -0,0 +1,49 @@
+v1.1.4
+======
+* Fix to work in latest version of Google Chrome
+
+v1.1.3
+======
+* No presence bug fix
+
+v1.1.2
+======
+* RTP data is now saved if call recording setting is ticked
+* Presence recode to improve accuracy (hopefully...)
+* New presence light
+
+v1.1.1
+======
+* Find local outgoing IP address button to make SIP setup a little easier
+
+v1.1.0
+======
+* Call dialogs and DTMF call action
+
+v1.0.6
+======
+* Compact call menu
+
+v1.0.5
+======
+* Adjust permissions
+
+v1.0.4
+======
+* Setting to adjust presence detection
+
+v1.0.3
+======
+* Remove broken SIP widget
+
+v1.0.2
+======
+* Convert sip_register, sip_invite and rtp code to python 3
+
+v1.0.1
+======
+* Unhide voip account field in user form view
+
+v1.0
+====
+* Port to v11
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/__init__.py b/voip_sip_webrtc/models/__init__.py
new file mode 100644
index 000000000..e0167eb53
--- /dev/null
+++ b/voip_sip_webrtc/models/__init__.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+
+from . import voip_voip
+from . import res_users
+from . import res_partner
+from . import voip_call
+from . import voip_ringtone
+from . import voip_settings
+from . import voip_server
+from . import voip_account
+from . import voip_account_action
+from . import voip_message_compose
+from . import voip_codec
+from . import voip_call_template
+from . import ir_actions_server
+from . import voip_call_template_preview
+from . import voip_media
+from . import voip_message_template
+from . import voip_dialog
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/ir_actions_server.py b/voip_sip_webrtc/models/ir_actions_server.py
new file mode 100644
index 000000000..127c0f115
--- /dev/null
+++ b/voip_sip_webrtc/models/ir_actions_server.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class IrActionsServer(models.Model):
+
+ _inherit = 'ir.actions.server'
+
+ voip_call_template_id = fields.Many2one('voip.call.template',string="VOIP Call Template")
+
+ @api.model
+ def _get_states(self):
+ res = super(IrActionsServer, self)._get_states()
+ res.insert(0, ('voip_call', 'Make Voip Call'))
+ return res
+
+ @api.model
+ def run_action_voip_call(self, action, eval_context=None):
+ if not action.voip_call_template_id:
+ return False
+
+ action.voip_call_template_id.make_call(self.env.context.get('active_id'))
+
+ return False
diff --git a/voip_sip_webrtc/models/res_partner.py b/voip_sip_webrtc/models/res_partner.py
new file mode 100644
index 000000000..a19874174
--- /dev/null
+++ b/voip_sip_webrtc/models/res_partner.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+
+class ResPartnerVoip(models.Model):
+
+ _inherit = "res.partner"
+
+ sip_address = fields.Char(string="SIP Address")
+ xmpp_address = fields.Char(string="XMPP Address")
+
+ @api.onchange('country_id','mobile')
+ def _onchange_mobile(self):
+ """Tries to convert a local number to e.164 format based on the partners country, don't change if already in e164 format"""
+ if self.mobile:
+
+ if self.country_id and self.country_id.phone_code:
+ if self.mobile.startswith("0"):
+ self.mobile = "+" + str(self.country_id.phone_code) + self.mobile[1:].replace(" ","")
+ elif self.mobile.startswith("+"):
+ self.mobile = self.mobile.replace(" ","")
+ else:
+ self.mobile = "+" + str(self.country_id.phone_code) + self.mobile.replace(" ","")
+ else:
+ self.mobile = self.mobile.replace(" ","")
+
+
+ @api.multi
+ def sip_action(self):
+ self.ensure_one()
+
+ my_context = {'default_type': 'sip', 'default_model':'res.partner', 'default_record_id':self.id, 'default_to_address': self.sip_address}
+
+ #Use the first SIP account you find
+ default_voip_account = self.env['voip.account'].search([])
+ if default_voip_account:
+ my_context['default_sip_account_id'] = default_voip_account[0].id
+ else:
+ raise UserError("No SIP accounts found, can not send message")
+
+ return {
+ 'name': 'SIP Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.message.compose',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
diff --git a/voip_sip_webrtc/models/res_users.py b/voip_sip_webrtc/models/res_users.py
new file mode 100644
index 000000000..8f3903d4f
--- /dev/null
+++ b/voip_sip_webrtc/models/res_users.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+from datetime import datetime, timedelta
+from openerp.http import request
+import socket
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models
+
+class ResUsersVoip(models.Model):
+
+ _inherit = "res.users"
+
+ voip_presence_status = fields.Char(string="Voip Presence Status", help="Used for both Webrtc and SIP")
+ last_web_client_activity_datetime = fields.Datetime(string="Last Activity Datetime")
+ voip_ringtone = fields.Binary(string="Ringtone")
+ voip_account_id = fields.Many2one('voip.account', string="SIP Account")
+ voip_ringtone_filename = fields.Char(string="Ringtone Filename")
+ voip_missed_call = fields.Binary(string="Missed Call Message")
+ voip_missed_call_filename = fields.Char(string="Missed Call Message Filename")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/sdp.py b/voip_sip_webrtc/models/sdp.py
new file mode 100644
index 000000000..ef5d7f54d
--- /dev/null
+++ b/voip_sip_webrtc/models/sdp.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+import time
+
+def generate_sdp(self, ip, audio_port, rtp_profiles, session_description=" "):
+
+ sdp = ""
+
+ #Protocol Version ("v=") https://tools.ietf.org/html/rfc4566#section-5.1 (always 0 for us)
+ sdp += "v=0\r\n"
+
+ #Origin ("o=") https://tools.ietf.org/html/rfc4566#section-5.2
+ username = "-"
+ sess_id = int(time.time())
+ sess_version = 0
+ nettype = "IN"
+ addrtype = "IP4"
+ sdp += "o=" + username + " " + str(sess_id) + " " + str(sess_version) + " " + nettype + " " + addrtype + " " + ip + "\r\n"
+
+ #Session Name ("s=") https://tools.ietf.org/html/rfc4566#section-5.3
+ sdp += "s=" + session_description + "\r\n"
+
+ #Connection Information ("c=") https://tools.ietf.org/html/rfc4566#section-5.7
+ sdp += "c=" + nettype + " " + addrtype + " " + ip + "\r\n"
+
+ #Timing ("t=") https://tools.ietf.org/html/rfc4566#section-5.9
+ sdp += "t=0 0\r\n"
+
+ #Media Descriptions ("m=") https://tools.ietf.org/html/rfc4566#section-5.14
+ sdp += "m=audio " + str(audio_port) + " RTP/AVP"
+ for rtp_profile in rtp_profiles:
+ sdp += " " + str(rtp_profile)
+ sdp += "\r\n"
+
+ sdp += "a=sendrecv\r\n"
+
+ return sdp
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/sip.py b/voip_sip_webrtc/models/sip.py
new file mode 100644
index 000000000..a5da1af2e
--- /dev/null
+++ b/voip_sip_webrtc/models/sip.py
@@ -0,0 +1,392 @@
+# -*- coding: utf-8 -*-
+import sys
+import socket
+import re
+import random
+import hashlib
+import threading
+import time
+import logging
+_logger = logging.getLogger(__name__)
+
+class SIPSession:
+
+ USER_AGENT = "Ragnarok"
+ rtp_threads = []
+ sip_history = {}
+
+ def __init__(self, ip, username, domain, password, auth_username=False, outbound_proxy=False, account_port="5060", display_name="-"):
+ self.ip = ip
+ self.username = username
+ self.domain = domain
+ self.password = password
+ self.auth_username = auth_username
+ self.outbound_proxy = outbound_proxy
+ self.account_port = account_port
+ self.display_name = display_name
+ self.tag = str(random.randint(0,2**31))
+ self.call_accepted = EventHook()
+ self.call_rejected = EventHook()
+ self.call_ended = EventHook()
+ self.call_error = EventHook()
+ self.call_ringing = EventHook()
+ self.message_sent = EventHook()
+ self.message_received = EventHook()
+ self.register_ok = EventHook()
+
+ #Each account is bound to a different port
+ self.sipsocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ self.sipsocket.bind(('', 0))
+ self.bind_port = self.sipsocket.getsockname()[1]
+
+ #Don't block the main thread with all the listening
+ sip_listener_starter = threading.Thread(target=self.sip_listener, args=())
+ sip_listener_starter.start()
+
+ def H(self, data):
+ return hashlib.md5( data.encode() ).hexdigest()
+
+ def KD(self, secret, data):
+ return self.H(secret + ":" + data)
+
+ def http_auth(self, authheader, method, address):
+ realm = re.findall(r'realm="(.*?)"', authheader)[0]
+ uri = "sip:" + address
+ nonce = re.findall(r'nonce="(.*?)"', authheader)[0]
+
+ if self.auth_username:
+ username = self.auth_username
+ else:
+ username = self.username
+
+ A1 = username + ":" + realm + ":" + self.password
+ A2 = method + ":" + uri
+
+ if "qop=" in authheader:
+ qop = re.findall(r'qop="(.*?)"', authheader)[0]
+ nc = "00000001"
+ cnonce = ''.join([random.choice('0123456789abcdef') for x in range(32)])
+ response = self.KD( self.H(A1), nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + self.H(A2) )
+ return 'Digest username="' + username + '",realm="' + realm + '",nonce="' + nonce + '",uri="' + uri + '",response="' + response + '",cnonce="' + cnonce + '",nc=' + nc + ',qop=auth,algorithm=MD5' + "\r\n"
+ else:
+ response = self.KD( self.H(A1), nonce + ":" + self.H(A2) )
+ return 'Digest username="' + username + '",realm="' + realm + '",nonce="' + nonce + '",uri="' + uri + '",response="' + response + '",algorithm=MD5' + "\r\n"
+
+ def answer_call(self, sip_invite, sdp):
+
+ call_id = re.findall(r'Call-ID: (.*?)\r\n', sip_invite)[0]
+ call_from = re.findall(r'From: (.*?)\r\n', sip_invite)[0]
+ call_to = re.findall(r'To: (.*?)\r\n', sip_invite)[0]
+
+ reply = ""
+ reply += "SIP/2.0 200 OK\r\n"
+ for (via_heading) in re.findall(r'Via: (.*?)\r\n', sip_invite):
+ reply += "Via: " + via_heading + "\r\n"
+ record_route = re.findall(r'Record-Route: (.*?)\r\n', sip_invite)
+ if record_route:
+ reply += "Record-Route: " + record_route[0] + "\r\n"
+ reply += "Contact: \r\n"
+ reply += "To: " + call_to + "\r\n"
+ reply += "From: " + call_from + "\r\n"
+ reply += "Call-ID: " + str(call_id) + "\r\n"
+ reply += "CSeq: 1 INVITE\r\n"
+ reply += "Allow: SUBSCRIBE, NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ reply += "Content-Type: application/sdp\r\n"
+ reply += "Supported: replaces\r\n"
+ reply += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ reply += "Content-Length: " + str(len(sdp)) + "\r\n"
+ reply += "\r\n"
+ reply += sdp
+
+ self.sipsocket.sendto(reply.encode(), (self.to_server, self.account_port) )
+
+ def send_sip_message(self, to_address, message_body):
+ call_id = ''.join([random.choice('0123456789abcdef') for x in range(32)])
+
+ message_string = ""
+ message_string += "MESSAGE sip:" + str(self.username) + "@" + str(self.domain) + " SIP/2.0\r\n"
+ message_string += "Via: SIP/2.0/UDP " + str(self.ip) + ":" + str(self.bind_port) + ";rport\r\n"
+ message_string += "Max-Forwards: 70\r\n"
+ message_string += 'To: ;messagetype=IM\r\n"
+ message_string += 'From: "' + str(self.display_name) + '"\r\n"
+ message_string += "Call-ID: " + str(call_id) + "\r\n"
+ message_string += "CSeq: 1 MESSAGE\r\n"
+ message_string += "Allow: SUBSCRIBE, NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ message_string += "Content-Type: text/html\r\n"
+ message_string += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ message_string += "Content-Length: " + str(len(message_body)) + "\r\n"
+ message_string += "\r\n"
+ message_string += message_body
+
+ if self.outbound_proxy:
+ to_server = self.outbound_proxy
+ else:
+ to_server = self.domain
+
+ self.sipsocket.sendto(message_string.encode(), (to_server, self.account_port) )
+ self.sip_history[call_id] = []
+ self.sip_history[call_id].append(message_string)
+ return call_id
+
+ def send_sip_register(self, register_address, register_frequency=3600):
+
+ call_id = ''.join([random.choice('0123456789abcdef') for x in range(32)])
+
+ register_string = ""
+ register_string += "REGISTER sip:" + self.domain + ":" + str(self.account_port) + " SIP/2.0\r\n"
+ register_string += "Via: SIP/2.0/UDP " + str(self.ip) + ":" + str(self.bind_port) + ";rport\r\n"
+ register_string += "Max-Forwards: 70\r\n"
+ register_string += "Contact: \r\n"
+ register_string += 'To: "' + str(self.display_name) + '"\r\n"
+ register_string += 'From: "' + str(self.display_name) + '";tag=" + self.tag + "\r\n"
+ register_string += "Call-ID: " + str(call_id) + "\r\n"
+ register_string += "CSeq: 1 REGISTER\r\n"
+ register_string += "Expires: " + str(register_frequency) + "\r\n"
+ register_string += "Allow: NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ register_string += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ register_string += "Content-Length: 0\r\n"
+ register_string += "\r\n"
+
+ if self.outbound_proxy:
+ self.to_server = self.outbound_proxy
+ else:
+ self.to_server = self.domain
+
+ self.sip_history[call_id] = []
+ self.sip_history[call_id].append(register_string)
+
+ #Reregister to keep the session alive
+ reregister_starter = threading.Thread(target=self.reregister, args=(register_string, register_frequency,))
+ reregister_starter.start()
+
+ def reregister(self, register_string, register_frequency):
+ try:
+ _logger.error(register_string)
+ self.sipsocket.sendto(register_string.encode(), (self.to_server, self.account_port) )
+ time.sleep(register_frequency)
+ self.reregister(register_string, register_frequency)
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+ def send_sip_invite(self, to_address, call_sdp):
+
+ call_id = ''.join([random.choice('0123456789abcdef') for x in range(32)])
+
+ invite_string = ""
+ invite_string += "INVITE sip:" + to_address + ":" + str(self.account_port) + " SIP/2.0\r\n"
+ invite_string += "Via: SIP/2.0/UDP " + str(self.ip) + ":" + str(self.bind_port) + ";rport\r\n"
+ invite_string += "Max-Forwards: 70\r\n"
+ invite_string += "Contact: \r\n"
+ invite_string += 'To: \r\n"
+ invite_string += 'From: "' + str(self.display_name) + '"\r\n"
+ invite_string += "Call-ID: " + str(call_id) + "\r\n"
+ invite_string += "CSeq: 1 INVITE\r\n"
+ invite_string += "Allow: SUBSCRIBE, NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ invite_string += "Content-Type: application/sdp\r\n"
+ invite_string += "Supported: replaces\r\n"
+ invite_string += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ invite_string += "Content-Length: " + str(len(call_sdp)) + "\r\n"
+ invite_string += "\r\n"
+ invite_string += call_sdp
+
+ to_server = ""
+ if self.outbound_proxy:
+ to_server = self.outbound_proxy
+ else:
+ to_server = self.domain
+
+ _logger.error(invite_string)
+
+ self.sipsocket.sendto(invite_string.encode(), (to_server, self.account_port) )
+ self.sip_history[call_id] = []
+ self.sip_history[call_id].append(invite_string)
+ return call_id
+
+ def sip_listener(self):
+
+ try:
+
+ _logger.error("Listening for SIP messages on " + str(self.bind_port) )
+
+ #Wait and send back the auth reply
+ stage = "WAITING"
+ while stage == "WAITING":
+
+ data, addr = self.sipsocket.recvfrom(2048)
+
+ data = data.decode()
+
+ _logger.error(data)
+
+ #Send auth response if challenged
+ if data.split("\r\n")[0] == "SIP/2.0 407 Proxy Authentication Required" or data.split("\r\n")[0] == "SIP/2.0 407 Proxy Authentication required":
+
+ authheader = re.findall(r'Proxy-Authenticate: (.*?)\r\n', data)[0]
+ call_id = re.findall(r'Call-ID: (.*?)\r\n', data)[0]
+ cseq = re.findall(r'CSeq: (.*?)\r\n', data)[0]
+ cseq_number = cseq.split(" ")[0]
+ cseq_type = cseq.split(" ")[1]
+ call_to_full = re.findall(r'To: (.*?)\r\n', data)[0]
+ call_to = re.findall(r'', call_to_full)[0]
+ if ":" in call_to: call_to = call_to.split(":")[0]
+
+ #Resend the initial message but with the auth_string
+ reply = self.sip_history[call_id][0]
+ auth_string = self.http_auth(authheader, cseq_type, call_to)
+
+ #Add one to sequence number
+ reply = reply.replace("CSeq: " + str(cseq_number) + " ", "CSeq: " + str(int(cseq_number) + 1) + " ")
+
+ #Add the Proxy Authorization line before the User-Agent line
+ idx = reply.index("User-Agent:")
+ reply = reply[:idx] + "Proxy-Authorization: " + auth_string + reply[idx:]
+
+ self.sipsocket.sendto(reply.encode(), addr)
+ elif data.split("\r\n")[0] == "SIP/2.0 401 Unauthorized":
+
+ authheader = re.findall(r'WWW-Authenticate: (.*?)\r\n', data)[0]
+ call_id = re.findall(r'Call-ID: (.*?)\r\n', data)[0]
+ cseq = re.findall(r'CSeq: (.*?)\r\n', data)[0]
+ cseq_number = cseq.split(" ")[0]
+ cseq_type = cseq.split(" ")[1]
+ call_to_full = re.findall(r'To: (.*?)\r\n', data)[0]
+ call_to = re.findall(r'', call_to_full)[0]
+ if ":" in call_to: call_to = call_to.split(":")[0]
+
+ #Resend the initial message but with the auth_string
+ reply = self.sip_history[call_id][0]
+ auth_string = self.http_auth(authheader, cseq_type, call_to)
+
+ #Add one to sequence number
+ reply = reply.replace("CSeq: " + str(cseq_number) + " ", "CSeq: " + str(int(cseq_number) + 1) + " ")
+
+ #Add the Authorization line before the User-Agent line
+ idx = reply.index("User-Agent:")
+ reply = reply[:idx] + "Authorization: " + auth_string + reply[idx:]
+
+ _logger.error(reply)
+ self.sipsocket.sendto(reply.encode(), addr)
+ elif data.split("\r\n")[0] == "SIP/2.0 403 Forbidden":
+ #Likely means call was rejected
+ self.call_rejected.fire(self, data)
+ stage = "Forbidden"
+ return False
+ elif data.startswith("MESSAGE"):
+ #Extract the actual message to make things easier for devs
+ message = data.split("\r\n\r\n")[1]
+ if "\r\n"
+ ringing += "To: " + call_to + "\r\n"
+ ringing += "From: " + call_from + "\r\n"
+ ringing += "Call-ID: " + str(call_id) + "\r\n"
+ ringing += "CSeq: 1 INVITE\r\n"
+ ringing += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ ringing += "Allow-Events: talk, hold\r\n"
+ ringing += "Content-Length: 0\r\n"
+ ringing += "\r\n"
+
+ self.sipsocket.sendto(ringing.encode(), addr)
+
+ self.call_ringing.fire(self, data)
+ elif data.startswith("BYE"):
+ #Do stuff when the call is ended by client
+ self.call_ended.fire(self, data)
+ stage = "BYE"
+ return True
+ elif data.split("\r\n")[0] == "SIP/2.0 200 OK":
+
+ cseq = re.findall(r'CSeq: (.*?)\r\n', data)[0]
+ cseq_type = cseq.split(" ")[1]
+
+ #200 OK is used by REGISTER, INVITE and MESSAGE, so the code logic gets split up
+ if cseq_type == "INVITE":
+ cseq_number = cseq.split(" ")[0]
+ contact_header = re.findall(r'Contact: <(.*?)>\r\n', data)[0]
+ call_from = re.findall(r'From: (.*?)\r\n', data)[0]
+ call_to = re.findall(r'To: (.*?)\r\n', data)[0]
+ call_id = re.findall(r'Call-ID: (.*?)\r\n', data)[0]
+
+ #Send the ACK
+ reply = ""
+ reply += "ACK " + contact_header + " SIP/2.0\r\n"
+ reply += "Via: SIP/2.0/UDP " + str(self.ip) + ":" + str(self.bind_port) + ";rport\r\n"
+ reply += "Max-Forwards: 70\r\n"
+ record_route = re.findall(r'Record-Route: (.*?)\r\n', data)
+ if record_route:
+ reply += "Route: " + record_route[0] + "\r\n"
+ reply += "Contact: \r\n"
+ reply += 'To: ' + call_to + "\r\n"
+ reply += "From: " + call_from + "\r\n"
+ reply += "Call-ID: " + str(call_id) + "\r\n"
+ reply += "CSeq: " + str(cseq_number) + " ACK\r\n"
+ reply += "User-Agent: " + str(self.USER_AGENT) + "\r\n"
+ reply += "Content-Length: 0\r\n"
+ reply += "\r\n"
+
+ self.sipsocket.sendto(reply.encode(), addr)
+
+ self.call_accepted.fire(self, data)
+ elif cseq_type == "MESSAGE":
+ self.message_sent.fire(self, data)
+ elif cseq_type == "REGISTER":
+ self.register_ok.fire(self, data)
+ elif data.split("\r\n")[0].startswith("SIP/2.0 4"):
+ self.call_error.fire(self, data)
+
+ except Exception as e:
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error(e)
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+class EventHook(object):
+
+ def __init__(self):
+ self.__handlers = []
+
+ def __iadd__(self, handler):
+ self.__handlers.append(handler)
+ return self
+
+ def __isub__(self, handler):
+ self.__handlers.remove(handler)
+ return self
+
+ def fire(self, *args, **keywargs):
+ for handler in self.__handlers:
+ handler(*args, **keywargs)
+
+ def clearObjectHandlers(self, inObject):
+ for theHandler in self.__handlers:
+ if theHandler.im_self == inObject:
+ self -= theHandler
diff --git a/voip_sip_webrtc/models/voip_account.py b/voip_sip_webrtc/models/voip_account.py
new file mode 100644
index 000000000..e0cacfffc
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_account.py
@@ -0,0 +1,454 @@
+# -*- coding: utf-8 -*-
+import socket
+from openerp.exceptions import UserError
+import logging
+_logger = logging.getLogger(__name__)
+from openerp.http import request
+import re
+import hashlib
+import random
+from openerp import api, fields, models
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
+import threading
+from threading import Timer
+import time
+from time import sleep
+import sys
+import datetime
+import struct
+import base64
+from . import sdp
+from . import sip
+from random import randint
+import queue
+
+class VoipAccount(models.Model):
+
+ _name = "voip.account"
+
+ name = fields.Char(string="Name", required="True")
+ state = fields.Selection([('new','New'), ('inactive','Inactive'), ('active','Active')], default="new", string="State")
+ type = fields.Selection([('sip', 'SIP'), ('xmpp', 'XMPP')], default="sip", string="Account Type")
+ address = fields.Char(string="SIP Address", required="True")
+ password = fields.Char(string="SIP Password", required="True")
+ auth_username = fields.Char(string="Auth Username")
+ username = fields.Char(string="Username", required="True")
+ domain = fields.Char(string="Domain", required="True")
+ voip_display_name = fields.Char(string="Display Name", default="Odoo")
+ outbound_proxy = fields.Char(string="Outbound Proxy")
+ port = fields.Integer(string="Port", default="5060")
+ verified = fields.Boolean(string="Verified")
+ bind_port = fields.Integer(string="Bind Port")
+ action_id = fields.Many2one('voip.account.action', string="Call Action")
+ call_dialog_id = fields.Many2one('voip.dialog', string="Call Dialog")
+ bind_port = fields.Integer(string="Bind Port", help="A record of what port the SIP session is bound on so we can deregister if neccassary")
+
+ @api.onchange('username','domain')
+ def _onchange_username(self):
+ if self.username and self.domain:
+ self.address = self.username + "@" + self.domain
+
+ @api.onchange('address')
+ def _onchange_address(self):
+ if self.address:
+ if "@" in self.address:
+ self.username = self.address.split("@")[0]
+ self.domain = self.address.split("@")[1]
+
+ def H(self, data):
+ return hashlib.md5(data).hexdigest()
+
+ def KD(self, secret, data):
+ return self.H(secret + ":" + data)
+
+ def generate_rtp_packet(self, rtp_payload_data, payload_type, payload_size, packet_count, sequence_number, timestamp):
+
+ rtp_data = ""
+
+ #---- Compose RTP packet to send back---
+ #10.. .... = Version: RFC 1889 Version (2)
+ #..0. .... = Padding: False
+ #...0 .... = Extension: False
+ #.... 0000 = Contributing source identifiers count: 0
+ rtp_data += "80"
+
+ #0... .... = Marker: False
+ #Payload type
+ if packet_count == 0:
+ #ulaw
+ rtp_data += " 80"
+ else:
+ rtp_data += " " + format( payload_type, '02x')
+
+ rtp_data += " " + format( sequence_number, '04x')
+
+ rtp_data += " " + format( int(timestamp), '08x')
+
+ #Synchronization Source identifier: 0x1202763d
+ rtp_data += " 12 20 76 3d"
+
+ #Payload:
+ hex_string = ""
+ for rtp_char in rtp_payload_data:
+ hex_format = "{0:02x}".format(rtp_char)
+ hex_string += hex_format + " "
+
+ rtp_data += " " + hex_string
+ return bytes.fromhex( rtp_data.replace(" ","") )
+
+ def rtp_server_listener(self, rtp_sender_queue, rtc_sender_thread, rtpsocket, voip_call_client_id, call_action_id, model=False, record_id=False):
+ #Create the call with the audio
+ with api.Environment.manage():
+ # As this function is in a new thread, I need to open a new cursor, because the old one may be closed
+ new_cr = self.pool.cursor()
+ self = self.with_env(self.env(cr=new_cr))
+
+ try:
+
+ _logger.error("Start RTP Listening")
+
+ audio_stream = b''
+ current_call_action = self.env['voip.account.action'].browse( int(call_action_id) )
+
+ t = threading.currentThread()
+ while getattr(t, "stream_active", True):
+
+ rtpsocket.settimeout(10)
+ data, addr = rtpsocket.recvfrom(2048)
+
+ #_logger.error(data.hex())
+ payload_type = data[1]
+
+ #Listen for telephone events DTMF
+ if payload_type == 101:
+ _logger.error("Telephone Event")
+ dtmf_number = data[12]
+ _logger.error(dtmf_number)
+ dmtf_transition = self.env['voip.account.action.transition'].search([('action_from_id','=', current_call_action.id), ('trigger','=','dtmf'), ('dtmf_input','=',dtmf_number)])
+
+ if dmtf_transition:
+ current_call_action = dmtf_transition.action_to_id
+
+ #Initialize the new call action
+ method = '_voip_action_initialize_%s' % (current_call_action.action_type_id.internal_name,)
+ action = getattr(current_call_action, method, None)
+ media_data = action(voip_call_client)
+
+ #Also set the current_call_action of the sending thread
+ rtp_sender_queue.put((current_call_action,media_data))
+
+ #Add the RTP payload to the received data
+ audio_stream += data[12:]
+
+ except Exception as e:
+ #Timeout
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+ try:
+
+ #Add the stream data to this client if allowed
+ record_calls = self.env['ir.default'].get('voip.settings', 'record_calls')
+ if record_calls:
+ _logger.error("Record Client Call")
+ voip_call_client = self.env['voip.call.client'].browse( int(voip_call_client_id) )
+ voip_call_client.write({'audio_stream': base64.b64encode(audio_stream)})
+
+ #Have to manually commit the new cursor?
+ self._cr.commit()
+ self._cr.close()
+
+ #if model:
+ # self.env[model].browse( int(record_id) ).message_post(body="Call Made", subject="Call Made", message_type="comment", subtype='voip_sip_webrtc.voip_call')
+
+ #Kill the sending thread
+ rtc_sender_thread.stream_active = False
+ rtc_sender_thread.join()
+
+ except Exception as e:
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+
+ def rtp_server_sender(self, rtp_sender_queue, rtpsocket, rtp_ip, rtp_port, codec_id, voip_call_client_id):
+
+ with api.Environment.manage():
+ # As this function is in a new thread, I need to open a new cursor, because the old one may be closed
+ new_cr = self.pool.cursor()
+ self = self.with_env(self.env(cr=new_cr))
+
+ try:
+
+ _logger.error("Start RTP Sender")
+
+ #Initialize the first action
+ voip_call_client = self.env['voip.call.client'].browse( int(voip_call_client_id) )
+ current_call_action_id = rtp_sender_queue.get()
+ current_call_action = self.env['voip.account.action'].browse( current_call_action_id )
+ call_start_time = datetime.datetime.now()
+ method = '_voip_action_initialize_%s' % (current_call_action.action_type_id.internal_name,)
+ action = getattr(current_call_action, method, None)
+ media_data = action(voip_call_client)
+
+ server_stream_data = b''
+
+ codec = self.env['voip.codec'].browse( int(codec_id) )
+
+ packet_count = 0
+ media_index = 0
+ sequence_number = randint(29161, 30000)
+ timestamp = (datetime.datetime.utcnow() - datetime.datetime(1900, 1, 1, 0, 0, 0)).total_seconds()
+
+ t = threading.currentThread()
+ while getattr(t, "stream_active", True):
+
+ method = '_voip_action_sender_%s' % (current_call_action.action_type_id.internal_name,)
+ action = getattr(current_call_action, method, None)
+
+ #The call action is capable of changing the media playback point (replaying media) and changing the current call action
+ rtp_payload_data, media_data, media_index = action(media_data, media_index, codec.payload_size)
+
+ if packet_count < 10:
+ _logger.error(rtp_payload_data)
+ _logger.error(media_index)
+
+ server_stream_data += rtp_payload_data
+
+ send_data = self.generate_rtp_packet(rtp_payload_data, codec.payload_type, codec.payload_size, packet_count, sequence_number, timestamp)
+ rtpsocket.sendto(send_data, (rtp_ip, rtp_port) )
+
+ packet_count += 1
+ sequence_number += 1
+ timestamp += codec.sample_rate / (1000 / codec.sample_interval)
+
+ try:
+ current_call_action, media_data = rtp_sender_queue.get(True, 0.02)
+ _logger.error("Current Action Change")
+ _logger.error(current_call_action.name)
+ media_index = 0
+ except queue.Empty:
+ pass
+ except Exception as e:
+ _logger.error(e)
+
+ except Exception as e:
+ #Timeout
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+ try:
+
+ voip_call = self.env['voip.call.client'].browse( int(voip_call_client_id) ).vc_id
+ diff_time = datetime.datetime.now() - call_start_time
+ write_values = {'status': 'over', 'start_time': call_start_time, 'end_time': datetime.datetime.now(), 'duration': str(diff_time.seconds) + " Seconds"}
+
+ #Add the stream data to the call if allowed
+ record_calls = self.env['ir.default'].get('voip.settings', 'record_calls')
+ if record_calls:
+ _logger.error("Record Server Stream")
+ write_values.update({'media_filename': "call.raw", 'server_stream_data': base64.b64encode(server_stream_data)})
+
+ voip_call.write(write_values)
+
+ #Have to manually commit the new cursor?
+ self._cr.commit()
+ self._cr.close()
+
+ except Exception as e:
+ _logger.error(e)
+ exc_type, exc_obj, exc_tb = sys.exc_info()
+ _logger.error("Line: " + str(exc_tb.tb_lineno) )
+
+ def call_accepted(self, session, data):
+ _logger.error("Call Accepted")
+
+ with api.Environment.manage():
+ #As this function is in a new thread, I need to open a new cursor, because the old one may be closed
+ new_cr = self.pool.cursor()
+ self = self.with_env(self.env(cr=new_cr))
+
+ call_id = re.findall(r'Call-ID: (.*?)\r\n', data)[0]
+ call_from_full = re.findall(r'From: (.*?)\r\n', data)[0]
+ call_from = re.findall(r'', call_from_full)[0]
+ call_to_full = re.findall(r'To: (.*?)\r\n', data)[0]
+ call_to = re.findall(r'', call_from_full)[0]
+ rtp_ip = re.findall(r'c=IN IP4 (.*?)\r\n', data)[0]
+ rtp_audio_port = int(re.findall(r'm=audio (.*?) RTP', data)[0])
+ codec = self.env['ir.default'].get('voip.settings', 'codec_id')
+
+ #Create the call now
+ voip_call = self.env['voip.call'].create({'from_address': call_from, 'to_address': session.username + "@" + session.domain, 'codec_id': codec, 'ring_time': datetime.datetime.now(), 'sip_call_id': call_id })
+
+ #Also create the client list
+ voip_call_client = self.env['voip.call.client'].create({'vc_id': voip_call.id, 'audio_media_port': rtp_audio_port, 'sip_address': call_from, 'name': call_from, 'model': False, 'record_id': False})
+
+ #Answer with a audio call
+ audio_media_port = random.randint(55000,56000)
+ local_ip = self.env['ir.default'].get('voip.settings', 'server_ip')
+ call_sdp = sdp.generate_sdp(self, local_ip, audio_media_port, [0])
+ session.answer_call(data, call_sdp)
+
+ #Start listening for / sending RTP data
+ rtpsocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ rtpsocket.bind(('', voip_call_client.audio_media_port))
+
+ rtp_sender_queue = queue.Queue()
+
+ rtc_sender_thread = threading.Thread(target=self.rtp_server_sender, args=(rtp_sender_queue, rtpsocket, rtp_ip, rtp_audio_port, codec, voip_call_client.id,))
+ rtc_sender_thread.start()
+
+ rtc_listener_thread = threading.Thread(target=self.rtp_server_listener, args=(rtp_sender_queue, rtc_sender_thread, rtpsocket, voip_call_client.id, self.id, voip_call_client.model, voip_call_client.record_id,))
+ rtc_listener_thread.start()
+
+ #Queue the first call action now
+ current_call_action = self.env['voip.account.action'].search([('voip_dialog_id', '=', voip_account.call_dialog_id.id), ('start','=',True) ])[0]
+ rtp_sender_queue.put(current_call_action.id)
+
+ self._cr.close()
+
+ def message_received(self, session, data, message):
+ _logger.error("Message Received")
+ _logger.error(message)
+
+ def register_ok(self, session, data):
+ _logger.error("REGISTER OK")
+
+ with api.Environment.manage():
+ # As this function is in a new thread, I need to open a new cursor, because the old one may be closed
+ new_cr = self.pool.cursor()
+ self = self.with_env(self.env(cr=new_cr))
+
+ voip_account = self.env['voip.account'].search([('username','=',session.username), ('domain','=',session.domain)])[0]
+ voip_account.state = "active"
+ voip_account.bind_port = session.bind_port
+
+ self._cr.commit()
+ self._cr.close()
+
+ sip_session = ""
+ def uac_register(self):
+
+ local_ip = self.env['ir.default'].get('voip.settings', 'server_ip')
+
+ if local_ip:
+ sip_session = sip.SIPSession(local_ip, self.username, self.domain, self.password, self.auth_username, self.outbound_proxy, self.port, self.voip_display_name)
+ sip_session.call_ringing += self.call_ringing
+ sip_session.message_received += self.message_received
+ sip_session.register_ok += self.register_ok
+ sip_session.send_sip_register(self.address)
+ else:
+ raise UserError("Please enter your IP under settings first")
+
+ def uac_deregister(self):
+ _logger.error("DEREGISTER")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_account_action.py b/voip_sip_webrtc/models/voip_account_action.py
new file mode 100644
index 000000000..c83368672
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_account_action.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+import socket
+import logging
+from openerp.exceptions import UserError
+_logger = logging.getLogger(__name__)
+from openerp.http import request
+import re
+import hashlib
+import random
+from openerp import api, fields, models
+import threading
+from . import sdp
+import time
+import datetime
+import struct
+import base64
+from random import randint
+import queue
+
+class VoipAccountAction(models.Model):
+
+ _name = "voip.account.action"
+ _description = "VOIP Account Action"
+
+ voip_dialog_id = fields.Many2one('voip.dialog', string="Voip Dialog")
+ name = fields.Char(string="Name")
+ start = fields.Boolean(string="Start Action")
+ account_id = fields.Many2one('voip.account', string="VOIP Account")
+ action_type_id = fields.Many2one('voip.account.action.type', string="Call Action", required="True")
+ action_type_internal_name = fields.Char(related="action_type_id.internal_name", string="Action Type Internal Name")
+ recorded_media_id = fields.Many2one('voip.media', string="Recorded Message")
+ user_id = fields.Many2one('res.users', string="Call User")
+ from_transition_ids = fields.One2many('voip.account.action.transition', 'action_to_id', string="Source Transitions")
+ to_transition_ids = fields.One2many('voip.account.action.transition', 'action_from_id', string="Destination Transitions")
+
+ def _voip_action_initialize_recorded_message(self, voip_call_client):
+ _logger.error("Change Action Recorded Message")
+ media_data = base64.decodestring(self.recorded_media_id.media)
+ return media_data
+
+ def _voip_action_sender_recorded_message(self, media_data, media_index, payload_size):
+ rtp_payload_data = media_data[media_index * payload_size : media_index * payload_size + payload_size]
+ new_media_index = media_index + 1
+ return rtp_payload_data, media_data, new_media_index
+
+class VoipAccountActionTransition(models.Model):
+
+ _name = "voip.account.action.transition"
+ _description = "VOIP Call Action Transition"
+
+ name = fields.Char(string="Name")
+ trigger = fields.Selection([('dtmf','DTMF Input'), ('auto','Automatic')], default="dtmf", string="Trigger")
+ dtmf_input = fields.Selection([('0','0'), ('1','1'), ('2','2'), ('3','3'), ('4','4'), ('5','5'), ('6','6'), ('7','7'), ('8','8'), ('9','9'), ('*','*'), ('#','#')], string="DTMF Input")
+ action_from_id = fields.Many2one('voip.account.action', string="From Voip Action")
+ action_to_id = fields.Many2one('voip.account.action', string="To Voip Action")
+
+class VoipAccountActionType(models.Model):
+
+ _name = "voip.account.action.type"
+ _description = "VOIP Account Action Type"
+
+ name = fields.Char(string="Name")
+ internal_name = fields.Char(string="Internal Name", help="function name of code")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_call.py b/voip_sip_webrtc/models/voip_call.py
new file mode 100644
index 000000000..ac1a90852
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_call.py
@@ -0,0 +1,240 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request
+import datetime
+import logging
+import socket
+import threading
+_logger = logging.getLogger(__name__)
+import time
+from random import randint
+from hashlib import sha1
+#import ssl
+#from dtls import do_patch
+#from dtls.sslconnection import SSLConnection
+import hmac
+import hashlib
+import random
+import string
+import passlib
+import struct
+import zlib
+import re
+from openerp.exceptions import UserError
+import binascii
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
+from openerp import api, fields, models
+
+class VoipCall(models.Model):
+
+ _name = "voip.call"
+ _order = 'create_date desc'
+
+ from_address = fields.Char(string="From Address")
+ from_partner_id = fields.Many2one('res.partner', string="From Partner", help="From can be blank if the call comes from outside of the system")
+ from_partner_sdp = fields.Text(string="From Partner SDP")
+ partner_id = fields.Many2one('res.partner', string="(OBSOLETE)To Partner")
+ to_address = fields.Char(string="To Address")
+ to_partner_id = fields.Many2one('res.partner', string="To Partner", help="To partner can be blank if the source is external and no record with mobile or sip is found")
+ status = fields.Selection([('pending','Pending'), ('missed','Missed'), ('accepted','Accepted'), ('rejected','Rejected'), ('active','Active'), ('over','Complete'), ('failed','Failed'), ('busy','Busy'), ('cancelled','Cancelled')], string='Status', default="pending", help="Pending = Calling person\nActive = currently talking\nMissed = Call timed out\nOver = Someone hit end call\nRejected = Someone didn't want to answer the call")
+ ring_time = fields.Datetime(string="Ring Time", help="Time the call starts dialing")
+ start_time = fields.Datetime(string="Start Time", help="Time the call was answered (if answered)")
+ end_time = fields.Datetime(string="End Time", help="Time the call end")
+ duration = fields.Char(string="Duration", help="Length of the call")
+ transcription = fields.Text(string="Transcription", help="Automatic transcription of the call")
+ notes = fields.Text(string="(OBSOLETE)Notes", help="Additional comments outside the transcription (use the chatter instead of this field)")
+ client_ids = fields.One2many('voip.call.client', 'vc_id', string="Client List")
+ type = fields.Selection([('internal','Internal'),('external','External')], string="Type")
+ mode = fields.Selection([('videocall','video call'), ('audiocall','audio call'), ('screensharing','screen sharing call')], string="Mode", help="This is only how the call starts, i.e a video call can turn into a screen sharing call mid way")
+ sip_tag = fields.Char(string="SIP Tag")
+ voip_account = fields.Many2one('voip.account', string="VOIP Account")
+ to_audio = fields.Binary(string="Audio")
+ to_audio_filename = fields.Char(string="(OBSOLETE)Audio Filename")
+ media = fields.Binary(string="Media")
+ media_filename = fields.Char(string="Media Filename")
+ server_stream_data = fields.Binary(string="Server Stream Data", help="Stream data sent by the server, e.g. automated call")
+ media_url = fields.Char(string="Media URL", compute="_compute_media_url")
+ codec_id = fields.Many2one('voip.codec', string="Codec")
+ direction = fields.Selection([('internal','Internal'), ('incoming','Incoming'), ('outgoing','Outgoing')], string="Direction")
+ sip_call_id = fields.Char(string="SIP Call ID")
+ ice_username = fields.Char(string="ICE Username")
+ ice_password = fields.Char(string="ICE Password")
+ call_dialog_id = fields.Many2one('voip.codec', string="Call Dialog")
+
+ @api.one
+ def _compute_media_url(self):
+ if self.server_stream_data:
+ self.media_url = "/voip/messagebank/" + str(self.id)
+ else:
+ self.media_url = ""
+
+ @api.model
+ def clear_messagebank(self):
+ """ Delete recorded phone call to clear up space """
+
+ for voip_call in self.env['voip.call'].search([('to_audio','!=', False)]):
+ #TODO remove to_audio
+ voip_call.to_audio = False
+ voip_call.to_audio_filename = False
+
+ voip_call.server_stream_data = False
+
+ voip_call.media = False
+ voip_call.media_filename = False
+
+ #Also remove the media attached to the client
+ for voip_client in self.env['voip.call.client'].search([('audio_stream','!=', False)]):
+ voip_client.audio_stream = False
+
+ def start_call(self):
+ """ Process the ICE queue now """
+
+ #Notify caller and callee that the call was rejected
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.start', voip_client.partner_id.id), notification)
+
+ def accept_call(self):
+ """ Mark the call as accepted and send response to close the notification window and open the VOIP window """
+
+ if self.status == "pending":
+ self.status = "accepted"
+
+ #Notify caller and callee that the call was accepted
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id, 'status': 'accepted', 'type': self.type}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.response', voip_client.partner_id.id), notification)
+
+ def reject_call(self):
+ """ Mark the call as rejected and send the response so the notification window is closed on both ends """
+
+ if self.status == "pending":
+ self.status = "rejected"
+
+ #Notify caller and callee that the call was rejected
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id, 'status': 'rejected'}
+ self.env['bus.bus'].sendone((request._cr.dbname, 'voip.response', voip_client.partner_id.id), notification)
+
+ def miss_call(self):
+ """ Mark the call as missed, both caller and callee will close there notification window due to the timeout """
+
+ if self.status == "pending":
+ self.status = "missed"
+
+ def begin_call(self):
+ """ Mark the call as active, we start recording the call duration at this point """
+
+ if self.status == "accepted":
+ self.status = "active"
+
+ self.start_time = datetime.datetime.now()
+
+ def end_call(self):
+ """ Mark the call as over, we can calculate the call duration based on the start time, also send notification to both sides to close there VOIP windows """
+
+ if self.status == "active":
+ self.status = "over"
+
+ self.end_time = datetime.datetime.now()
+ diff_time = datetime.datetime.strptime(self.end_time, DEFAULT_SERVER_DATETIME_FORMAT) - datetime.datetime.strptime(self.start_time, DEFAULT_SERVER_DATETIME_FORMAT)
+ self.duration = str(diff_time.seconds) + " Seconds"
+
+ #Notify both caller and callee that the call is ended
+ for voip_client in self.client_ids:
+ notification = {'call_id': self.id}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.end', voip_client.partner_id.id), notification)
+
+ def voip_call_sdp(self, sdp):
+ """Store the description and send it to everyone else"""
+
+ if self.type == "internal":
+ for voip_client in self.client_ids:
+ if voip_client.partner_id.id == self.env.user.partner_id.id:
+ voip_client.sdp = sdp
+ else:
+ notification = {'call_id': self.id, 'sdp': sdp }
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.sdp', voip_client.partner_id.id), notification)
+
+ def generate_call_sdp(self):
+
+ sdp_response = ""
+
+ #Protocol Version ("v=") https://tools.ietf.org/html/rfc4566#section-5.1 (always 0 for us)
+ sdp_response += "v=0\r\n"
+
+ #Origin ("o=") https://tools.ietf.org/html/rfc4566#section-5.2 (Should come up with a better session id...)
+ sess_id = int(time.time()) #Not perfect but I don't expect more then one call a second
+ sess_version = 0 #Will always start at 0
+ sdp_response += "o=- " + str(sess_id) + " " + str(sess_version) + " IN IP4 0.0.0.0\r\n"
+
+ #Session Name ("s=") https://tools.ietf.org/html/rfc4566#section-5.3 (We don't need a session name, information about the call is all displayed in the UI)
+ sdp_response += "s= \r\n"
+
+ #Timing ("t=") https://tools.ietf.org/html/rfc4566#section-5.9 (For now sessions are infinite but we may use this if for example a company charges a price for a fixed 30 minute consultation)
+ sdp_response += "t=0 0\r\n"
+
+ #In later versions we might send the missed call mp3 via rtp
+ sdp_response += "a=sendrecv\r\n"
+
+ #TODO generate cert/fingerprint within module
+ fignerprint = self.env['ir.default'].get('voip.settings', 'fingerprint')
+ sdp_response += "a=fingerprint:sha-256 " + fignerprint + "\r\n"
+ sdp_response += "a=setup:passive\r\n"
+
+ #Sure why not
+ sdp_response += "a=ice-options:trickle\r\n"
+
+ #Sigh no idea
+ sdp_response += "a=msid-semantic:WMS *\r\n"
+
+ #Random stuff, left here so I don't have get it a second time if needed
+ #example supported audio profiles: 109 9 0 8 101
+ #sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF 109 101\r\n"
+
+ #Media Descriptions ("m=") https://tools.ietf.org/html/rfc4566#section-5.14 (Message bank is audio only for now)
+ audio_codec = "9" #Use G722 Audio Profile
+ sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF " + audio_codec + "\r\n"
+
+ #Connection Data ("c=") https://tools.ietf.org/html/rfc4566#section-5.7 (always seems to be 0.0.0.0?)
+ sdp_response += "c=IN IP4 0.0.0.0\r\n"
+
+ #ICE creds (https://tools.ietf.org/html/rfc5245#page-76)
+ ice_ufrag = ''.join(random.choice('123456789abcdef') for _ in range(4))
+ ice_pwd = ''.join(random.choice('123456789abcdef') for _ in range(22))
+ self.ice_password = ice_pwd
+ sdp_response += "a=ice-ufrag:" + str(ice_ufrag) + "\r\n"
+ sdp_response += "a=ice-pwd:" + str(ice_pwd) + "\r\n"
+
+ #Ummm naming each media?!?
+ sdp_response += "a=mid:sdparta_0\r\n"
+
+ return {"type":"answer","sdp": sdp_response}
+
+ def voip_call_ice(self, ice):
+ """Forward ICE to everyone else"""
+
+ for voip_client in self.client_ids:
+
+ #Don't send ICE back to yourself
+ if voip_client.partner_id.id != self.env.user.partner_id.id:
+ notification = {'call_id': self.id, 'ice': ice }
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.ice', voip_client.partner_id.id), notification)
+
+class VoipCallClient(models.Model):
+
+ _name = "voip.call.client"
+
+ vc_id = fields.Many2one('voip.call', string="VOIP Call")
+ partner_id = fields.Many2one('res.partner', string="Partner")
+ sip_address = fields.Char(string="SIP Address")
+ name = fields.Char(string="Name", help="Can be a number if the client is from outside the system")
+ model = fields.Char(string="Model")
+ record_id = fields.Integer(string="Record ID")
+ state = fields.Selection([('invited','Invited'),('joined','joined'),('media_access','Media Access')], string="State", default="invited")
+ sdp = fields.Char(string="SDP")
+ sip_invite = fields.Char(string="SIP INVITE Message")
+ sip_addr = fields.Char(string="Address")
+ sip_addr_host = fields.Char(string="SIP Address Host")
+ sip_addr_port = fields.Char(string="SIP Address Port")
+ audio_media_port = fields.Integer(string="Audio Media Port")
+ audio_stream = fields.Binary(string="Audio Stream")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_call_template.py b/voip_sip_webrtc/models/voip_call_template.py
new file mode 100644
index 000000000..74f961e81
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_call_template.py
@@ -0,0 +1,155 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import functools
+from werkzeug import urls
+from datetime import datetime
+import base64
+
+from openerp import api, fields, models, tools
+
+try:
+ # We use a jinja2 sandboxed environment to render mako templates.
+ # Note that the rendering does not cover all the mako syntax, in particular
+ # arbitrary Python statements are not accepted, and not all expressions are
+ # allowed: only "public" attributes (not starting with '_') of objects may
+ # be accessed.
+ # This is done on purpose: it prevents incidental or malicious execution of
+ # Python code that may break the security of the server.
+ from jinja2.sandbox import SandboxedEnvironment
+ mako_template_env = SandboxedEnvironment(
+ block_start_string="<%",
+ block_end_string="%>",
+ variable_start_string="${",
+ variable_end_string="}",
+ comment_start_string="<%doc>",
+ comment_end_string="%doc>",
+ line_statement_prefix="%",
+ line_comment_prefix="##",
+ trim_blocks=True, # do not output newline after blocks
+ autoescape=True, # XML/HTML automatic escaping
+ )
+ mako_template_env.globals.update({
+ 'str': str,
+ 'quote': urls.url_quote,
+ 'urlencode': urls.url_encode,
+ 'datetime': datetime,
+ 'len': len,
+ 'abs': abs,
+ 'min': min,
+ 'max': max,
+ 'sum': sum,
+ 'filter': filter,
+ 'reduce': functools.reduce,
+ 'map': map,
+ 'round': round,
+
+ # dateutil.relativedelta is an old-style class and cannot be directly
+ # instanciated wihtin a jinja2 expression, so a lambda "proxy" is
+ # is needed, apparently.
+ 'relativedelta': lambda *a, **kw : relativedelta.relativedelta(*a, **kw),
+ })
+except ImportError:
+ _logger.warning("jinja2 not available, templating features will not work!")
+
+class VoipCallTemplate(models.Model):
+
+ _name = "voip.call.template"
+
+ name = fields.Char(string="Name")
+ model_id = fields.Many2one('ir.model', string="Applies to", help="The kind of document with with this template can be used")
+ voip_account_id = fields.Many2one('voip.account', string="VOIP Account")
+ to_address = fields.Char(string="To Address", help="Use placeholders")
+ media_id = fields.Many2one('voip.media', string="Media")
+ codec_id = fields.Many2one('voip.codec', string="Codec")
+ call_dialog_id = fields.Many2one('voip.dialog', string="Call Dialog")
+ type = fields.Selection([('prerecorded','Pre Recorded')], string="Template Type", default="prerecorded")
+ model_object_field_id = fields.Many2one('ir.model.fields', string="Field", help="Select target field from the related document model.\nIf it is a relationship field you will be able to select a target field at the destination of the relationship.")
+ sub_object_id = fields.Many2one('ir.model', string='Sub-model', readonly=True, help="When a relationship field is selected as first field, this field shows the document model the relationship goes to.")
+ sub_model_object_field_id = fields.Many2one('ir.model.fields', string='Sub-field', help="When a relationship field is selected as first field, this field lets you select the target field within the destination document model (sub-model).")
+ null_value = fields.Char(string='Default Value', help="Optional value to use if the target field is empty")
+ copyvalue = fields.Char(string='Placeholder Expression', help="Final placeholder expression, to be copy-pasted in the desired template field.")
+
+ @api.onchange('model_object_field_id')
+ def _onchange_model_object_field_id(self):
+ if self.model_object_field_id.relation:
+ self.sub_object_id = self.env['ir.model'].search([('model','=',self.model_object_field_id.relation)])[0].id
+ else:
+ self.sub_object_id = False
+
+ if self.model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ @api.onchange('sub_model_object_field_id')
+ def _onchange_sub_model_object_field_id(self):
+ if self.sub_model_object_field_id:
+ self.copyvalue = self.build_expression(self.model_object_field_id.name, self.sub_model_object_field_id.name, self.null_value)
+
+ @api.model
+ def build_expression(self, field_name, sub_field_name, null_value):
+ """Returns a placeholder expression for use in a template field,
+ based on the values provided in the placeholder assistant.
+
+ :param field_name: main field name
+ :param sub_field_name: sub field name (M2O)
+ :param null_value: default value if the target value is empty
+ :return: final placeholder expression
+ """
+ expression = ''
+ if field_name:
+ expression = "${object." + field_name
+ if sub_field_name:
+ expression += "." + sub_field_name
+ if null_value:
+ expression += " or '''%s'''" % null_value
+ expression += "}"
+ return expression
+
+ def make_call(self, record_id):
+ _logger.error("Make Call")
+ to_address = self.render_template(self.to_address, self.model_id.model, record_id)
+ self.voip_account_id.make_call(to_address, self.call_dialog_id, self.model_id.model, record_id)
+
+ def render_template(self, template_txt, model, res_id):
+ """Render the given template text, replace mako expressions ``${expr}``
+ with the result of evaluating these expressions with
+ an evaluation context containing:
+
+ * ``user``: browse_record of the current user
+ * ``object``: browse_record of the document record this mail is
+ related to
+ * ``context``: the context passed to the mail composition wizard
+
+ :param str template: the template text to render
+ :param str model: model name of the document record this mail is related to.
+ :param int res_id: id of document records those mails are related to.
+ """
+
+ # try to load the template
+ try:
+ mako_env = mako_safe_template_env if self.env.context.get('safe') else mako_template_env
+ template = mako_template_env.from_string(tools.ustr(template_txt))
+ except Exception:
+ _logger.error("Failed to load template %r", template)
+ return False
+
+ # prepare template variables
+ user = self.env.user
+ record = self.env[model].browse( int(res_id) )
+
+ variables = {
+ 'user': user
+ }
+
+ variables['object'] = record
+
+ try:
+ render_result = template.render(variables)
+ except Exception as e:
+ _logger.error("Failed to render template %r using values %r" % (template, variables))
+ _logger.error(e)
+ render_result = u""
+ if render_result == u"False":
+ render_result = u""
+
+ return render_result
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_call_template_preview.py b/voip_sip_webrtc/models/voip_call_template_preview.py
new file mode 100644
index 000000000..537c6a017
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_call_template_preview.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models, tools
+from openerp.exceptions import UserError
+
+class VoipCallTemplatePreview(models.TransientModel):
+
+ _name = "voip.call.template.preview"
+
+ @api.model
+ def _get_records(self):
+ """ Returns the first 10 records of the VOIP call template's model """
+
+ #Get call template through context since we can't get it through self
+ call_template = self.env['voip.call.template'].browse( self._context.get('default_call_template_id') )
+
+ if call_template:
+ records = self.env[call_template.model_id.model].search([], limit=10)
+ return records.name_get()
+ else:
+ return []
+
+ call_template_id = fields.Many2one('voip.call.template', string="Call Template")
+ rec_id = fields.Selection(_get_records, string="Record")
+
+ def test_call_template(self):
+
+ local_ip = self.env['ir.default'].get('voip.settings', 'server_ip')
+
+ if local_ip:
+ self.call_template_id.make_call(self.rec_id)
+ else:
+ raise UserError("Please enter your IP under settings first")
diff --git a/voip_sip_webrtc/models/voip_codec.py b/voip_sip_webrtc/models/voip_codec.py
new file mode 100644
index 000000000..d5f81c44e
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_codec.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class VoipCodec(models.Model):
+
+ _name = "voip.codec"
+
+ name = fields.Char(string="Name")
+ payload_type = fields.Integer(string="Payload Type")
+ encoding = fields.Char(string="Encoding")
+ sample_rate = fields.Integer(string="Sample Rate")
+ payload_size = fields.Integer(string="Payload Size")
+ sample_interval = fields.Integer(string="Sample Interval")
+ supported = fields.Boolean(string="Supported")
+ sdp_data = fields.Char(string="SDP Data")
+ riff_audio_encoding_value = fields.Integer("RIFF Audio Encoding Value")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_dialog.py b/voip_sip_webrtc/models/voip_dialog.py
new file mode 100644
index 000000000..b3608a383
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_dialog.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class VoipDialog(models.Model):
+
+ _name = "voip.dialog"
+
+ name = fields.Char(string="Name")
+ call_action_ids = fields.One2many('voip.account.action', 'voip_dialog_id', string="Call Actions")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_media.py b/voip_sip_webrtc/models/voip_media.py
new file mode 100644
index 000000000..5cfa5fc30
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_media.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+import logging
+_logger = logging.getLogger(__name__)
+
+class VoipMedia(models.Model):
+
+ _name = "voip.media"
+
+ @api.model
+ def _get_default_codec_id(self):
+ return self.env['ir.default'].get('voip.settings','codec_id')
+
+ name = fields.Char(string="Name")
+ media = fields.Binary(string="Media File")
+ media_filename = fields.Char(string="Media Filename")
+ codec_id = fields.Many2one('voip.codec', default=_get_default_codec_id, string="Codec")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_message_compose.py b/voip_sip_webrtc/models/voip_message_compose.py
new file mode 100644
index 000000000..d7a94d930
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_message_compose.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+import socket
+import threading
+import logging
+_logger = logging.getLogger(__name__)
+from lxml import etree
+import re
+from odoo.exceptions import UserError
+
+from openerp import api, fields, models
+
+class VoipMessageCompose(models.TransientModel):
+
+ _name = "voip.message.compose"
+
+ type = fields.Char(string="Message Type")
+ sip_account_id = fields.Many2one('voip.account', string="SIP Account")
+ message_template_id = fields.Many2one('voip.message.template', string="Message Template")
+ partner_id = fields.Many2one('res.partner', string="Partner (OBSOLETE)")
+ model = fields.Char(string="Model")
+ record_id = fields.Integer(string="Record ID")
+ to_address = fields.Char(string="To Address")
+ message = fields.Text(string="Message")
+
+ @api.onchange('message_template_id')
+ def _onchange_message_template_id(self):
+ self.message = self.message_template_id.message
+
+ def send_message(self):
+
+ method = '_send_%s_message' % (self.type,)
+ action = getattr(self, method, None)
+
+ if not action:
+ raise NotImplementedError('Method %r is not implemented on %r object.' % (method, self))
+
+ action()
+
+ def _send_sip_message(self):
+
+ message_response = self.sip_account_id.send_message(self.to_address, self.message, model=self.model, record_id=self.record_id)
+
+ if message_response != "OK":
+ _logger.error("SIP Message Failure")
+ raise UserError("Failed to send SIP message: " + message_response)
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_message_template.py b/voip_sip_webrtc/models/voip_message_template.py
new file mode 100644
index 000000000..d2c47b485
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_message_template.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+
+from openerp import api, fields, models
+
+class VoipMessageTemplate(models.Model):
+
+ _name = "voip.message.template"
+
+ name = fields.Char(string="Name")
+ message = fields.Text(string="Message")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_ringtone.py b/voip_sip_webrtc/models/voip_ringtone.py
new file mode 100644
index 000000000..c2828f97a
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_ringtone.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request
+
+from openerp import api, fields, models
+
+class VoipRingtone(models.Model):
+
+ _name = "voip.ringtone"
+
+ name = fields.Char(string="Name")
+ media = fields.Binary(string="Media File")
+ media_filename = fields.Char(string="Media Filename")
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_server.py b/voip_sip_webrtc/models/voip_server.py
new file mode 100644
index 000000000..b2628a5e8
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_server.py
@@ -0,0 +1,336 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request
+import socket
+import threading
+import logging
+_logger = logging.getLogger(__name__)
+import json
+import random
+import math
+from random import randint
+import time
+import string
+import socket
+import datetime
+from dateutil import tz
+import re
+
+from odoo import api, fields, models, registry
+from odoo.exceptions import UserError, ValidationError
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
+
+class VoipVoip(models.Model):
+
+ _name = "voip.server"
+ _description = "Voip Server"
+
+ @api.model
+ def user_list(self, **kw):
+ """ Get all active users so we can place them in the system tray """
+
+ user_list = []
+
+ #This list should only include users that have ever logged in, sort it by last presence that way all the online users are at the top
+ for presence_user in self.env['bus.presence'].search([('user_id','!=',self.env.user.id)], order="last_presence desc"):
+ to_zone = tz.gettz(self.env.user.tz)
+ utc = datetime.datetime.strptime(presence_user.last_presence, DEFAULT_SERVER_DATETIME_FORMAT)
+ utc = utc.replace(tzinfo=tz.gettz('UTC'))
+ local_time = utc.astimezone(to_zone)
+
+ if presence_user.user_id.last_web_client_activity_datetime:
+ last_activity_diff = datetime.datetime.now() - fields.Datetime.from_string(presence_user.user_id.last_web_client_activity_datetime)
+
+ last_activity_ago = "unknown"
+ if last_activity_diff.total_seconds() < 60:
+ #Under 1 minute
+ last_activity_ago = "Active " + str(math.floor(last_activity_diff.total_seconds())) + " seconds ago"
+ elif last_activity_diff.total_seconds() < 3600:
+ #Under 1 hour
+ last_activity_ago = "Active " + str(math.floor(last_activity_diff.total_seconds() / 60)) + " minutes ago"
+ elif last_activity_diff.total_seconds() < 86400:
+ #Under 1 day
+ last_activity_ago = "Active " + str(math.floor(last_activity_diff.total_seconds() / 3600)) + " hours ago"
+ else:
+ #We won't go into weeks / months years
+ last_activity_ago = "Active " + str(last_activity_diff.days) + " days ago"
+
+ user_list.append({'name': presence_user.user_id.name, 'partner_id':presence_user.user_id.partner_id.id, 'status': presence_user.user_id.im_status, 'last_presence': local_time.strftime("%a %I:%M %p"), 'last_activity_ago': last_activity_ago})
+
+ return user_list
+
+ @api.model
+ def sip_call_notify(self, mode, call_type, aor):
+ """ Create the VOIP call record and notify the callee of the incoming call """
+
+ #Create the VOIP call now so we can mark it as missed / rejected / accepted
+ voip_call = self.env['voip.call'].create({'type': call_type, 'mode': mode })
+
+ #Find the caller based on the address of record
+ from_partner = self.env['res.partner'].search([('sip_address','=', aor)])
+
+ if from_partner == False:
+ raise UserError("Could not find SIP partner")
+
+ #Add the current user is the call owner
+ voip_call.from_partner_id = from_partner.id
+
+ #Add the current user as the to partner
+ voip_call.partner_id = self.env.user.partner_id.id
+
+ #Also add both partners to the client list
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': from_partner.id, 'state':'joined', 'name': from_partner.name})
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': self.env.user.partner_id.id, 'state':'invited', 'name': self.env.user.partner_id.name})
+
+ #Ringtone will either the default ringtone or the users ringtone
+ ringtone = "/voip/ringtone/" + str(voip_call.id) + ".mp3"
+ ring_duration = self.env['ir.default'].get('voip.settings', 'ring_duration')
+
+ #Complicated code just to get the display name of the mode...
+ mode_display = dict(self.env['voip.call'].fields_get(allfields=['mode'])['mode']['selection'])[voip_call.mode]
+
+ #Send notification to callee
+ notification = {'voip_call_id': voip_call.id, 'ringtone': ringtone, 'ring_duration': ring_duration, 'from_name': from_partner.name, 'caller_partner_id': from_partner.id, 'direction': 'incoming', 'mode':mode, 'sdp': ''}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.notification', self.env.user.partner_id.id), notification)
+
+ @api.model
+ def voip_call_notify(self, mode, to_partner_id, call_type, sdp):
+ """ Create the VOIP call record and notify the callee of the incoming call """
+
+ #Create the VOIP call now so we can mark it as missed / rejected / accepted
+ voip_call = self.env['voip.call'].create({'type': call_type, 'mode': mode })
+
+ #Add the current user is the call owner
+ voip_call.from_partner_id = self.env.user.partner_id.id
+
+ #Add the selected user as the to partner
+ voip_call.partner_id = int(to_partner_id)
+
+ #Also add both partners to the client list
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': self.env.user.partner_id.id, 'state':'joined', 'name': self.env.user.partner_id.name})
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': voip_call.partner_id.id, 'state':'invited', 'name': voip_call.partner_id.name})
+
+ #Ringtone will either the default ringtone or the users ringtone
+ ringtone = "/voip/ringtone/" + str(voip_call.id) + ".mp3"
+ ring_duration = self.env['ir.default'].get('voip.settings', 'ring_duration')
+
+ #Complicated code just to get the display name of the mode...
+ mode_display = dict(self.env['voip.call'].fields_get(allfields=['mode'])['mode']['selection'])[voip_call.mode]
+
+ if voip_call.type == "internal":
+ #Send notification to callee
+ notification = {'voip_call_id': voip_call.id, 'ringtone': ringtone, 'ring_duration': ring_duration, 'from_name': self.env.user.partner_id.name, 'caller_partner_id': self.env.user.partner_id.id, 'direction': 'incoming', 'mode':mode, 'sdp': sdp}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.notification', voip_call.partner_id.id), notification)
+
+ #Also send one to yourself so we get the countdown
+ notification = {'voip_call_id': voip_call.id, 'ring_duration': ring_duration, 'to_name': voip_call.partner_id.name, 'callee_partner_id': voip_call.partner_id.id, 'direction': 'outgoing'}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.notification', voip_call.from_partner_id.id), notification)
+
+ elif voip_call.type == "external":
+ _logger.error("external call")
+
+ #Send the INVITE
+ voip_account = self.env.user.voip_account_id
+ voip_call.voip_account = voip_account
+ voip_call.from_partner_sdp = sdp['sdp']
+ media_port = random.randint(55000,56000)
+ call_id = random.randint(50000,60000)
+ from_tag = random.randint(8000000,9000000)
+
+ sipsocket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sipsocket.bind(('', 6000))
+ bind_port = sipsocket.getsockname()[1]
+
+ local_ip = self.env['ir.default'].get('voip.settings', 'server_ip')
+
+ #SDP from webrtc doesn't work?!?
+ sdp = sdp['sdp']
+
+
+ #Server SDP (Works)
+ sdp = ""
+ sdp += "v=0\r\n"
+ sess_id = int(time.time())
+ sess_version = 0
+ sdp += "o=- " + str(sess_id) + " " + str(sess_version) + " IN IP4 " + local_ip + "\r\n"
+ sdp += "s= \r\n"
+ sdp += "c=IN IP4 " + local_ip + "\r\n"
+ sdp += "t=0 0\r\n"
+ sdp += "m=audio " + str(media_port) + " RTP/AVP 0\r\n"
+ sdp += "a=sendrecv\r\n"
+
+ #Webrtc SDP Data (Fails)
+ sdp = ""
+ sdp += "v=0\r\n"
+ sdp += "o=mozilla...THIS_IS_SDPARTA-57.0 9175984511205677962 0 IN IP4 0.0.0.0\r\n"
+ sdp += "s=-\r\n"
+ sdp += "t=0 0\r\n"
+ sdp += "a=fingerprint:sha-256 3B:D9:87:A6:7F:E2:B3:F8:0D:92:9F:B7:4A:D7:84:17:E9:C9:5E:70:64:06:85:21:B9:7C:6D:5D:3D:78:36:6B\r\n"
+ sdp += "a=group:BUNDLE sdparta_0\r\n"
+ sdp += "a=ice-options:trickle\r\n"
+ sdp += "a=msid-semantic:WMS *\r\n"
+ sdp += "m=audio 9 UDP/TLS/RTP/SAVPF 109 9 0 8 101\r\n"
+ sdp += "c=IN IP4 0.0.0.0\r\n"
+ sdp += "a=sendrecv\r\n"
+ sdp += "a=extmap:1/sendonly urn:ietf:params:rtp-hdrext:ssrc-audio-level\r\n"
+ sdp += "a=fmtp:109 maxplaybackrate=48000;stereo=1;useinbandfec=1\r\n"
+ sdp += "a=fmtp:101 0-15\r\n"
+ sdp += "a=ice-pwd:66f0aeeb56dd05307985a8715f7badcd\r\n"
+ sdp += "a=ice-ufrag:afa2841a\r\n"
+ sdp += "a=mid:sdparta_0\r\n"
+ sdp += "a=msid:{83486b07-4708-46d3-92c7-909f5a598edc} {d04078f0-2166-4d12-b657-c7ca1bb5041b}\r\n"
+ sdp += "a=rtcp-mux\r\n"
+ sdp += "a=rtpmap:109 opus/48000/2\r\n"
+ sdp += "a=rtpmap:9 G722/8000/1\r\n"
+ sdp += "a=rtpmap:0 PCMU/8000\r\n"
+ sdp += "a=rtpmap:8 PCMA/8000\r\n"
+ sdp += "a=rtpmap:101 telephone-event/8000/1\r\n"
+ sdp += "a=setup:actpass\r\n"
+ sdp += "a=ssrc:645231268 cname:{b132b6ce-4687-4a65-9796-f82caca3ab92}\r\n"
+
+ to_address = voip_call.partner_id.mobile.strip()
+
+ if "@" not in to_address:
+ to_address = to_address + "@" + voip_account.domain
+
+ invite_string = ""
+ invite_string += "INVITE sip:" + to_address + ":" + str(voip_account.port) + " SIP/2.0\r\n"
+ invite_string += "Via: SIP/2.0/UDP " + local_ip + ":" + str(bind_port) + ";branch=z9hG4bK-524287-1---0d0dce78a0c26252;rport\r\n"
+ invite_string += "Max-Forwards: 70\r\n"
+ invite_string += "Contact: \r\n"
+ invite_string += 'To: \r\n"
+ invite_string += 'From: "' + voip_account.voip_display_name + '";tag=" + str(from_tag) + "\r\n"
+ invite_string += "Call-ID: " + request.env.cr.dbname + "-call-" + str(call_id) + "\r\n"
+ invite_string += "CSeq: 1 INVITE\r\n"
+ invite_string += "Allow: SUBSCRIBE, NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ invite_string += "Content-Type: application/sdp\r\n"
+ invite_string += "Supported: replaces\r\n"
+ invite_string += "User-Agent: Sythil Tech SIP Client\r\n"
+ invite_string += "Content-Length: " + str( len(sdp) ) + "\r\n"
+ invite_string += "\r\n"
+ invite_string += sdp
+
+ _logger.error(invite_string )
+
+ if voip_account.outbound_proxy:
+ sipsocket.sendto(invite_string, (voip_account.outbound_proxy, voip_account.port) )
+ else:
+ sipsocket.sendto(invite_string, (voip_account.domain, voip_account.port) )
+
+ stage = "WAITING"
+ while stage == "WAITING":
+ sipsocket.settimeout(10)
+ data, addr = sipsocket.recvfrom(2048)
+
+ _logger.error(data)
+
+ #Send auth response if challenged
+ if data.split("\r\n")[0] == "SIP/2.0 407 Proxy Authentication Required" or data.split("\r\n")[0] == "SIP/2.0 407 Proxy Authentication required":
+
+ authheader = re.findall(r'Proxy-Authenticate: (.*?)\r\n', data)[0]
+
+ realm = re.findall(r'realm="(.*?)"', authheader)[0]
+ method = "INVITE"
+ uri = "sip:" + to_address
+ nonce = re.findall(r'nonce="(.*?)"', authheader)[0]
+ qop = re.findall(r'qop="(.*?)"', authheader)[0]
+ nc = "00000001"
+ cnonce = ''.join([random.choice('0123456789abcdef') for x in range(32)])
+
+ #For now we assume qop is present (https://tools.ietf.org/html/rfc2617#section-3.2.2.1)
+ A1 = voip_account.username + ":" + realm + ":" + voip_account.password
+ A2 = method + ":" + uri
+ response = voip_account.KD( voip_account.H(A1), nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + voip_account.H(A2) )
+
+ reply = ""
+ reply += "INVITE sip:" + to_address + ":" + str(voip_account.port) + " SIP/2.0\r\n"
+ reply += "Via: SIP/2.0/UDP " + local_ip + ":" + str(bind_port) + ";branch=z9hG4bK-524287-1---0d0dce78a0c26252;rport\r\n"
+ reply += "Max-Forwards: 70\r\n"
+ reply += "Contact: \r\n"
+ reply += 'To: \r\n"
+ reply += 'From: "' + voip_account.voip_display_name + '";tag=" + str(from_tag) + "\r\n"
+ reply += "Call-ID: " + request.env.cr.dbname + "-call-" + str(call_id) + "\r\n"
+ reply += "CSeq: 1 INVITE\r\n"
+ reply += "Allow: SUBSCRIBE, NOTIFY, INVITE, ACK, CANCEL, BYE, REFER, INFO, OPTIONS, MESSAGE\r\n"
+ reply += "Content-Type: application/sdp\r\n"
+ reply += 'Proxy-Authorization: Digest username="' + voip_account.username + '",realm="' + realm + '",nonce="' + nonce + '",uri="sip:' + to_address + '",response="' + response + '",cnonce="' + cnonce + '",nc=' + nc + ',qop=auth,algorithm=MD5' + "\r\n"
+ reply += "Supported: replaces\r\n"
+ reply += "User-Agent: Sythil Tech SIP Client\r\n"
+ reply += "Content-Length: " + str( len(sdp) ) + "\r\n"
+ reply += "\r\n"
+ reply += sdp
+
+ sipsocket.sendto(reply, addr)
+
+ @api.model
+ def generate_server_ice(self, port, component_id):
+
+ ice_response = ""
+
+ #ip_addr = socket.gethostbyname(host)
+ ip = self.env['ir.default'].get('voip.settings', 'server_ip')
+
+ #See https://tools.ietf.org/html/rfc5245#section-4.1.2.1 (I don't make up these formulas...)
+ priority = int((2 ^ 24) * 126) + int((2 ^ 8) * 65535)
+
+ #For now we assume the server on has one public facing network card...
+ foundation = "0"
+
+ ice_response = "candidate:" + foundation + " " + str(component_id) + " UDP " + str(priority) + " " + str(ip) + " " + str(port) + " typ host"
+
+ return {"candidate":ice_response,"sdpMid":"sdparta_0","sdpMLineIndex":0}
+
+ @api.model
+ def generate_server_sdp(self):
+
+ sdp_response = ""
+
+ #Protocol Version ("v=") https://tools.ietf.org/html/rfc4566#section-5.1 (always 0 for us)
+ sdp_response += "v=0\r\n"
+
+ #Origin ("o=") https://tools.ietf.org/html/rfc4566#section-5.2 (Should come up with a better session id...)
+ sess_id = int(time.time()) #Not perfect but I don't expect more then one call a second
+ sess_version = 0 #Will always start at 0
+ sdp_response += "o=- " + str(sess_id) + " " + str(sess_version) + " IN IP4 0.0.0.0\r\n"
+
+ #Session Name ("s=") https://tools.ietf.org/html/rfc4566#section-5.3 (We don't need a session name, information about the call is all displayed in the UI)
+ sdp_response += "s= \r\n"
+
+ #Timing ("t=") https://tools.ietf.org/html/rfc4566#section-5.9 (For now sessions are infinite but we may use this if for example a company charges a price for a fixed 30 minute consultation)
+ sdp_response += "t=0 0\r\n"
+
+ #In later versions we might send the missed call mp3 via rtp
+ sdp_response += "a=sendrecv\r\n"
+
+ #TODO generate before call fingerprint...
+ sdp_response += "a=fingerprint:sha-256 DA:52:67:C5:2A:2E:91:13:A2:7D:3A:E1:2E:A4:F3:28:90:67:71:0E:B7:6F:7B:56:79:F4:B2:D1:54:4B:92:7E\r\n"
+ #sdp_response += "a=setup:actpass\r\n"
+ sdp_response += "a=setup:passive\r\n"
+ #sdp_response += "a=setup:active\r\n"
+
+ #Sure why not
+ sdp_response += "a=ice-options:trickle\r\n"
+
+ #Sigh no idea
+ sdp_response += "a=msid-semantic:WMS *\r\n"
+
+ #Random stuff, left here so I don't have get it a second time if needed
+ #example supported audio profiles: 109 9 0 8 101
+ #sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF 109 101\r\n"
+
+ #Media Descriptions ("m=") https://tools.ietf.org/html/rfc4566#section-5.14 (Message bank is audio only for now)
+ audio_codec = "9" #Use G722 Audio Profile
+ sdp_response += "m=audio 9 UDP/TLS/RTP/SAVPF " + audio_codec + "\r\n"
+
+ #Connection Data ("c=") https://tools.ietf.org/html/rfc4566#section-5.7 (always seems to be 0.0.0.0?)
+ sdp_response += "c=IN IP4 0.0.0.0\r\n"
+
+ #ICE creds (https://tools.ietf.org/html/rfc5245#page-76)
+ ice_ufrag = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(4))
+ ice_pwd = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(22))
+ sdp_response += "a=ice-ufrag:" + str(ice_ufrag) + "\r\n"
+ sdp_response += "a=ice-pwd:" + str(ice_pwd) + "\r\n"
+
+ #Ummm naming each media?!?
+ sdp_response += "a=mid:sdparta_0\r\n"
+
+ return {"type":"answer","sdp": sdp_response}
\ No newline at end of file
diff --git a/voip_sip_webrtc/models/voip_settings.py b/voip_sip_webrtc/models/voip_settings.py
new file mode 100644
index 000000000..31f70eff0
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_settings.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+import socket
+import threading
+import random
+import string
+import logging
+import requests
+_logger = logging.getLogger(__name__)
+from openerp.http import request
+import odoo
+from socket import gethostname
+from pprint import pprint
+from time import gmtime, mktime
+from os.path import exists, join
+import os
+import struct
+from hashlib import sha256
+
+from openerp import api, fields, models
+
+class VoipSettings(models.Model):
+
+ _name = "voip.settings"
+ _inherit = 'res.config.settings'
+
+ ringtone_id = fields.Many2one('voip.ringtone', string="Ringtone", required=True)
+ ring_duration = fields.Integer(string="Ring Duration (Seconds)", required=True)
+ server_ip = fields.Char(string="IP Address")
+ inactivity_time = fields.Integer(string="Inactivity Time (Minutes)", help="The amount of minutes before the user is considered offline", required=True)
+ record_calls = fields.Boolean(string="Record SIP Calls")
+ codec_id = fields.Many2one('voip.codec', string="Default Codec", help="When a call is accepted it specifies to use this codec, all media should be pre-transcoded in this codec ready to be streamed", required=True)
+
+ def find_outgoing_ip(self):
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ s.connect(("8.8.8.8", 80))
+ self.server_ip = s.getsockname()[0]
+ s.close()
+
+ @api.multi
+ def set_values(self):
+ super(VoipSettings, self).set_values()
+ self.env['ir.default'].set('voip.settings', 'ringtone_id', self.ringtone_id.id)
+ self.env['ir.default'].set('voip.settings', 'ring_duration', self.ring_duration)
+ self.env['ir.default'].set('voip.settings', 'server_ip', self.server_ip)
+ self.env['ir.default'].set('voip.settings', 'inactivity_time', self.inactivity_time)
+ self.env['ir.default'].set('voip.settings', 'record_calls', self.record_calls)
+ self.env['ir.default'].set('voip.settings', 'codec_id', self.codec_id.id)
+
+ @api.model
+ def get_values(self):
+ res = super(VoipSettings, self).get_values()
+ res.update(
+ ringtone_id=self.env['ir.default'].get('voip.settings', 'ringtone_id'),
+ ring_duration=self.env['ir.default'].get('voip.settings', 'ring_duration'),
+ server_ip=self.env['ir.default'].get('voip.settings', 'server_ip'),
+ inactivity_time=self.env['ir.default'].get('voip.settings', 'inactivity_time'),
+ record_calls=self.env['ir.default'].get('voip.settings', 'record_calls'),
+ codec_id=self.env['ir.default'].get('voip.settings', 'codec_id')
+ )
+ return res
+
+
+
diff --git a/voip_sip_webrtc/models/voip_voip.py b/voip_sip_webrtc/models/voip_voip.py
new file mode 100644
index 000000000..e52bad244
--- /dev/null
+++ b/voip_sip_webrtc/models/voip_voip.py
@@ -0,0 +1,81 @@
+# -*- coding: utf-8 -*-
+from openerp.http import request
+import socket
+import threading
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo import api, fields, models, registry
+
+class VoipVoip(models.Model):
+
+ _name = "voip.voip"
+ _description = "Voip Functions"
+
+ @api.model
+ def sip_read_message(self, data):
+ sip_dict = {}
+ for line in data.split("\n"):
+ sip_key = line.split(":")[0]
+ sip_value = line[len(sip_key) + 2:]
+ sip_dict[sip_key] = sip_value
+
+ #Get from SIP address
+ from_sip = sip_dict['From']
+ start = from_sip.index( "sip:" ) + 4
+ end = from_sip.index( ";", start )
+ sip_dict['from_sip'] = from_sip[start:end].replace(">","").strip()
+
+ #Get to SIP address
+ sip_dict['to_sip'] = sip_dict['To'].split("sip:")[1].strip()
+
+ return sip_dict
+
+ @api.model
+ def start_sip_call(self, to_partner):
+ #Ask for media permission from the caller
+ mode = "audiocall"
+ constraints = {'audio': True}
+ notification = {'mode': mode, 'to_partner_id': to_partner, 'constraints': constraints, 'call_type': 'external'}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.sip', self.env.user.partner_id.id), notification)
+
+ @api.model
+ def start_incoming_sip_call(self, sip_invite, addr, sip_tag):
+
+ sip_dict = self.sip_read_message(sip_invite)
+
+ #Find the from partner
+ from_partner_id = self.env['res.partner'].sudo().search([('sip_address', '=', sip_dict['from_sip'] )])
+
+ #Find the to partner
+ to_partner_id = self.env['res.partner'].sudo().search([('sip_address', '=', sip_dict['to_sip'] )])
+
+ #SIP INVITE will continously send, only allow one call from this person at a time, as a future feature if multiple people call they are allowed to join the call with permission
+ if self.env['voip.call'].search_count([('status', '=', 'pending'), ('from_partner_id', '=', from_partner_id.id), ('partner_id', '=', to_partner_id.id)]) < 50:
+
+ _logger.error("INVITE: " + str(sip_invite) )
+ _logger.error("from partner:" + str(from_partner_id.name) )
+ _logger.error("to partner:" + str(to_partner_id.name) )
+
+ #The call is created now so we can update it as a missed / rejected call or accepted, the timer for the call starts after being accepted though
+ voip_call = self.env['voip.call'].create({'type': 'external', 'direction': 'incoming', 'sip_tag': sip_tag})
+
+ ringtone = "/voip/ringtone/" + str(voip_call.id) + ".mp3"
+ ring_duration = self.env['ir.default'].get('voip.settings', 'ring_duration')
+
+ #Assign the caller and callee partner
+ voip_call.from_partner_id = from_partner_id
+ voip_call.partner_id = to_partner_id
+
+ #Add the to calle to the client list
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': to_partner_id.id, 'state':'joined', 'name': to_partner_id.name})
+
+ #Also add the external partner to the list but assume we already have media access, the long polling will be ignored since the from is external to the system
+ self.env['voip.call.client'].sudo().create({'vc_id':voip_call.id, 'partner_id': from_partner_id.id, 'state':'media_access', 'name': from_partner_id.name, 'sip_invite': sip_invite, 'sip_addr_host': addr[0], 'sip_addr_port': addr[1] })
+
+ #Send notification to callee
+ notification = {'voip_call_id': voip_call.id, 'ringtone': ringtone, 'ring_duration': ring_duration, 'from_name': from_partner_id.name, 'caller_partner_id': from_partner_id.id, 'direction': 'incoming', 'mode': "audiocall"}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.notification', to_partner_id.id), notification)
+
+ #Have to manually commit the new cursor?
+ self.env.cr.commit()
\ No newline at end of file
diff --git a/voip_sip_webrtc/security/ir.model.access.csv b/voip_sip_webrtc/security/ir.model.access.csv
new file mode 100644
index 000000000..81cd6429c
--- /dev/null
+++ b/voip_sip_webrtc/security/ir.model.access.csv
@@ -0,0 +1,15 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_voip_call","access voip.call","model_voip_call","base.group_user",1,1,1,0
+"access_voip_ringtone","access voip.ringetone","model_voip_ringtone","base.group_user",1,0,0,0
+"access_voip_call_client","access voip.call.client","model_voip_call_client","base.group_user",1,1,1,0
+"access_voip_voip","access voip.voip","model_voip_voip","base.group_user",1,1,1,1
+"access_voip_server","access voip.server","model_voip_server","base.group_user",1,1,1,1
+"access_voip_account","access voip.account","model_voip_account","base.group_user",1,1,1,1
+"access_voip_account_action","access voip.account.action","model_voip_account_action","base.group_user",1,1,1,1
+"access_voip_account_action_transition","access voip.account.action.transition","model_voip_account_action_transition","base.group_user",1,1,1,1
+"access_voip_account_action_type","access voip.account.action.type","model_voip_account_action_type","base.group_user",1,1,1,1
+"access_voip_codec","access voip.codec","model_voip_codec","base.group_user",1,1,1,1
+"access_voip_call_template","access voip.call.template","model_voip_call_template","base.group_user",1,1,1,1
+"access_voip_media","access voip.media","model_voip_media","base.group_user",1,1,1,1
+"access_voip_message_template","access voip.message.template","model_voip_message_template","base.group_user",1,1,1,1
+"access_voip_dialog","access voip.dialog","model_voip_dialog","base.group_user",1,1,1,1
\ No newline at end of file
diff --git a/voip_sip_webrtc/static/description/1.jpg b/voip_sip_webrtc/static/description/1.jpg
new file mode 100644
index 000000000..d5ddacdcb
Binary files /dev/null and b/voip_sip_webrtc/static/description/1.jpg differ
diff --git a/voip_sip_webrtc/static/description/icon.png b/voip_sip_webrtc/static/description/icon.png
new file mode 100644
index 000000000..eb680ef42
Binary files /dev/null and b/voip_sip_webrtc/static/description/icon.png differ
diff --git a/voip_sip_webrtc/static/description/index.html b/voip_sip_webrtc/static/description/index.html
new file mode 100644
index 000000000..9f0bce282
--- /dev/null
+++ b/voip_sip_webrtc/static/description/index.html
@@ -0,0 +1,23 @@
+
+
Description
+Make video calls with other users within your system
+*IMPORTANT* camera access requires https for Google Chrome web browser
+
+
+
Instructions
+1. Click on the phone icon in the top right hand corner
+2. You can select between making a video call, audio only call or screensharing
+3. The other user will receive a notification to answer the call
+4. Both users have to accept access to camera/audio
+5. Once both users have accepted media access the call will begin
+
+
+
+
+Ringtone courtesy of zedge.net
+
+For people interested in calling mobiles please consider checking out voip_sip_webrtc_twilio
+
+For those interested in making automated calls there is a a few beta modules for that.
+voip_sip_webrtc_transcode (for converting media into rtp streaming formats)
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/static/src/audio/blank.mp3 b/voip_sip_webrtc/static/src/audio/blank.mp3
new file mode 100644
index 000000000..ef84d711f
Binary files /dev/null and b/voip_sip_webrtc/static/src/audio/blank.mp3 differ
diff --git a/voip_sip_webrtc/static/src/audio/old_school_ringtone.mp3 b/voip_sip_webrtc/static/src/audio/old_school_ringtone.mp3
new file mode 100644
index 000000000..a9b1c5733
Binary files /dev/null and b/voip_sip_webrtc/static/src/audio/old_school_ringtone.mp3 differ
diff --git a/voip_sip_webrtc/static/src/css/notification.css b/voip_sip_webrtc/static/src/css/notification.css
new file mode 100644
index 000000000..31423b80c
--- /dev/null
+++ b/voip_sip_webrtc/static/src/css/notification.css
@@ -0,0 +1,57 @@
+.sy_phone_menu {
+ padding: 5px;
+}
+
+.sy_phone_menu i {
+ padding-left:5px;
+}
+
+.s-chat-manager {
+ width: 300px;
+ height: 300px;
+ z-index: 1100;
+ position:absolute;
+ bottom:10px;
+ left:10px;
+ resize: both;
+ padding:5px;
+ display:none;
+ border: black 1px solid;
+ overflow: hidden;
+ background-color: #EEEEEE;
+ box-shadow: 0px 0px 5px 1px #4c4c4c;
+}
+
+.s-voip-manager {
+ width: 300px;
+ height: auto;
+ z-index: 1100;
+ position:absolute;
+ bottom:10px;
+ right:10px;
+ resize: both;
+ display:none;
+ border: black 1px solid;
+ overflow: hidden;
+ background-color: #EEEEEE;
+ box-shadow: 0px 0px 5px 1px #4c4c4c;
+}
+
+.voip-call-image {
+ width:100px;
+ height:100px;
+ margin-bottom:10px;
+}
+
+.voip-controls-panel {
+ float:right;
+ width:100px;
+}
+
+.voip-video-container {
+ margin-right:110px;
+ margin-left:10px;
+ margin-top:10px;
+ margin-bottom:10px;
+ text-align:center;
+}
\ No newline at end of file
diff --git a/voip_sip_webrtc/static/src/js/notification.js b/voip_sip_webrtc/static/src/js/notification.js
new file mode 100644
index 000000000..37eb1f621
--- /dev/null
+++ b/voip_sip_webrtc/static/src/js/notification.js
@@ -0,0 +1,712 @@
+odoo.define('voip_sip_webrtc.voip_call_notification', function (require) {
+"use strict";
+
+var core = require('web.core');
+var framework = require('web.framework');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+var odoo_session = require('web.session');
+var web_client = require('web.web_client');
+var Widget = require('web.Widget');
+var ajax = require('web.ajax');
+var bus = require('bus.bus').bus;
+var Notification = require('web.notification').Notification;
+var WebClient = require('web.WebClient');
+var SystrayMenu = require('web.SystrayMenu');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+ajax.loadXML('/voip_sip_webrtc/static/src/xml/voip_window.xml', qweb);
+
+var mySound = "";
+var countdown;
+var secondsLeft;
+var callSeconds;
+var call_id = "";
+var myNotif = "";
+var incomingNotification;
+var incoming_ring_interval;
+var outgoingNotification;
+var role;
+var mode = false;
+var call_type = ""
+var to_partner_id;
+var outgoing_ring_interval;
+var call_interval;
+var call_sdp = "";
+var ice_candidate_queue = [];
+var got_remote_description = false;
+var userAgent;
+var to_sip;
+
+var peerConnectionConfig = {
+ 'iceServers': [
+ {'urls': 'stun:stun.services.mozilla.com'},
+ {'urls': 'stun:stun.l.google.com:19302'},
+ ]
+};
+
+//navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
+window.navigator.mediaDevices.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mediaDevices.getUserMedia || navigator.msGetUserMedia;
+window.RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
+window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate || window.webkitRTCIceCandidate;
+window.RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription || window.webkitRTCSessionDescription;
+
+var localStream;
+var localVideo = document.querySelector('#localVideo');
+var remoteVideo = document.querySelector('#remoteVideo');
+var remoteStream = "";
+
+var VoipCallClient = Widget.extend({
+ init: function (p_role, p_mode, p_call_type, p_to_partner_id) {
+ this.role = p_role;
+ this.mode = p_mode;
+ this.call_type = p_call_type;
+ this.to_partner_id = p_to_partner_id;
+
+ //temp fix since the rest of the code still uses global variables
+ role = p_role;
+ mode = p_mode;
+ call_type = p_call_type;
+ to_partner_id = p_to_partner_id;
+
+ },
+ requestMediaAccess: function (contraints) {
+
+ if (navigator.webkitGetUserMedia) {
+ navigator.webkitGetUserMedia(contraints, getUserMediaSuccess, getUserMediaError);
+ } else {
+ window.navigator.mediaDevices.getUserMedia(contraints).then(getUserMediaSuccess).catch(getUserMediaError);
+ }
+ },
+ endCall: function () {
+ console.log("End Call");
+ },
+});
+
+function getUserMediaSuccess(stream) {
+ console.log("Got Media Access");
+
+ $(".s-voip-manager").css("display","block");
+
+ localVideo = document.querySelector('#localVideo');
+ remoteVideo = document.querySelector('#remoteVideo');
+
+ localStream = stream;
+ localVideo.srcObject = stream;
+ //localVideo.src = window.URL.createObjectURL(stream);
+
+ window.peerConnection = new RTCPeerConnection(peerConnectionConfig);
+ window.peerConnection.onicecandidate = gotIceCandidate;
+ //window.peerConnection.ontrack = gotRemoteStream;
+ window.peerConnection.onaddstream = gotRemoteStream;
+ window.peerConnection.addStream(localStream);
+
+ console.log(role);
+ console.log(call_type);
+
+ if (role == "caller") {
+ if (call_type == "external") {
+ //Send the sdp now since we need it for the INVITE
+ window.peerConnection.createOffer().then(createCall).catch(errorHandler);
+ } else {
+
+ //Avoid sending the SDP data since it will stuff up the SDP answer
+ rpc.query({
+ model: 'voip.server',
+ method: 'voip_call_notify',
+ args: [mode, to_partner_id, call_type, '']
+ }).then(function(result){
+ console.log("Notify Callee of incoming phone call");
+ });
+
+ }
+ }
+
+ if (role == "callee") {
+ //Start sending out SDP now since both caller and callee have granted media access
+ console.log("Create SDP Offer");
+ window.peerConnection.createOffer().then(createdDescription).catch(errorHandler);
+ }
+
+
+}
+
+function getUserMediaError(error) {
+ alert("Failed to access to media: " + error);
+};
+
+
+
+WebClient.include({
+
+ show_application: function() {
+
+ $('body').append(qweb.render('voip_sip_webrtc.VoipWindow', {}));
+ $('body').append(qweb.render('voip_sip_webrtc.ChatWindow', {}));
+
+ $(".s-voip-manager").draggable().resizable({handles: 'ne, se, sw, nw'});
+ $(".s-chat-manager").draggable().resizable({handles: 'ne, se, sw, nw'});
+
+ bus.on('notification', this, function (notifications) {
+ _.each(notifications, (function (notification) {
+
+
+ if (notification[0][1] === 'voip.notification') {
+ var self = this;
+
+ call_id = notification[1].voip_call_id;
+
+ if (notification[1].direction == 'incoming') {
+ var from_name = notification[1].from_name;
+ var ringtone = notification[1].ringtone;
+ var caller_partner_id = notification[1].caller_partner_id;
+
+ call_sdp = notification[1].sdp;
+ role = "callee";
+ mode = notification[1].mode;
+
+ var notif_text = from_name + " wants you to join a " + mode;
+
+ countdown = notification[1].ring_duration;
+
+ incomingNotification = new VoipCallIncomingNotification(self.notification_manager, "Incoming Call", notif_text, call_id);
+ self.notification_manager.display(incomingNotification);
+ mySound = new Audio(ringtone);
+ mySound.loop = true;
+ mySound.play();
+
+ //Display an image of the person who is calling
+ $("#voipcallincomingimage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+ $("#toPartnerImage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+
+ } else if (notification[1].direction == 'outgoing') {
+ var to_name = notification[1].to_name;
+
+ var notif_text = "Calling " + to_name;
+ var callee_partner_id = notification[1].callee_partner_id
+
+ countdown = notification[1].ring_duration
+
+ outgoingNotification = new VoipCallOutgoingNotification(self.notification_manager, "Outgoing Call", notif_text, call_id);
+ self.notification_manager.display(outgoingNotification);
+
+ //Display an image of the person you are calling
+ $("#voipcalloutgoingimage").attr('src', '/web/image/res.partner/' + callee_partner_id + '/image_medium/image.jpg');
+ $("#toPartnerImage").attr('src', '/web/image/res.partner/' + callee_partner_id + '/image_medium/image.jpg');
+ }
+
+ } else if (notification[0][1] === 'voip.response') {
+
+ var status = notification[1].status;
+ var type = notification[1].type;
+
+ console.log("Response: " + status + " | " + type);
+
+ //Destroy the notifcation and stop the countdown because the call was accepted or rejected, no need to wait until timeout
+ if (typeof outgoingNotification !== "undefined") {
+ clearInterval(outgoing_ring_interval);
+ outgoingNotification.destroy(true);
+ }
+
+ } else if(notification[0][1] === 'voip.sdp') {
+ var sdp = notification[1].sdp;
+ console.log("Got SDP Type: " + sdp.type);
+ console.log("Got SDP Data: " + sdp.sdp);
+
+ window.peerConnection.setRemoteDescription(new RTCSessionDescription(sdp)).then(function() {
+ console.log("Set Remote Description");
+ // Only create answers in response to offers
+ if(sdp.type == 'offer') {
+ console.log("Create SDP Answer");
+ window.peerConnection.createAnswer().then(createdDescription).catch(errorHandler);
+ }
+
+ got_remote_description = true;
+ processIceQueue();
+
+ }).catch(errorHandler);
+
+ } else if(notification[0][1] === 'voip.ice') {
+ var ice = notification[1].ice;
+
+ console.log("Got Ice Candidate");
+
+ //Queue the ICE candidates that come before remote description is set
+ ice_candidate_queue.push(ice);
+
+ if (got_remote_description) {
+ processIceQueue();
+ }
+
+ } else if(notification[0][1] === 'voip.end') {
+ console.log("Call End");
+
+ //Stop all audio / video tracks
+ if (localStream) {
+ localStream.getTracks().forEach(track => track.stop());
+ }
+
+ if (remoteStream) {
+ remoteStream.getTracks().forEach(track => track.stop());
+ }
+
+ //Destroy the notifcation and stop the countdown because the call was accepted or rejected, no need to wait until timeout
+ if (typeof outgoingNotification !== "undefined") {
+ clearInterval(outgoing_ring_interval);
+ outgoingNotification.destroy(true);
+ }
+
+ if (typeof incomingNotification !== "undefined") {
+ clearInterval(call_interval);
+ incomingNotification.destroy(true);
+
+ mySound.pause();
+ mySound.currentTime = 0;
+ }
+
+ $("#voip_text").html("Starting Call...");
+ $(".s-voip-manager").css("display","none");
+
+ //Update Presence Light
+ $("#voip_presence_light").attr('class', 'fa fa-check-circle');
+ $("#voip_presence_light").attr('style', 'color:green;');
+
+ got_remote_description = false;
+
+ }
+
+
+ }).bind(this));
+
+ });
+ return this._super.apply(this, arguments);
+ },
+
+});
+
+function resetCall() {
+
+ //Stop all audio / video tracks
+ if (localStream) {
+ localStream.getTracks().forEach(track => track.stop());
+ }
+
+ if (remoteStream) {
+ remoteStream.getTracks().forEach(track => track.stop());
+ }
+
+ //Destroy the notifcation and stop the countdown because the call was accepted or rejected, no need to wait until timeout
+ if (typeof outgoingNotification !== "undefined") {
+ clearInterval(outgoing_ring_interval);
+ outgoingNotification.destroy(true);
+ }
+
+ if (typeof incomingNotification !== "undefined") {
+ clearInterval(call_interval);
+ incomingNotification.destroy(true);
+
+ mySound.pause();
+ mySound.currentTime = 0;
+ }
+
+ $("#voip_text").html("Starting Call...");
+ $(".s-voip-manager").css("display","none");
+
+ got_remote_description = false;
+
+}
+
+function errorHandler(error) {
+ console.log(error);
+}
+
+function onMessage(message) {
+
+
+ if (message.body.includes("");
+
+ var aor = message.remoteIdentity.uri.user + "@" + message.remoteIdentity.uri.host
+
+ $("#sip-panel-uri").html(message.remoteIdentity.displayName)
+
+ window.to_sip = aor;
+
+ }
+
+}
+
+function onInvite(session) {
+
+ console.log("Call Type: SIP");
+
+ $(".s-voip-manager").css("display","block");
+
+ window.sip_session = session;
+
+
+
+ mode = "audiocall";
+ call_type = "external";
+
+ var aor = session.remoteIdentity.uri.user + "@" + session.remoteIdentity.uri.host;
+
+ rpc.query({
+ model: 'voip.server',
+ method: 'sip_call_notify',
+ args: [mode, call_type, aor]
+ }).then(function(result){
+ console.log("Incoming SIP Call Notify");
+ });
+
+}
+
+function onPresence(notification) {
+ console.log("Presence");
+ console.log(notification.request.body);
+}
+
+
+function processIceQueue() {
+ console.log("Process Ice Queue");
+ for (var i = ice_candidate_queue.length - 1; i >= 0; i--) {
+ console.log("Add ICE Candidate:");
+ console.log(ice_candidate_queue[i]);
+
+ window.peerConnection.addIceCandidate(new RTCIceCandidate( ice_candidate_queue[i] )).catch(errorHandler);
+ ice_candidate_queue.splice(i, 1);
+ }
+
+}
+
+function createCall(description) {
+
+ window.peerConnection.setLocalDescription(description).then(function() {
+
+ //Send the call notification to the callee
+ rpc.query({
+ model: 'voip.server',
+ method: 'voip_call_notify',
+ args: [mode, to_partner_id, call_type, description]
+ }).then(function(result){
+ console.log("Notify Callee of incoming phone call");
+ });
+
+ }).catch(errorHandler);
+}
+
+function createdDescription(description) {
+
+ window.peerConnection.setLocalDescription(description).then(function() {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'voip_call_sdp',
+ args: [[call_id], description]
+ }).then(function(result){
+ console.log("Send SDP: " + description);
+ });
+
+ }).catch(errorHandler);
+}
+
+function messageBankDescription(description) {
+ console.log('Created Message Bank Description: ' + description.sdp);
+
+ window.peerConnection.setLocalDescription(description).then(function() {
+
+ //Send the sdp offer to the server
+ rpc.query({
+ model: 'voip.call',
+ method: 'message_bank',
+ args: [[call_id], description]
+ }).then(function(result){
+ console.log("Message Bank Call");
+ });
+
+
+ }).catch(errorHandler);
+}
+
+function gotIceCandidate(event) {
+ if(event.candidate != null) {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'voip_call_ice',
+ args: [[call_id], event.candidate]
+ }).then(function(result){
+ console.log("Send ICE Candidate: " + event.candidate);
+ });
+
+ }
+}
+
+function gotRemoteStream(event) {
+ console.log("Got Remote Stream: " + event.stream);
+ remoteVideo.srcObject = event.stream;
+ remoteStream = event.stream;
+
+ var startDate = new Date();
+
+ //Hide the image and replace it with the video stream
+ $("#toPartnerImage").css('display','none');
+ $("#remoteVideo").css('display','block');
+
+ //For calls with multiple streams (e.g. video calls) this get called twice so we use time difference as a work around
+ call_interval = setInterval(function() {
+ var endDate = new Date();
+ var seconds = (endDate.getTime() - startDate.getTime()) / 1000;
+
+ $("#voip_text").html( Math.round(seconds) + " seconds");
+ }, 1000);
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'begin_call',
+ args: [[call_id]],
+ context: weContext.get()
+ }).then(function(result){
+ console.log("Begin Call");
+ $("#voip_presence_light").attr('class', 'fa fa-exclamation-circle');
+ $("#voip_presence_light").attr('style', 'color:orange;');
+ });
+
+}
+
+function sipOnError(request) {
+ var cause = request.cause;
+
+ if (cause === SIP.C.causes.REJECTED) {
+ alert("Call was rejected");
+ } else {
+ console.log("SIP Call Error");
+ console.log(cause);
+ alert(request.cause);
+ }
+}
+
+var chatSubscription;
+
+$(document).on('click', '#voip_end_call', function(){
+
+ if (call_type == "external") {
+ window.sip_session.bye();
+ } else {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'end_call',
+ args: [[call_id]]
+ }).then(function(result){
+ console.log("End Call");
+ });
+ }
+
+});
+
+$(document).on('click', '#sip_message_send_button', function(){
+ window.userAgent.message(window.to_sip, $("#sip_address_textbox").val() );
+
+ //Add the message to the chat log
+ $("#sip-message-log").append("->" + $("#sip_address_textbox").val() + " ");
+
+ //Clear the message
+ $("#sip_address_textbox").val("");
+
+ //TODO also add to chatter
+});
+
+$(document).on('click', '#voip_create_window', function(){
+ console.log("Create Window");
+ var myWindow = window.open("/voip/window", "Voip Call in Progress", "width=500,height=500");
+
+ //Transfer the stream to the popup window
+ myWindow.getElementById('remoteVideo').src = remoteVideo.src;
+
+ //TODO??? Close the model to free up space or maybe keep it there as a fallback if popup is closed
+});
+
+$(document).on('click', '#voip_full_screen', function(){
+ $(".s-voip-manager").css("width","calc(100vw - 20px)");
+ $(".s-voip-manager").css("height","calc(100vh - 20px)");
+ $(".s-voip-manager").css("left","0px");
+ $(".s-voip-manager").css("top","0px");
+ $(".s-voip-manager").css("margin","10px");
+ $(".s-voip-manager").css("resize","none");
+ $("#remoteVideo").css("width","auto");
+});
+
+var VoipCallOutgoingNotification = Notification.extend({
+ template: "VoipCallOutgoingNotification",
+
+ init: function(parent, title, text, call_id) {
+ this._super(parent, title, text, true);
+ },
+ start: function() {
+ myNotif = this;
+ this._super.apply(this, arguments);
+ secondsLeft = countdown;
+ $("#callsecondsoutgoingleft").html(secondsLeft);
+
+ outgoing_ring_interval = setInterval(function() {
+ $("#callsecondsoutgoingleft").html(secondsLeft);
+ if (secondsLeft == 0) {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'miss_call',
+ args: [[call_id]]
+ }).then(function(result){
+ console.log("Missed Call");
+ });
+
+ //Send the offer to message bank (server)
+ /*
+ if (mode == "audiocall") {
+ window.peerConnection.createOffer().then(messageBankDescription).catch(errorHandler);
+ }
+ */
+
+ //Play the missed call audio
+ mySound = new Audio("/voip/miss/" + call_id + ".mp3");
+ mySound.play();
+
+ clearInterval(outgoing_ring_interval);
+ resetCall();
+ myNotif.destroy(true);
+ }
+
+ secondsLeft--;
+ }, 1000);
+
+ },
+});
+
+var VoipCallIncomingNotification = Notification.extend({
+ template: "VoipCallIncomingNotification",
+
+ init: function(parent, title, text, call_id) {
+ this._super(parent, title, text, true);
+
+
+ this.events = _.extend(this.events || {}, {
+ 'click .link2accept': function() {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'accept_call',
+ args: [[call_id]],
+ context: weContext.get()
+ }).then(function(result){
+ console.log("Accept Call");
+ });
+
+ //Clear the countdown now
+ clearInterval(incoming_ring_interval);
+
+ mySound.pause();
+ mySound.currentTime = 0;
+
+ //Constraints are slightly different for the callee e.g. for callee we don't need screen sharing access only audio
+ var constraints = {};
+ if (mode == "videocall") {
+ constraints = {audio: true, video: true};
+ } else if (mode == "audiocall") {
+ constraints = {audio: true};
+ } else if (mode == "screensharing") {
+ //constraints = {'audio': true, 'OfferToReceiveVideo': true};
+ constraints = {'audio': true, 'video': {'mediaSource': "screen"}};
+ }
+
+ console.log(call_type);
+ console.log(constraints);
+ if (call_type == "external") {
+ console.log("Accept SIP Call");
+
+ console.log(window.sip_session);
+
+ window.sip_session.accept({
+ media: {
+ constraints: {
+ audio: true
+ },
+ render: {
+ remote: document.getElementById('remoteVideo'),
+ local: document.getElementById('localVideo')
+ }
+ }
+ });
+
+ window.sip_session.on('failed', sipOnError);
+ window.sip_session.on('bye', resetCall);
+
+ } else {
+
+ //Ask for media access only if the call is accepted
+ if (navigator.webkitGetUserMedia) {
+ navigator.webkitGetUserMedia(constraints, getUserMediaSuccess, getUserMediaError);
+ } else {
+ window.navigator.mediaDevices.getUserMedia(constraints).then(getUserMediaSuccess).catch(getUserMediaError);
+ }
+ }
+
+ this.destroy(true);
+ },
+
+ 'click .link2reject': function() {
+
+ rpc.query({
+ model: 'voip.call',
+ method: 'reject_call',
+ args: [[call_id]]
+ }).then(function(result){
+ console.log("Reject Call");
+ });
+
+ //Clear the countdown now
+ clearInterval(incoming_ring_interval);
+
+ mySound.pause();
+ mySound.currentTime = 0;
+ this.destroy(true);
+ },
+ });
+ },
+ start: function() {
+ myNotif = this;
+ this._super.apply(this, arguments);
+ secondsLeft = countdown;
+ $("#callsecondsincomingleft").html(secondsLeft);
+
+ incoming_ring_interval = setInterval(function() {
+ $("#callsecondsincomingleft").html(secondsLeft);
+ if (secondsLeft == 0) {
+ mySound.pause();
+ mySound.currentTime = 0;
+ resetCall();
+ clearInterval(incoming_ring_interval);
+ myNotif.destroy(true);
+ }
+
+ secondsLeft--;
+ }, 1000);
+
+ },
+});
+
+
+return {
+ VoipCallClient: VoipCallClient,
+};
+
+
+});
\ No newline at end of file
diff --git a/voip_sip_webrtc/static/src/js/voip_system_tray.js b/voip_sip_webrtc/static/src/js/voip_system_tray.js
new file mode 100644
index 000000000..09695e584
--- /dev/null
+++ b/voip_sip_webrtc/static/src/js/voip_system_tray.js
@@ -0,0 +1,116 @@
+odoo.define('voip_sip_webrtc.voip_system_tray', function (require) {
+"use strict";
+
+var core = require('web.core');
+var framework = require('web.framework');
+var odoo_session = require('web.session');
+var web_client = require('web.web_client');
+var Widget = require('web.Widget');
+var ajax = require('web.ajax');
+var bus = require('bus.bus').bus;
+var Notification = require('web.notification').Notification;
+var WebClient = require('web.WebClient');
+var SystrayMenu = require('web.SystrayMenu');
+var voip_notification = require('voip_sip_webrtc.voip_call_notification');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var VOIPSystemTray = Widget.extend({
+ template:'VoipSystemTray',
+ events: {
+ "click": "on_click",
+ "click .start_voip_audio_call": "start_voip_audio_call",
+ "click .start_voip_video_call": "start_voip_video_call",
+ "click .start_voip_screenshare_call": "start_voip_screenshare_call",
+ },
+ on_click: function (event) {
+ event.preventDefault();
+
+ rpc.query({
+ model: 'voip.server',
+ method: 'user_list',
+ args: [],
+ context: weContext.get()
+ }).then(function(result){
+
+ $("#voip_tray").html("");
+
+ for (var voip_user in result) {
+ var voip_user = result[voip_user];
+ var drop_menu_html = "";
+
+ drop_menu_html += "
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/views/voip_settings_views.xml b/voip_sip_webrtc/views/voip_settings_views.xml
new file mode 100644
index 000000000..1edf367d1
--- /dev/null
+++ b/voip_sip_webrtc/views/voip_settings_views.xml
@@ -0,0 +1,37 @@
+
+
+
+
+ voip.settings.view.form
+ voip.settings
+
+
+
+
+
+
+
Webrtc Settings
+
+
+
+
+
+
SIP Settings
+
+
+
+
+
+
+
+
+
+
+
+ VOIP Settings
+ voip.settings
+ form
+ inline
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc/views/voip_sip_webrtc_templates.xml b/voip_sip_webrtc/views/voip_sip_webrtc_templates.xml
new file mode 100644
index 000000000..8c4393c57
--- /dev/null
+++ b/voip_sip_webrtc/views/voip_sip_webrtc_templates.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/__init__.py b/voip_sip_webrtc_transcode/__init__.py
new file mode 100644
index 000000000..cde864bae
--- /dev/null
+++ b/voip_sip_webrtc_transcode/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
diff --git a/voip_sip_webrtc_transcode/__manifest__.py b/voip_sip_webrtc_transcode/__manifest__.py
new file mode 100644
index 000000000..804fea4bf
--- /dev/null
+++ b/voip_sip_webrtc_transcode/__manifest__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Voip Communication - Transcoding",
+ 'version': "1.0.1",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Transcodes audio into formats suitable for RTP transport",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/voip_media_transcode_wizard_views.xml',
+ 'views/voip_media_views.xml',
+ ],
+ 'demo': [],
+ 'depends': ['voip_sip_webrtc'],
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/doc/changelog.rst b/voip_sip_webrtc_transcode/doc/changelog.rst
new file mode 100644
index 000000000..df924f1ed
--- /dev/null
+++ b/voip_sip_webrtc_transcode/doc/changelog.rst
@@ -0,0 +1,7 @@
+v1.0.1
+======
+* Set a default codec to reduce confusion and complexity
+
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/models/__init__.py b/voip_sip_webrtc_transcode/models/__init__.py
new file mode 100644
index 000000000..12f45c431
--- /dev/null
+++ b/voip_sip_webrtc_transcode/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import voip_media_transcode_wizard
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/models/voip_media_transcode_wizard.py b/voip_sip_webrtc_transcode/models/voip_media_transcode_wizard.py
new file mode 100644
index 000000000..aaf8060aa
--- /dev/null
+++ b/voip_sip_webrtc_transcode/models/voip_media_transcode_wizard.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import base64
+import subprocess
+import tempfile
+
+from openerp import api, fields, models
+
+class VoipMediaTranscodeWizard(models.TransientModel):
+
+ _name = 'voip.media.transcode.wizard'
+
+ @api.model
+ def _get_default_codec_id(self):
+ return self.env['ir.default'].get('voip.settings','codec_id')
+
+ media_id = fields.Many2one('voip.media', string="Media")
+ media = fields.Binary(string="Audio File", required="True")
+ media_filename = fields.Char(string="Audio File Filename")
+ codec_id = fields.Many2one('voip.codec', default=_get_default_codec_id, string="Codec", required="True")
+
+ def transcode(self):
+ _logger.error("Transcode")
+
+ with tempfile.NamedTemporaryFile(suffix='.' + self.media_filename.split(".")[-1]) as tmp:
+ #Write the media to a temp file
+ tmp.write( base64.decodestring(self.media) )
+
+ #Transcode the file
+ output_filepath = tempfile.gettempdir() + "/output.raw"
+ subprocess.call(['sox', tmp.name, "--rate", str(self.codec_id.sample_rate), "--channels", "1", "--encoding", self.codec_id.encoding, "--type","raw", output_filepath])
+
+ #Read the transcoded file
+ file_content = open(output_filepath, 'rb').read()
+
+ #Save the transcoded file to the media template
+ self.media_id.media = base64.encodestring(file_content)
+ self.media_id.media_filename = self.media_filename
+ self.media_id.codec_id = self.codec_id.id
+
+ #Clean up temp file
+ tmp.close()
+
+ #TODO cleanup output.raw
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/static/description/icon.png b/voip_sip_webrtc_transcode/static/description/icon.png
new file mode 100644
index 000000000..eb680ef42
Binary files /dev/null and b/voip_sip_webrtc_transcode/static/description/icon.png differ
diff --git a/voip_sip_webrtc_transcode/static/description/index.html b/voip_sip_webrtc_transcode/static/description/index.html
new file mode 100644
index 000000000..ac1706fcb
--- /dev/null
+++ b/voip_sip_webrtc_transcode/static/description/index.html
@@ -0,0 +1,10 @@
+
+
Description
+Transcodes audio into formats suitable for RTP transport
+
+*NOTE* This module requires that the SoX software is installed
+sudo apt-get install sox
+
+You may also need to install the mp3 header if you are converting from mp3 files
+sudo apt-get install libsox-fmt-mp3
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/views/voip_media_transcode_wizard_views.xml b/voip_sip_webrtc_transcode/views/voip_media_transcode_wizard_views.xml
new file mode 100644
index 000000000..de4b78bfa
--- /dev/null
+++ b/voip_sip_webrtc_transcode/views/voip_media_transcode_wizard_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ voip.media.transcode.wizard form view
+ voip.media.transcode.wizard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ voip.media.transcode.wizard
+ form
+ Audio Transcode Wizard (VOIP Media)
+ form
+ {'default_media_id': active_id}
+ new
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_transcode/views/voip_media_views.xml b/voip_sip_webrtc_transcode/views/voip_media_views.xml
new file mode 100644
index 000000000..d0cb96296
--- /dev/null
+++ b/voip_sip_webrtc_transcode/views/voip_media_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ voip.media form view inherit transcode
+ voip.media
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/__init__.py b/voip_sip_webrtc_twilio/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/voip_sip_webrtc_twilio/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/__manifest__.py b/voip_sip_webrtc_twilio/__manifest__.py
new file mode 100644
index 000000000..26cb3031f
--- /dev/null
+++ b/voip_sip_webrtc_twilio/__manifest__.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Voip Communication - Twilio",
+ 'version': "1.0.25",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Add support for Twilio XML",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/voip_number_views.xml',
+ 'views/voip_twilio_views.xml',
+ 'views/voip_call_views.xml',
+ 'views/voip_twilio_invoice_views.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_users_views.xml',
+ 'views/crm_lead_views.xml',
+ 'views/voip_call_wizard_views.xml',
+ 'views/voip_account_action_views.xml',
+ 'views/voip_sip_webrtc_twilio_templates.xml',
+ 'views/voip_call_comment_views.xml',
+ 'views/mail_activity_views.xml',
+ 'views/menus.xml',
+ #'data/voip.account.action.type.csv',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [],
+ 'depends': ['voip_sip_webrtc', 'account'],
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/controllers/__init__.py b/voip_sip_webrtc_twilio/controllers/__init__.py
new file mode 100644
index 000000000..afffdb590
--- /dev/null
+++ b/voip_sip_webrtc_twilio/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import main
+from . import bus
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/controllers/bus.py b/voip_sip_webrtc_twilio/controllers/bus.py
new file mode 100644
index 000000000..7514f5e7a
--- /dev/null
+++ b/voip_sip_webrtc_twilio/controllers/bus.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*
+
+from odoo.addons.bus.controllers.main import BusController
+from odoo.http import request
+
+class VoipTwilioBusController(BusController):
+ # --------------------------
+ # Extends BUS Controller Poll
+ # --------------------------
+ def _poll(self, dbname, channels, last, options):
+ if request.session.uid:
+
+ #Triggers the voip javascript client to start the call
+ channels.append((request.db, 'voip.twilio.start', request.env.user.partner_id.id))
+
+ return super(VoipTwilioBusController, self)._poll(dbname, channels, last, options)
diff --git a/voip_sip_webrtc_twilio/controllers/main.py b/voip_sip_webrtc_twilio/controllers/main.py
new file mode 100644
index 000000000..82694c707
--- /dev/null
+++ b/voip_sip_webrtc_twilio/controllers/main.py
@@ -0,0 +1,156 @@
+# -*- coding: utf-8 -*-
+import openerp.http as http
+import werkzeug
+from odoo.http import request
+from odoo.exceptions import UserError
+import json
+import base64
+import time
+import urllib.parse
+import jwt
+import hmac
+import hashlib
+
+import logging
+_logger = logging.getLogger(__name__)
+
+class TwilioVoiceController(http.Controller):
+
+ @http.route('/voip/ringtone.mp3', type="http", auth="user")
+ def voip_ringtone_mp3(self):
+ """Return the ringtone file to be used by javascript"""
+
+ voip_ringtone_id = request.env['ir.default'].get('voip.settings', 'ringtone_id')
+ voip_ringtone = request.env['voip.ringtone'].browse( voip_ringtone_id )
+ ringtone_media = voip_ringtone.media
+
+ headers = []
+ ringtone_base64 = base64.b64decode(ringtone_media)
+ headers.append(('Content-Length', len(ringtone_base64)))
+ response = request.make_response(ringtone_base64, headers)
+
+ return response
+
+ @http.route('/twilio/voice', type='http', auth="public", csrf=False)
+ def twilio_voice(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ from_sip = values['From']
+ to_sip = values['To']
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += " \n"
+ twilio_xml += " " + to_sip + ";region=gll\n"
+ twilio_xml += " \n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/voice/route', type='http', auth="public", csrf=False)
+ def twilio_voice_route(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ from_number = values['From']
+ to_number = values['To']
+
+ to_stored_number = request.env['voip.number'].sudo().search([('number','=',to_number)])
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += ' ' + "\n"
+
+ #Call all the users assigned to this number
+ for call_route in to_stored_number.call_routing_ids:
+ twilio_xml += " " + call_route.twilio_client_name + "\n"
+
+ twilio_xml += " \n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/client-voice', type='http', auth="public", csrf=False)
+ def twilio_client_voice(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ _logger.error(field_name)
+ _logger.error(field_value)
+ values[field_name] = field_value
+
+ from_number = values['From']
+ to_number = values['To']
+
+ twilio_xml = ""
+ twilio_xml += '' + "\n"
+ twilio_xml += "\n"
+ twilio_xml += ' " + to_number + "\n"
+ twilio_xml += ""
+
+ return twilio_xml
+
+ @http.route('/twilio/capability-token/', type='http', auth="user", csrf=False)
+ def twilio_capability_token(self, stored_number_id, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ stored_number = request.env['voip.number'].browse( int(stored_number_id) )
+
+ # Find these values at twilio.com/console
+ account_sid = stored_number.account_id.twilio_account_sid
+ auth_token = stored_number.account_id.twilio_auth_token
+
+ application_sid = stored_number.twilio_app_id
+
+ #Automatically create a Twilio client name for the user if one has not been manually set up
+ if not request.env.user.twilio_client_name:
+ request.env.user.twilio_client_name = request.env.cr.dbname + "_user_" + str(request.env.user.id)
+
+ header = '{"typ":"JWT",' + "\r\n"
+ header += ' "alg":"HS256"}'
+ base64_header = base64.b64encode(header.encode("utf-8"))
+
+ payload = '{"exp":' + str(time.time() + 60) + ',' + "\r\n"
+ payload += ' "iss":"' + account_sid + '",' + "\r\n"
+ payload += ' "scope":"scope:client:outgoing?appSid=' + application_sid + '&clientName=' + request.env.user.twilio_client_name + ' scope:client:incoming?clientName=' + request.env.user.twilio_client_name + '"}'
+ base64_payload = base64.b64encode(payload.encode("utf-8"))
+ base64_payload = base64_payload.decode("utf-8").replace("+","-").replace("/","_").replace("=","").encode("utf-8")
+
+ signing_input = base64_header + b"." + base64_payload
+ secret = bytes(auth_token.encode('utf-8'))
+ signature = base64.b64encode(hmac.new(secret, signing_input, digestmod=hashlib.sha256).digest())
+ signature = signature.decode("utf-8").replace("+","-").replace("/","_").replace("=","").encode("utf-8")
+
+ token = base64_header + b"." + base64_payload + b"." + signature
+ return json.dumps({'indentity': request.env.user.twilio_client_name, 'token': token.decode("utf-8")})
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/data/voip.account.action.type.csv b/voip_sip_webrtc_twilio/data/voip.account.action.type.csv
new file mode 100644
index 000000000..b76ab0d81
--- /dev/null
+++ b/voip_sip_webrtc_twilio/data/voip.account.action.type.csv
@@ -0,0 +1,2 @@
+"id","name","internal_name"
+"call_users","Call Users","call_users"
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/doc/changelog.rst b/voip_sip_webrtc_twilio/doc/changelog.rst
new file mode 100644
index 000000000..5512075f6
--- /dev/null
+++ b/voip_sip_webrtc_twilio/doc/changelog.rst
@@ -0,0 +1,94 @@
+v1.0.25
+=======
+* Fix an upgrade issue
+
+v1.0.24
+=======
+* Redirect to record after making call comment
+
+v1.0.23
+=======
+* Add 'Make Twilio Call' button to activity screen to optimise workflow
+
+v1.0.22
+=======
+* Call log report now has total cost / total duration
+* Invoices created by the Create Invoice button will generate a unique pdf more tailored to calls (e.g. no quantity column)
+
+v1.0.21
+=======
+* Fix Call History report and add ability to skip report (can be time consuming)
+
+v1.0.20
+=======
+* Fix JWT renewal for incoming calls
+
+v1.0.19
+=======
+* Feature to assign a number to a user to speed up calling
+
+v1.0.18
+=======
+* Display user friendly error if call fails for any reason e.g. no media access
+
+v1.0.17
+=======
+* Smart button on Twilio account screen that shows calls for that account
+
+v1.0.16
+=======
+* Code out the need to have the Twilio Python library installed for capabilty token generation
+
+v1.0.15
+=======
+* Fix call history import
+
+v1.0.14
+=======
+* Fix cability token url using http in https systems
+
+v1.0.13
+=======
+* Fix mySound undefined bug on outgoing calls
+
+v1.0.12
+=======
+* Automatically generate Twilio client name
+
+v1.0.11
+=======
+* Ability to call leads
+
+v1.0.10
+=======
+* Can now write a post call comment, you can also listen to call recording using a link inside the chatter
+* Call recordings are no longer downloaded, instead only the url is keep (prevents post call hang due to waiting for download + increased privacy / security not having a copy inside Odoo)
+
+v1.0.9
+======
+* Update Import call history to also import call recordings
+* Calls are now added to the Odoo call log with their recordings
+
+v1.0.8
+======
+* Bug fix to also record outgoing SIP calls not just the Twilio javascript client ones.
+
+v1.0.7
+======
+* Twilio capability token generation (easy setup)
+
+v1.0.6
+======
+* Fix incorrect voice URL
+
+v1.0.5
+======
+* Answer calls from within your browser
+
+v1.0.4
+======
+* Merge with twilio bill and introduce manual call functionality
+
+v1.0.0
+======
+* Port to version 11
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/__init__.py b/voip_sip_webrtc_twilio/models/__init__.py
new file mode 100644
index 000000000..340ae4af7
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from . import voip_twilio
+from . import voip_call
+from . import voip_call_wizard
+from . import res_partner
+from . import voip_number
+from . import voip_account_action
+from . import crm_lead
+from . import mail_activity
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/crm_lead.py b/voip_sip_webrtc_twilio/models/crm_lead.py
new file mode 100644
index 000000000..8154d523d
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/crm_lead.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+import logging
+_logger = logging.getLogger(__name__)
+
+class CRMLeadTwilioVoip(models.Model):
+
+ _inherit = "crm.lead"
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ my_context = {'default_to_number': self.mobile, 'default_record_model': 'crm.lead', 'default_record_id': self.id}
+
+ #Use the first number you find
+ default_number = self.env['voip.number'].search([])
+ if default_number:
+ my_context['default_from_number'] = default_number[0].id
+ else:
+ raise UserError("No numbers found, can not make call")
+
+ if self.env.user.twilio_assigned_number_id:
+ self.env['voip.call.wizard'].create({'to_number': self.mobile, 'record_model': 'crm.lead', 'record_id': self.id, 'from_number': self.env.user.twilio_assigned_number_id.id}).start_call()
+ #my_context['default_from_number'] = self.env.user.twilio_assigned_number_id.id
+ return True
+
+ return {
+ 'name': 'Voip Call Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.call.wizard',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/mail_activity.py b/voip_sip_webrtc_twilio/models/mail_activity.py
new file mode 100644
index 000000000..07876cd15
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/mail_activity.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+class MailActivityTwilioVoip(models.Model):
+
+ _inherit = "mail.activity"
+
+ activity_type_id_ref = fields.Char(string="Activity Type External Reference", compute="_compute_activity_type_id_ref")
+
+ @api.depends('activity_type_id_ref')
+ def _compute_activity_type_id_ref(self):
+ if self.activity_type_id:
+ external_id = self.env['ir.model.data'].sudo().search([('model', '=', 'mail.activity.type'), ('res_id', '=', self.activity_type_id.id)])
+ if external_id:
+ self.activity_type_id_ref = external_id.complete_name
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ #Call the mobile action of whatever record the activity is assigned to (this will fail if it is not crm.lead of res.partner)
+ assigned_record = self.env[self.res_model].browse(self.res_id)
+ return assigned_record.twilio_mobile_action()
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/res_partner.py b/voip_sip_webrtc_twilio/models/res_partner.py
new file mode 100644
index 000000000..f8f3dc9ee
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/res_partner.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+import logging
+_logger = logging.getLogger(__name__)
+import datetime
+
+class ResPartnerTwilioVoip(models.Model):
+
+ _inherit = "res.partner"
+
+ call_routing_ids = fields.Many2many('voip.number', string="Call Routing")
+ twilio_client_name = fields.Char(string="Twilio Client Name")
+ twilio_assigned_number_id = fields.Many2one('voip.number', string="Assigned Number")
+
+ @api.multi
+ def twilio_mobile_action(self):
+ self.ensure_one()
+
+ my_context = {'default_to_number': self.mobile, 'default_record_model': 'res.partner', 'default_record_id': self.id, 'default_partner_id': self.id}
+
+ #Use the first number you find
+ default_number = self.env['voip.number'].search([])
+ if default_number:
+ my_context['default_from_number'] = default_number[0].id
+ else:
+ raise UserError("No numbers found, can not make call")
+
+ if self.env.user.twilio_assigned_number_id:
+ self.env['voip.call.wizard'].create({'to_number': self.mobile, 'record_model': 'res.partner', 'record_id': self.id, 'partner_id': self.id, 'from_number': self.env.user.twilio_assigned_number_id.id}).start_call()
+ #my_context['default_from_number'] = self.env.user.twilio_assigned_number_id.id
+ return True
+
+ return {
+ 'name': 'Voip Call Compose',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.call.wizard',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': my_context
+ }
+
+class ResUsersTwilioVoip(models.Model):
+
+ _inherit = "res.users"
+
+ call_routing_ids = fields.Many2many('voip.number', string="Call Routing")
+ twilio_client_name = fields.Char(string="Twilio Client Name")
+ twilio_assigned_number_id = fields.Many2one('voip.number', string="Assigned Number")
+
+ def get_call_details(self, conn):
+ twilio_from_mobile = conn['parameters']['From']
+
+ #conn['parameters']['AccountSid']
+
+ call_from_partner = self.env['res.partner'].sudo().search([('mobile','=',conn['parameters']['From'])])
+ caller_partner_id = False
+
+ if call_from_partner:
+ from_name = call_from_partner.name + " (" + twilio_from_mobile + ")"
+ caller_partner_id = call_from_partner.id
+ else:
+ from_name = twilio_from_mobile
+
+ ringtone = "/voip/ringtone.mp3"
+ ring_duration = self.env['ir.default'].get('voip.settings', 'ring_duration')
+
+ #Create the call now so we can record even missed calls
+ #I have no idea why we don't get the To address, maybe it provided during call accept / reject or timeout?!?
+ voip_call = self.env['voip.call'].create({'status': 'pending', 'from_address': twilio_from_mobile, 'from_partner_id': call_from_partner.id, 'ring_time': datetime.datetime.now(), 'record_model': 'res.partner', 'record_id': call_from_partner.id or False})
+
+ return {'from_name': from_name, 'caller_partner_id': caller_partner_id, 'ringtone': ringtone, 'ring_duration': ring_duration, 'voip_call_id': voip_call.id}
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/voip_account_action.py b/voip_sip_webrtc_twilio/models/voip_account_action.py
new file mode 100644
index 000000000..ddabbdfc5
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/voip_account_action.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+from openerp import api, fields, models
+
+import logging
+_logger = logging.getLogger(__name__)
+
+class VoipAccountActionInheritTwilio(models.Model):
+
+ _inherit = "voip.account.action"
+
+ call_user_ids = fields.Many2many('res.users', string="Call Users")
+
+ def _voip_action_call_users(self, session, data):
+ for call_user in self.call_user_ids:
+ _logger.error("Call User " + call_user.name)
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/voip_call.py b/voip_sip_webrtc_twilio/models/voip_call.py
new file mode 100644
index 000000000..8578cfc5f
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/voip_call.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import requests
+import base64
+import json
+import datetime
+import time
+from email import utils
+from openerp.http import request
+from odoo.exceptions import UserError
+
+from openerp import api, fields, models
+
+class VoipCallInheritTWilio(models.Model):
+
+ _inherit = "voip.call"
+
+ twilio_sid = fields.Char(string="Twilio SID")
+ twilio_account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ currency_id = fields.Many2one('res.currency', string="Currency")
+ price = fields.Float(string="Price")
+ margin = fields.Float(string="Margin")
+ twilio_number_id = fields.Many2one('voip.number', string="Twilio Number")
+ twilio_call_recording_uri = fields.Char(string="Twilio Call Recording URI")
+ twilio_call_recording = fields.Binary(string="Twilio Call Recording")
+ twilio_call_recording_filename = fields.Char(string="Twilio Call Recording Filename")
+ record_model = fields.Char(string="Record Model", help="Name of the model this call was to e.g. res.partner / crm.lead")
+ record_id = fields.Char(string="Record ID", help="ID of the record the call was to")
+
+ @api.multi
+ def add_twilio_call(self, call_sid):
+ self.ensure_one()
+
+ if call_sid is None:
+ raise UserError('Call Failed')
+
+ self.twilio_sid = call_sid
+
+ #Fetch the recording for this call
+ twilio_account = self.twilio_account_id
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + twilio_account.twilio_account_sid + "/Calls/" + call_sid + ".json", auth=(str(twilio_account.twilio_account_sid), str(twilio_account.twilio_auth_token)))
+
+ call = json.loads(response_string.text)
+
+ #Fetch the recording if it exists
+ if 'subresource_uris' in call:
+ if 'recordings' in call['subresource_uris']:
+ if call['subresource_uris']['recordings'] != '':
+ recording_response = requests.get("https://api.twilio.com" + call['subresource_uris']['recordings'], auth=(str(twilio_account.twilio_account_sid), str(twilio_account.twilio_auth_token)))
+ recording_json = json.loads(recording_response.text)
+ for recording in recording_json['recordings']:
+ self.twilio_call_recording_uri = "https://api.twilio.com" + recording['uri'].replace(".json",".mp3")
+
+ if 'price' in call:
+ if call['price'] is not None:
+ if float(call['price']) != 0.0:
+ self.currency_id = self.env['res.currency'].search([('name','=', call['price_unit'])])[0].id
+ self.price = -1.0 * float(call['price'])
+
+ #Have to map the Twilio call status to the one in the core module
+ twilio_status = call['status']
+ if twilio_status == "queued":
+ self.status = "pending"
+ elif twilio_status == "ringing":
+ self.status = "pending"
+ elif twilio_status == "in-progress":
+ self.status = "active"
+ elif twilio_status == "canceled":
+ self.status = "cancelled"
+ elif twilio_status == "completed":
+ self.status = "over"
+ elif twilio_status == "failed":
+ self.status = "failed"
+ elif twilio_status == "busy":
+ self.status = "busy"
+ elif twilio_status == "no-answer":
+ self.status = "failed"
+
+ self.start_time = datetime.datetime.strptime(call['start_time'], '%a, %d %b %Y %H:%M:%S %z').strftime('%Y-%m-%d %H:%M:%S')
+ self.end_time = datetime.datetime.strptime(call['end_time'], '%a, %d %b %Y %H:%M:%S %z').strftime('%Y-%m-%d %H:%M:%S')
+
+ #Duration includes the ring time
+ self.duration = call['duration']
+
+ #Post to the chatter about the call
+ #callee = self.env[voip_call.record_model].browse( int(voip_call.record_id) )
+ #message_body = "A call was made using " + voip_call.twilio_number_id.name + " it lasted " + str(voip_call.duration) + " seconds"
+ #my_message = callee.message_post(body=message_body, subject="Twilio Outbound Call")
+
+
+class VoipCallComment(models.TransientModel):
+
+ _name = "voip.call.comment"
+
+ call_id = fields.Many2one('voip.call', string="Voip Call")
+ note = fields.Html(string="Note")
+
+ @api.multi
+ def post_feedback(self):
+ self.ensure_one()
+
+ message = self.env['mail.message']
+
+ if self.call_id.record_model and self.call_id.record_id:
+ record = self.env[self.call_id.record_model].browse(self.call_id.record_id)
+
+ call_activity = self.env['ir.model.data'].get_object('mail','mail_activity_data_call')
+ record_model = self.env['ir.model'].search([('model','=', self.call_id.record_model)])
+ #Create an activity then mark it as done
+ note = self.note + "From Number: " + self.call_id.twilio_number_id.name
+
+ #Chatter will sanitise html5 audo so instead place a url
+ setting_record_calls = self.env['ir.default'].get('voip.settings','record_calls')
+ if setting_record_calls:
+ note += " Recording: " + 'Play Online'
+
+ mail_activity = self.env['mail.activity'].create({'res_model_id': record_model.id, 'res_id': self.call_id.record_id, 'activity_type_id': call_activity.id, 'note': note})
+ mail_activity.action_feedback()
+
+
+ return {'type': 'ir.actions.act_window',
+ 'res_model': self.call_id.record_model,
+ 'view_mode': 'form',
+ 'res_id': int(self.call_id.record_id)}
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/voip_call_wizard.py b/voip_sip_webrtc_twilio/models/voip_call_wizard.py
new file mode 100644
index 000000000..6a35276ea
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/voip_call_wizard.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import datetime
+
+from odoo import api, fields, models
+
+class VoipCallWizard(models.Model):
+
+ _name = "voip.call.wizard"
+ _description = "Twilio Call Wizard"
+
+ record_model = fields.Char(string="Record Model")
+ record_id = fields.Integer(string="Record ID")
+ partner_id = fields.Many2one('res.partner')
+ from_number = fields.Many2one('voip.number', string="From Number")
+ to_number = fields.Char(string="To Number", readonly="True")
+
+ def start_call(self):
+
+ #Create the call record now
+ voip_call = self.env['voip.call'].create({'status': 'pending', 'twilio_number_id': self.from_number.id, 'twilio_account_id': self.from_number.account_id.id, 'from_address': self.from_number.number, 'from_partner_id': self.env.user.partner_id.id, 'to_address': self.to_number, 'to_partner_id': self.partner_id.id, 'ring_time': datetime.datetime.now(), 'record_model': self.record_model, 'record_id': self.record_id})
+
+ #Send notification to self to start the Twilio javascript client
+ notification = {'from_number': self.from_number.number, 'to_number': self.to_number, 'capability_token_url': self.from_number.capability_token_url, 'call_id': voip_call.id}
+ self.env['bus.bus'].sendone((self._cr.dbname, 'voip.twilio.start', self.env.user.partner_id.id), notification)
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/voip_number.py b/voip_sip_webrtc_twilio/models/voip_number.py
new file mode 100644
index 000000000..da8447192
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/voip_number.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+from openerp import api, fields, models
+import json
+
+import requests
+from openerp.http import request
+
+class VoipNumber(models.Model):
+
+ _name = "voip.number"
+
+ name = fields.Char(string="Name")
+ number = fields.Char(string="Number")
+ account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ capability_token_url = fields.Char(string="Capability Token URL")
+ twilio_app_id = fields.Char(string="Twilio App ID")
+ call_routing_ids = fields.Many2many('res.users', string="Called Users", help="The users that will get called when an incoming call is made to this number")
+
+ @api.model
+ def get_numbers(self, **kw):
+ """ Get the numbers that the user can receive calls from """
+
+ call_routes = []
+ for call_route in self.env.user.call_routing_ids:
+ call_routes.append({'capability_token_url': call_route.capability_token_url})
+
+ return call_routes
+
+ def create_twilio_app(self):
+
+ #Create the application for the number and point it back to the server
+ data = {'FriendlyName': 'Auto Setup Application for ' + str(self.name), 'VoiceUrl': request.httprequest.host_url + 'twilio/client-voice'}
+ response_string = requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.account_id.twilio_account_sid + "/Applications.json", data=data, auth=(str(self.account_id.twilio_account_sid), str(self.account_id.twilio_auth_token)))
+ response_string_json = json.loads(response_string.content.decode('utf-8'))
+
+ self.twilio_app_id = response_string_json['sid']
+
+ self.capability_token_url = request.httprequest.host_url + 'twilio/capability-token/' + str(self.id)
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/models/voip_twilio.py b/voip_sip_webrtc_twilio/models/voip_twilio.py
new file mode 100644
index 000000000..3dda592a7
--- /dev/null
+++ b/voip_sip_webrtc_twilio/models/voip_twilio.py
@@ -0,0 +1,426 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import json
+import requests
+from datetime import datetime
+import re
+from lxml import etree
+from dateutil import parser
+from openerp.http import request
+import base64
+
+from openerp import api, fields, models
+from openerp.exceptions import UserError
+
+class VoipTwilio(models.Model):
+
+ _name = "voip.twilio"
+ _description = "Twilio Account"
+
+ name = fields.Char(string="Name")
+ twilio_account_sid = fields.Char(string="Account SID")
+ twilio_auth_token = fields.Char(string="Auth Token")
+ twilio_last_check_date = fields.Datetime(string="Last Check Date")
+ resell_account = fields.Boolean(string="Resell Account")
+ margin = fields.Float(string="Margin", default="1.1", help="Multiply the call price by this figure 0.7 * 1.1 = 0.77")
+ partner_id = fields.Many2one('res.partner', string="Customer")
+ twilio_call_number = fields.Integer(string="Calls", compute="_compute_twilio_call_number")
+
+ @api.one
+ def _compute_twilio_call_number(self):
+ self.twilio_call_number = self.env['voip.call'].search_count([('twilio_account_id','=',self.id)])
+
+ @api.multi
+ def create_invoice(self):
+ self.ensure_one()
+
+ return {
+ 'name': 'Twilio Create Invoice',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'voip.twilio.invoice',
+ 'target': 'new',
+ 'type': 'ir.actions.act_window',
+ 'context': {'default_twilio_account_id': self.id, 'default_margin': self.margin}
+ }
+
+ @api.multi
+ def setup_numbers(self):
+ """Adds mobile numbers to the system"""
+ self.ensure_one()
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ if response_string.status_code == 200:
+ response_string_twilio_numbers = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ _logger.error(response_string_twilio_numbers.text.encode("utf-8"))
+
+ root = etree.fromstring(response_string_twilio_numbers.text.encode("utf-8"))
+ my_from_number_list = root.xpath('//IncomingPhoneNumber')
+ for my_from_number in my_from_number_list:
+ friendly_name = my_from_number.xpath('//FriendlyName')[0].text
+ twilio_number = my_from_number.xpath('//PhoneNumber')[0].text
+ sid = my_from_number.xpath('//Sid')[0].text
+
+ #Create a new mobile number
+ if self.env['voip.number'].search_count([('number','=',twilio_number)]) == 0:
+ voip_number = self.env['voip.number'].create({'name': friendly_name, 'number': twilio_number,'account_id':self.id})
+
+ #Create the application for the number and point it back to the server
+ data = {'FriendlyName': 'Auto Setup Application for ' + str(voip_number.name), 'VoiceUrl': request.httprequest.host_url + 'twilio/client-voice'}
+ response_string = requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Applications.json", data=data, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ response_string_json = json.loads(response_string.content.decode('utf-8'))
+
+ voip_number.twilio_app_id = response_string_json['sid']
+
+ voip_number.capability_token_url = request.httprequest.host_url.replace("http://","//") + 'twilio/capability-token/' + str(voip_number.id)
+
+ #Setup the Voice URL
+ payload = {'VoiceUrl': str(request.httprequest.host_url + "twilio/voice/route")}
+ requests.post("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/IncomingPhoneNumbers/" + sid, data=payload, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ return {
+ 'name': 'Twilio Numbers',
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'voip.number',
+ 'type': 'ir.actions.act_window'
+ }
+
+ else:
+ raise UserError("Bad Credentials")
+
+ @api.multi
+ def fetch_call_history(self):
+ self.ensure_one()
+
+ payload = {}
+ if self.twilio_last_check_date:
+ my_time = datetime.strptime(self.twilio_last_check_date,'%Y-%m-%d %H:%M:%S')
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json?StartTime%3E=" + my_time.strftime('%Y-%m-%d'), auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ else:
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+
+ #Loop through all pages until you have reached the last page
+ while True:
+
+ json_call_list = json.loads(response_string.text)
+
+ if 'calls' not in json_call_list:
+ raise UserError("No calls to import")
+
+ for call in json_call_list['calls']:
+
+ #Don't reimport the same record
+ if self.env['voip.call'].search([('twilio_sid','=',call['sid'])]):
+ continue
+
+ from_partner = False
+ from_address = call['from']
+ to_address = call['to']
+ to_partner = False
+ create_dict = {}
+
+ create_dict['twilio_account_id'] = self.id
+
+ if 'price' in call:
+ if call['price'] is not None:
+ if float(call['price']) != 0.0:
+ create_dict['currency_id'] = self.env['res.currency'].search([('name','=', call['price_unit'])])[0].id
+ create_dict['price'] = -1.0 * float(call['price'])
+
+ #Format the from address and find the from partner
+ if from_address is not None:
+ from_address = from_address.replace(";region=gll","")
+ from_address = from_address.replace(":5060","")
+ from_address = from_address.replace("sip:","")
+
+ if "+" in from_address:
+ #Mobiles should conform to E.164
+ from_partner = self.env['res.partner'].search([('mobile','=',from_address)])
+ else:
+ if "@" not in from_address and "@" in to_address:
+ #Get the full aor based on the domain of the to address
+ domain = re.findall(r'@(.*?)', to_address)[0].replace(":5060","")
+ from_address = from_address + "@" + domain
+
+ from_partner = self.env['res.partner'].search([('sip_address','=', from_address)])
+
+ if from_partner:
+ #Use the first found partner
+ create_dict['from_partner_id'] = from_partner[0].id
+ create_dict['from_address'] = from_address
+
+ #Format the to address and find the to partner
+ if to_address is not None:
+ to_address = to_address.replace(";region=gll","")
+ to_address = to_address.replace(":5060","")
+ to_address = to_address.replace("sip:","")
+
+ if "+" in to_address:
+ #Mobiles should conform to E.164
+ to_partner = self.env['res.partner'].search([('mobile','=',to_address)])
+ else:
+
+ if "@" not in to_address and "@" in from_address:
+ #Get the full aor based on the domain of the from address
+ domain = re.findall(r'@(.*?)', from_address)[0].replace(":5060","")
+ to_address = to_address + "@" + domain
+
+ to_partner = self.env['res.partner'].search([('sip_address','=', to_address)])
+
+ if to_partner:
+ #Use the first found partner
+ create_dict['to_partner_id'] = to_partner[0].id
+ create_dict['to_address'] = to_address
+
+ #Have to map the Twilio call status to the one in the core module
+ twilio_status = call['status']
+ if twilio_status == "queued":
+ create_dict['status'] = "pending"
+ elif twilio_status == "ringing":
+ create_dict['status'] = "pending"
+ elif twilio_status == "in-progress":
+ create_dict['status'] = "active"
+ elif twilio_status == "canceled":
+ create_dict['status'] = "cancelled"
+ elif twilio_status == "completed":
+ create_dict['status'] = "over"
+ elif twilio_status == "failed":
+ create_dict['status'] = "failed"
+ elif twilio_status == "busy":
+ create_dict['status'] = "busy"
+ elif twilio_status == "no-answer":
+ create_dict['status'] = "failed"
+
+ create_dict['start_time'] = call['start_time']
+ create_dict['end_time'] = call['end_time']
+
+ create_dict['twilio_sid'] = call['sid']
+ #Duration includes the ring time
+ create_dict['duration'] = call['duration']
+
+ #Fetch the recording if it exists
+ #if 'subresource_uris' in call:
+ # if 'recordings' in call['subresource_uris']:
+ # if call['subresource_uris']['recordings'] != '':
+ # recording_response = requests.get("https://api.twilio.com" + call['subresource_uris']['recordings'], auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ # recording_json = json.loads(recording_response.text)
+ # for recording in recording_json['recordings']:
+ # create_dict['twilio_call_recording_uri'] = "https://api.twilio.com" + recording['uri'].replace(".json",".mp3")
+
+ self.env['voip.call'].create(create_dict)
+
+ # Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ _logger.error(next_page_uri)
+ if next_page_uri is not None:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, data=payload, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ else:
+ break;
+
+ #After finish looping all paage then set the last check date so we only get new messages next time
+ self.twilio_last_check_date = datetime.utcnow()
+
+ return {
+ 'name': 'Twilio Call History',
+ 'view_type': 'form',
+ 'view_mode': 'tree,form',
+ 'res_model': 'voip.call',
+ 'context': {'search_default_twilio_account_id': self.id},
+ 'type': 'ir.actions.act_window',
+ }
+
+ @api.multi
+ def generate_invoice_previous_month(self):
+ self.ensure_one()
+
+ if self.partner_id.id == False:
+ raise UserError("Please select a contact before creating the invoice")
+ return False
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Usage/Records/LastMonth.json", auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_usage_list = json.loads(response_string.text)
+
+ invoice = self.env['account.invoice'].create({
+ 'partner_id': self.partner_id.id,
+ 'account_id': self.partner_id.property_account_receivable_id.id,
+ 'fiscal_position_id': self.partner_id.property_account_position_id.id,
+ })
+
+ _logger.error( response_string.text )
+ start_date_string = json_usage_list['usage_records'][0]['start_date']
+ end_date_string = json_usage_list['usage_records'][0]['end_date']
+ invoice.comment = "Twilio Bill " + start_date_string + " - " + end_date_string
+
+ while True:
+
+ for usage_record in json_usage_list['usage_records']:
+ category = usage_record['category']
+
+ #Exclude the umbrella categories otherwise the pricing will overlap
+ if float(usage_record['price']) > 0 and category != "calls" and category != "sms" and category != "phonenumbers" and category != "recordings" and category != "transcriptions" and category != "trunking-origination" and category != "totalprice":
+
+ line_values = {
+ 'name': usage_record['description'],
+ 'price_unit': float(usage_record['price']) * self.margin,
+ 'invoice_id': invoice.id,
+ 'account_id': invoice.journal_id.default_credit_account_id.id
+ }
+
+ invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
+
+ invoice.compute_taxes()
+
+ #For debugging
+ if category == "totalprice":
+ invoice.comment = invoice.comment + " (Total $" + usage_record['price'] + " USD) (Marign: $" + str(float(usage_record['price']) * self.margin) + " USD)"
+ _logger.error(usage_record['price'])
+
+ #Get the next page if there is one
+ next_page_uri = json_usage_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_usage_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ #Also generate a call log report
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_sid + "/Calls.json?StartTime%3E=" + start_date_string + "&EndTime%3C=" + end_date_string, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+
+ #Loop through all pages until you have reached the end
+ call_total = 0
+ while True:
+
+ for call in json_call_list['calls']:
+ if call['price']:
+ if float(call['price']) != 0:
+ #Format the date depending on the language of the contact
+ call_start = parser.parse(call['start_time'])
+ call_cost = -1.0 * float(call['price']) * self.margin
+ call_total += call_cost
+
+ m, s = divmod( int(call['duration']) , 60)
+ h, m = divmod(m, 60)
+ self.env['account.invoice.voip.history'].create({'invoice_id': invoice.id, 'start_time': call_start, 'duration': "%d:%02d:%02d" % (h, m, s), 'cost': call_cost, 'to_address': call['to'] })
+
+ #Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_sid), str(self.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ return {
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice',
+ 'type': 'ir.actions.act_window',
+ 'res_id': invoice.id,
+ 'view_id': self.env['ir.model.data'].get_object('account', 'invoice_form').id
+ }
+
+class VoipTwilioInvoice(models.Model):
+
+ _name = "voip.twilio.invoice"
+ _description = "Twilio Account Invoice"
+
+ twilio_account_id = fields.Many2one('voip.twilio', string="Twilio Account")
+ start_date = fields.Date(string="Start Date")
+ end_date = fields.Date(string="End Date")
+ margin = fields.Float(string="Margin")
+ generate_call_report = fields.Boolean(string="Generate Call Report", default=True)
+
+ @api.multi
+ def generate_invoice(self):
+ self.ensure_one()
+
+ response_string = requests.get("https://api.twilio.com/2010-04-01/Accounts/" + self.twilio_account_id.twilio_account_sid + "/Calls.json?StartTime%3E=" + self.start_date + "&EndTime%3C=" + self.end_date, auth=(str(self.twilio_account_id.twilio_account_sid), str(self.twilio_account_id.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+
+ invoice = self.env['account.invoice'].create({
+ 'partner_id': self.twilio_account_id.partner_id.id,
+ 'account_id': self.twilio_account_id.partner_id.property_account_receivable_id.id,
+ 'fiscal_position_id': self.twilio_account_id.partner_id.property_account_position_id.id,
+ })
+
+ #Loop through all pages until you have reached the end
+ call_total = 0
+ duration_total = 0
+ while True:
+
+ for call in json_call_list['calls']:
+ if call['price']:
+ if float(call['price']) != 0:
+ #Format the date depending on the language of the contact
+ call_start = parser.parse(call['start_time'])
+ call_cost = -1.0 * float(call['price']) * self.margin
+ call_total += call_cost
+ duration_total += float(call['duration'])
+
+ if self.generate_call_report:
+ m, s = divmod( int(call['duration']) , 60)
+ h, m = divmod(m, 60)
+ self.env['account.invoice.voip.history'].create({'invoice_id': invoice.id, 'start_time': call_start, 'duration': "%d:%02d:%02d" % (h, m, s), 'cost': call_cost, 'to_address': call['to'] })
+
+ #Get the next page if there is one
+ next_page_uri = json_call_list['next_page_uri']
+ if next_page_uri:
+ response_string = requests.get("https://api.twilio.com" + next_page_uri, auth=(str(self.twilio_account_id.twilio_account_sid), str(self.twilio_account_id.twilio_auth_token)))
+ json_call_list = json.loads(response_string.text)
+ else:
+ #End the loop if there are is no more pages
+ break
+
+ #Put Total call time and cost into invoice for the report or pdf attachment
+ invoice.voip_total_call_cost = round(call_total,2)
+ m, s = divmod( int(duration_total) , 60)
+ h, m = divmod(m, 60)
+ invoice.voip_total_call_time = "%d:%02d:%02d" % (h, m, s)
+ invoice.twilio_invoice = True
+
+ line_values = {
+ 'name': "VOIP Calls " + self.start_date + " - " + self.end_date,
+ 'price_unit': call_total,
+ 'invoice_id': invoice.id,
+ 'account_id': invoice.journal_id.default_credit_account_id.id
+ }
+
+ invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
+
+ invoice.compute_taxes()
+
+ return {
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice',
+ 'type': 'ir.actions.act_window',
+ 'res_id': invoice.id,
+ 'view_id': self.env['ir.model.data'].get_object('account', 'invoice_form').id
+ }
+
+class AccountInvoiceVoip(models.Model):
+
+ _inherit = "account.invoice"
+
+ voip_history_ids = fields.One2many('account.invoice.voip.history', 'invoice_id', string="VOIP Call History")
+ twilio_invoice = fields.Boolean(string="Is Twilio Invoice", help="Allows Twilio specific changes to invoice template")
+ voip_total_call_time = fields.Char(string="Voip Total Call Time")
+ voip_total_call_cost = fields.Float(string="Voip Total Call Cost")
+
+class AccountInvoiceVoipHistory(models.Model):
+
+ _name = "account.invoice.voip.history"
+ _description = "Twilio Account Invoice History"
+
+ invoice_id = fields.Many2one('account.invoice', string="Invoice")
+ start_time = fields.Datetime(string="Start Time")
+ duration = fields.Char(string="Duration")
+ to_address = fields.Char(string="To Address")
+ cost = fields.Float(string="Cost")
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/security/ir.model.access.csv b/voip_sip_webrtc_twilio/security/ir.model.access.csv
new file mode 100644
index 000000000..f27512e77
--- /dev/null
+++ b/voip_sip_webrtc_twilio/security/ir.model.access.csv
@@ -0,0 +1,6 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_voip_call_wizard","access voip.call.wizard","model_voip_call_wizard","base.group_user",1,1,1,0
+"access_voip_number","access voip.number","model_voip_number","base.group_user",1,1,1,1
+"access_voip_twilio","access voip.twilio","model_voip_twilio","base.group_user",1,1,1,1
+"access_voip_twilio_invoice","access voip.twilio.invoice","model_voip_twilio_invoice","base.group_user",1,1,1,1
+"access_account_invoice_voip_history","access account.invoice.voip.history","model_account_invoice_voip_history","base.group_user",1,1,1,1
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/static/description/1.jpg b/voip_sip_webrtc_twilio/static/description/1.jpg
new file mode 100644
index 000000000..485194bb6
Binary files /dev/null and b/voip_sip_webrtc_twilio/static/description/1.jpg differ
diff --git a/voip_sip_webrtc_twilio/static/description/2.jpg b/voip_sip_webrtc_twilio/static/description/2.jpg
new file mode 100644
index 000000000..639dc93d3
Binary files /dev/null and b/voip_sip_webrtc_twilio/static/description/2.jpg differ
diff --git a/voip_sip_webrtc_twilio/static/description/3.jpg b/voip_sip_webrtc_twilio/static/description/3.jpg
new file mode 100644
index 000000000..bd4f8fa04
Binary files /dev/null and b/voip_sip_webrtc_twilio/static/description/3.jpg differ
diff --git a/voip_sip_webrtc_twilio/static/description/icon.png b/voip_sip_webrtc_twilio/static/description/icon.png
new file mode 100644
index 000000000..eb680ef42
Binary files /dev/null and b/voip_sip_webrtc_twilio/static/description/icon.png differ
diff --git a/voip_sip_webrtc_twilio/static/description/index.html b/voip_sip_webrtc_twilio/static/description/index.html
new file mode 100644
index 000000000..8ac5a0e93
--- /dev/null
+++ b/voip_sip_webrtc_twilio/static/description/index.html
@@ -0,0 +1,38 @@
+
+
Description
+Provides real time call functionality as well Twilio XML for automated calls
+
+
Import Call Log
+
+Import log of calls made using external SIP clients
+Access the import button under CRM->VOIP->Twilio Accounts
+
+
Call mobiles from your browser
+
+Manually call mobile phones and talk in real time.
+
Setup
+1. Configure your environment so Twilio can access your database from the public internet
+2. Enter Twilio account details under CRM->Voip->Twilio Accounts
+3. Click "Setup Numbers" button
+4. Go to any contact and select Twilio Call Mobile from the action menu at the top
+
+If your database can not be directly accessed by Twilio you can create a Twilio app for handling capability token generation by following this guide
+https://www.twilio.com/docs/voice/client/javascript/quickstart
+
+
Answer calls from your browser
+
+Go to CRM->Voip->Twilio Accounts and hit the setup numbers button this will automatically point the number to your server.
+Then go into CRM->Voip->Stored Number and assign users to a number, when a call is made they can answer it within there browser.
+IMPORTANT In order for this feature to work you will need your database to have a publicly accessable url.
+
+
Serve Twilio XML for SIP Calls
+
+Serves out Twilio XML which directs calls to the callee
+
Setup
+1. Add "[domain]/twilio/voice" under the request URL
+2. Setup your Twilio SIP account
+
+Known Issues
+Twilio Apps points to http url on Odoo systems behind a reverse proxy
+Does not work in Google Chrome at all
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/static/src/js/quickstart.js b/voip_sip_webrtc_twilio/static/src/js/quickstart.js
new file mode 100644
index 000000000..83655d86e
--- /dev/null
+++ b/voip_sip_webrtc_twilio/static/src/js/quickstart.js
@@ -0,0 +1,146 @@
+$(function () {
+ var speakerDevices = document.getElementById('speaker-devices');
+ var ringtoneDevices = document.getElementById('ringtone-devices');
+ var outputVolumeBar = document.getElementById('output-volume');
+ var inputVolumeBar = document.getElementById('input-volume');
+ var volumeIndicators = document.getElementById('volume-indicators');
+
+ console.log('Requesting Capability Token...');
+ $.getJSON('https://cute-land-1506.twil.io/capability-token')
+ .done(function (data) {
+ console.log('Got a token.');
+ console.log('Token: ' + data.token);
+
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+
+ Twilio.Device.ready(function (device) {
+ console.log('Twilio.Device Ready!');
+ //document.getElementById('call-controls').style.display = 'block';
+ });
+
+
+
+
+
+
+
+
+
+ setClientNameUI(data.identity);
+
+ Twilio.Device.audio.on('deviceChange', updateAllDevices);
+
+ // Show audio selection UI if it is supported by the browser.
+ if (Twilio.Device.audio.isSelectionSupported) {
+ document.getElementById('output-selection').style.display = 'block';
+ }
+ })
+ .fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ // Bind button to make call
+ button_call = document.getElementById('button-call');
+ if (typeof(button_call) != 'undefined' && button_call != null)
+ {
+ button_call.onclick = function () {
+ // get the phone number to connect the call to
+ var params = {
+ To: document.getElementById('phone-number').value
+ };
+
+ console.log('Calling ' + params.To + '...');
+ Twilio.Device.connect(params);
+ };
+ }
+
+
+
+
+ get_devices = document.getElementById('get-devices');
+ if (typeof(get_devices) != 'undefined' && get_devices != null)
+ {
+ get_devices.onclick = function() {
+ navigator.mediaDevices.getUserMedia({ audio: true })
+ .then(updateAllDevices);
+ };
+ }
+
+/*
+ speakerDevices.addEventListener('change', function() {
+ var selectedDevices = [].slice.call(speakerDevices.children)
+ .filter(function(node) { return node.selected; })
+ .map(function(node) { return node.getAttribute('data-id'); });
+
+ Twilio.Device.audio.speakerDevices.set(selectedDevices);
+ });
+*/
+
+/*
+ ringtoneDevices.addEventListener('change', function() {
+ var selectedDevices = [].slice.call(ringtoneDevices.children)
+ .filter(function(node) { return node.selected; })
+ .map(function(node) { return node.getAttribute('data-id'); });
+
+ Twilio.Device.audio.ringtoneDevices.set(selectedDevices);
+ });
+*/
+
+ function bindVolumeIndicators(connection) {
+ connection.volume(function(inputVolume, outputVolume) {
+ var inputColor = 'red';
+ if (inputVolume < .50) {
+ inputColor = 'green';
+ } else if (inputVolume < .75) {
+ inputColor = 'yellow';
+ }
+
+ //inputVolumeBar.style.width = Math.floor(inputVolume * 300) + 'px';
+ //inputVolumeBar.style.background = inputColor;
+
+ var outputColor = 'red';
+ if (outputVolume < .50) {
+ outputColor = 'green';
+ } else if (outputVolume < .75) {
+ outputColor = 'yellow';
+ }
+
+ //outputVolumeBar.style.width = Math.floor(outputVolume * 300) + 'px';
+ //outputVolumeBar.style.background = outputColor;
+ });
+ }
+
+ function updateAllDevices() {
+ console.log("Update Devices");
+ //updateDevices(speakerDevices, Twilio.Device.audio.speakerDevices.get());
+ //updateDevices(ringtoneDevices, Twilio.Device.audio.ringtoneDevices.get());
+ }
+});
+
+// Update the available ringtone and speaker devices
+function updateDevices(selectEl, selectedDevices) {
+ selectEl.innerHTML = '';
+ Twilio.Device.audio.availableOutputDevices.forEach(function(device, id) {
+ var isActive = (selectedDevices.size === 0 && id === 'default');
+ selectedDevices.forEach(function(device) {
+ if (device.deviceId === id) { isActive = true; }
+ });
+
+ var option = document.createElement('option');
+ option.label = device.label;
+ option.setAttribute('data-id', id);
+ if (isActive) {
+ option.setAttribute('selected', 'selected');
+ }
+ selectEl.appendChild(option);
+ });
+}
+
+
+// Set the client name in the UI
+function setClientNameUI(clientName) {
+ console.log("Set Client Name: " + clientName);
+ //var div = document.getElementById('client-name');
+ //div.innerHTML = 'Your client name: ' + clientName + '';
+}
diff --git a/voip_sip_webrtc_twilio/static/src/js/twilio.min.js b/voip_sip_webrtc_twilio/static/src/js/twilio.min.js
new file mode 100644
index 000000000..9b79e0aad
--- /dev/null
+++ b/voip_sip_webrtc_twilio/static/src/js/twilio.min.js
@@ -0,0 +1,55 @@
+/*! twilio-client.js 1.4.32
+
+The following license applies to all parts of this software except as
+documented below.
+
+ Copyright 2015 Twilio, inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+This software includes rtcpeerconnection-shim under the following (BSD 3-Clause) license.
+
+ Copyright (c) 2017 Philipp Hancke. All rights reserved.
+
+ Copyright (c) 2014, The WebRTC project authors. All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+
+ * Neither the name of Philipp Hancke nor the names of its contributors may
+ be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+ */
+(function(root){var bundle=function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i=METRICS_BATCH_SIZE){publishMetrics()}});function formatPayloadForEA(warningData){var payloadData={threshold:warningData.threshold.value};if(warningData.values){payloadData.values=warningData.values.map(function(value){if(typeof value==="number"){return Math.round(value*100)/100}return value})}else if(warningData.value){payloadData.value=warningData.value}return{data:payloadData}}function reemitWarning(wasCleared,warningData){var groupPrefix=/^audio/.test(warningData.name)?"audio-level-":"network-quality-";var groupSuffix=wasCleared?"-cleared":"-raised";var groupName=groupPrefix+"warning"+groupSuffix;var warningPrefix=WARNING_PREFIXES[warningData.threshold.name];var warningName=warningPrefix+WARNING_NAMES[warningData.name];if(warningName==="constant-audio-input-level"&&self.isMuted()){return}var level=wasCleared?"info":"warning";if(warningName==="constant-audio-output-level"){level="info"}publisher.post(level,groupName,warningName,formatPayloadForEA(warningData),self);if(warningName!=="constant-audio-output-level"){var emitName=wasCleared?"warning-cleared":"warning";self.emit(emitName,warningName)}}monitor.on("warning-cleared",reemitWarning.bind(null,true));monitor.on("warning",reemitWarning.bind(null,false));this.mediaStream=new this.options.MediaStream(device,getUserMedia);this.on("volume",function(inputVolume,outputVolume){self._latestInputVolume=inputVolume;self._latestOutputVolume=outputVolume});this.mediaStream.onvolume=this.emit.bind(this,"volume");this.mediaStream.oniceconnectionstatechange=function(state){var level=state==="failed"?"error":"debug";publisher.post(level,"ice-connection-state",state,null,self)};this.mediaStream.onicegatheringstatechange=function(state){publisher.debug("signaling-state",state,null,self)};this.mediaStream.onsignalingstatechange=function(state){publisher.debug("signaling-state",state,null,self)};this.mediaStream.ondisconnect=function(msg){self.log(msg);publisher.warn("network-quality-warning-raised","ice-connectivity-lost",{message:msg},self);self.emit("warning","ice-connectivity-lost")};this.mediaStream.onreconnect=function(msg){self.log(msg);publisher.info("network-quality-warning-cleared","ice-connectivity-lost",{message:msg},self);self.emit("warning-cleared","ice-connectivity-lost")};this.mediaStream.onerror=function(e){if(e.disconnect===true){self._disconnect(e.info&&e.info.message)}var error={code:e.info.code,message:e.info.message||"Error with mediastream",info:e.info,connection:self};self.log("Received an error from MediaStream:",e);self.emit("error",error)};this.mediaStream.onopen=function(){if(self._status==="open"){return}else if(self._status==="ringing"||self._status==="connecting"){self.mute(false);self._maybeTransitionToOpen()}else{self.mediaStream.close()}};this.mediaStream.onclose=function(){self._status="closed";if(device.sounds.__dict__.disconnect){device.soundcache.get("disconnect").play()}monitor.disable();publishMetrics();self.emit("disconnect",self)};this.outboundConnectionId=twutil.generateConnectionUUID();this.pstream=device.stream;this._onCancel=function(payload){var callsid=payload.callsid;if(self.parameters.CallSid===callsid){self._status="closed";self.emit("cancel");self.pstream.removeListener("cancel",self._onCancel)}};if(this.pstream){this.pstream.on("cancel",this._onCancel);this.pstream.on("ringing",this._onRinging)}this.on("error",function(error){publisher.error("connection","error",{code:error.code,message:error.message},self);if(self.pstream&&self.pstream.status==="disconnected"){cleanupEventListeners(self)}});this.on("disconnect",function(){cleanupEventListeners(self)});return this}util.inherits(Connection,EventEmitter);Connection.toString=function(){return"[Twilio.Connection class]"};Connection.prototype.toString=function(){return"[Twilio.Connection instance]"};Connection.prototype.sendDigits=function(digits){if(digits.match(/[^0-9*#w]/)){throw new Exception("Illegal character passed into sendDigits")}var sequence=[];digits.split("").forEach(function(digit){var dtmf=digit!=="w"?"dtmf"+digit:"";if(dtmf==="dtmf*")dtmf="dtmfs";if(dtmf==="dtmf#")dtmf="dtmfh";sequence.push(dtmf)});(function playNextDigit(soundCache){var digit=sequence.shift();soundCache.get(digit).play();if(sequence.length){setTimeout(playNextDigit.bind(null,soundCache),200)}})(this._soundcache);var dtmfSender=this.mediaStream.getOrCreateDTMFSender();function insertDTMF(dtmfs){if(!dtmfs.length){return}var dtmf=dtmfs.shift();if(dtmf.length){dtmfSender.insertDTMF(dtmf,DTMF_TONE_DURATION,DTMF_INTER_TONE_GAP)}setTimeout(insertDTMF.bind(null,dtmfs),DTMF_PAUSE_DURATION)}if(dtmfSender){if(!("canInsertDTMF"in dtmfSender)||dtmfSender.canInsertDTMF){this.log("Sending digits using RTCDTMFSender");insertDTMF(digits.split("w"));return}this.log("RTCDTMFSender cannot insert DTMF")}this.log("Sending digits over PStream");var payload;if(this.pstream!==null&&this.pstream.status!=="disconnected"){payload={dtmf:digits,callsid:this.parameters.CallSid};this.pstream.publish("dtmf",payload)}else{payload={error:{}};var error={code:payload.error.code||31e3,message:payload.error.message||"Could not send DTMF: Signaling channel is disconnected",connection:this};this.emit("error",error)}};Connection.prototype.status=function(){return this._status};Connection.prototype.mute=function(shouldMute){if(typeof shouldMute==="undefined"){shouldMute=true;this.log.deprecated(".mute() is deprecated. Please use .mute(true) or .mute(false) "+"to mute or unmute a call instead.")}else if(typeof shouldMute==="function"){this.addListener("mute",shouldMute);return}if(this.isMuted()===shouldMute){return}this.mediaStream.mute(shouldMute);var isMuted=this.isMuted();this._publisher.info("connection",isMuted?"muted":"unmuted",null,this);this.emit("mute",isMuted,this)};Connection.prototype.isMuted=function(){return this.mediaStream.isMuted};Connection.prototype.unmute=function(){this.log.deprecated(".unmute() is deprecated. Please use .mute(false) to unmute a call instead.");this.mute(false)};Connection.prototype.accept=function(handler){if(typeof handler==="function"){this.addListener("accept",handler);return}if(this._status!=="pending"){return}var audioConstraints=handler||this.options.audioConstraints;var self=this;this._status="connecting";function connect_(){if(self._status!=="connecting"){cleanupEventListeners(self);self.mediaStream.close();return}var pairs=[];for(var key in self.message){pairs.push(encodeURIComponent(key)+"="+encodeURIComponent(self.message[key]))}function onLocalAnswer(pc){self._publisher.info("connection","accepted-by-local",null,self);self._monitor.enable(pc)}function onRemoteAnswer(pc){self._publisher.info("connection","accepted-by-remote",null,self);self._monitor.enable(pc)}var sinkIds=typeof self.options.getSinkIds==="function"&&self.options.getSinkIds();if(Array.isArray(sinkIds)){self.mediaStream._setSinkIds(sinkIds).catch(function(){})}var params=pairs.join("&");if(self._direction==="INCOMING"){self._isAnswered=true;self.mediaStream.answerIncomingCall(self.parameters.CallSid,self.options.offerSdp,self.options.rtcConstraints,self.options.iceServers,onLocalAnswer)}else{self.pstream.once("answer",self._onAnswer.bind(self));self.mediaStream.makeOutgoingCall(self.pstream.token,params,self.outboundConnectionId,self.options.rtcConstraints,self.options.iceServers,onRemoteAnswer)}self._onHangup=function(payload){if(payload.callsid&&(self.parameters.CallSid||self.outboundConnectionId)){if(payload.callsid!==self.parameters.CallSid&&payload.callsid!==self.outboundConnectionId){return}}else if(payload.callsid){return}self.log("Received HANGUP from gateway");if(payload.error){var error={code:payload.error.code||31e3,message:payload.error.message||"Error sent from gateway in HANGUP",connection:self};self.log("Received an error from the gateway:",error);self.emit("error",error)}self.sendHangup=false;self._publisher.info("connection","disconnected-by-remote",null,self);self._disconnect(null,true);cleanupEventListeners(self)};self.pstream.addListener("hangup",self._onHangup)}var inputStream=typeof this.options.getInputStream==="function"&&this.options.getInputStream();var promise=inputStream?this.mediaStream.setInputTracksFromStream(inputStream):this.mediaStream.openWithConstraints(audioConstraints);promise.then(function(){self._publisher.info("get-user-media","succeeded",{data:{audioConstraints:audioConstraints}},self);connect_()},function(error){var message;var code;if(error.code&&error.code===error.PERMISSION_DENIED||error.name&&error.name==="PermissionDeniedError"){code=31208;message="User denied access to microphone, or the web browser did not allow microphone "+"access at this address.";self._publisher.error("get-user-media","denied",{data:{audioConstraints:audioConstraints,error:error}},self)}else{code=31201;message="Error occurred while accessing microphone: "+error.name+(error.message?" ("+error.message+")":"");self._publisher.error("get-user-media","failed",{data:{audioConstraints:audioConstraints,error:error}},self)}return self._die(message,code)})};Connection.prototype.reject=function(handler){if(typeof handler==="function"){this.addListener("reject",handler);return}if(this._status!=="pending"){return}var payload={callsid:this.parameters.CallSid};this.pstream.publish("reject",payload);this.emit("reject");this.mediaStream.reject(this.parameters.CallSid);this._publisher.info("connection","rejected-by-local",null,this)};Connection.prototype.ignore=function(handler){if(typeof handler==="function"){this.addListener("cancel",handler);return}if(this._status!=="pending"){return}this._status="closed";this.emit("cancel");this.mediaStream.ignore(this.parameters.CallSid);this._publisher.info("connection","ignored-by-local",null,this)};Connection.prototype.cancel=function(handler){this.log.deprecated(".cancel() is deprecated. Please use .ignore() instead.");this.ignore(handler)};Connection.prototype.disconnect=function(handler){if(typeof handler==="function"){this.addListener("disconnect",handler);return}this._disconnect()};Connection.prototype._disconnect=function(message,remote){message=typeof message==="string"?message:null;if(this._status!=="open"&&this._status!=="connecting"&&this._status!=="ringing"){return}this.log("Disconnecting...");if(this.pstream!==null&&this.pstream.status!=="disconnected"&&this.sendHangup){var callId=this.parameters.CallSid||this.outboundConnectionId;if(callId){var payload={callsid:callId};if(message){payload.message=message}this.pstream.publish("hangup",payload)}}cleanupEventListeners(this);this.mediaStream.close();if(!remote){this._publisher.info("connection","disconnected-by-local",null,this)}};Connection.prototype.error=function(handler){if(typeof handler==="function"){this.addListener("error",handler);return}};Connection.prototype._die=function(message,code){this._disconnect();this.emit("error",{message:message,code:code})};Connection.prototype._setCallSid=function _setCallSid(payload){var callSid=payload.callsid;if(!callSid){return}this.parameters.CallSid=callSid;this.mediaStream.callSid=callSid};Connection.prototype._setSinkIds=function _setSinkIds(sinkIds){return this.mediaStream._setSinkIds(sinkIds)};Connection.prototype._setInputTracksFromStream=function _setInputTracksFromStream(stream){return this.mediaStream.setInputTracksFromStream(stream)};Connection.prototype._onRinging=function(payload){this._setCallSid(payload);if(this._status!=="connecting"&&this._status!=="ringing"){return}var hasEarlyMedia=!!payload.sdp;if(this.options.enableRingingState){this._status="ringing";this._publisher.info("connection","outgoing-ringing",{hasEarlyMedia:hasEarlyMedia},this);this.emit("ringing",hasEarlyMedia)}else if(hasEarlyMedia){this._onAnswer(payload)}};Connection.prototype._onAnswer=function(payload){if(this._isAnswered){return}this._setCallSid(payload);this._isAnswered=true;this._maybeTransitionToOpen()};Connection.prototype._maybeTransitionToOpen=function(){if(this.mediaStream&&this.mediaStream.status==="open"&&this._isAnswered){this._status="open";this.emit("accept",this)}};Connection.prototype.volume=function(handler){if(!window||!window.AudioContext&&!window.webkitAudioContext){console.warn("This browser does not support Connection.volume")}else if(typeof handler==="function"){this.on("volume",handler)}};Connection.prototype.getLocalStream=function getLocalStream(){return this.mediaStream&&this.mediaStream.stream};Connection.prototype.getRemoteStream=function getRemoteStream(){return this.mediaStream&&this.mediaStream._remoteStream};Connection.prototype.postFeedback=function(score,issue){if(typeof score==="undefined"||score===null){return this._postFeedbackDeclined()}if(FEEDBACK_SCORES.indexOf(score)===-1){throw new Error("Feedback score must be one of: "+FEEDBACK_SCORES)}if(typeof issue!=="undefined"&&issue!==null&&FEEDBACK_ISSUES.indexOf(issue)===-1){throw new Error("Feedback issue must be one of: "+FEEDBACK_ISSUES)}return this._publisher.post("info","feedback","received",{quality_score:score,issue_name:issue},this,true)};Connection.prototype._postFeedbackDeclined=function(){return this._publisher.post("info","feedback","received-none",null,this,true)};Connection.prototype._getTempCallSid=function(){return this.outboundConnectionId};Connection.prototype._getRealCallSid=function(){return/^TJ/.test(this.parameters.CallSid)?null:this.parameters.CallSid};function cleanupEventListeners(connection){function cleanup(){if(!connection.pstream){return}connection.pstream.removeListener("answer",connection._onAnswer);connection.pstream.removeListener("cancel",connection._onCancel);connection.pstream.removeListener("hangup",connection._onHangup);connection.pstream.removeListener("ringing",connection._onRinging)}cleanup();setTimeout(cleanup,0)}exports.Connection=Connection},{"./log":8,"./rtc":14,"./rtc/monitor":16,"./util":28,events:43,util:55}],6:[function(require,module,exports){var AudioHelper=require("./audiohelper");var EventEmitter=require("events").EventEmitter;var util=require("util");var log=require("./log");var twutil=require("./util");var rtc=require("./rtc");var Publisher=require("./eventpublisher");var Options=require("./options").Options;var Sound=require("./sound");var Connection=require("./connection").Connection;var getUserMedia=require("./rtc/getusermedia");var PStream=require("./pstream").PStream;var REG_INTERVAL=3e4;var RINGTONE_PLAY_TIMEOUT=2e3;function Device(token,options){if(!Device.isSupported){throw new twutil.Exception("twilio.js 1.3+ SDKs require WebRTC/ORTC browser support. "+"For more information, see . "+"If you have any questions about this announcement, please contact "+"Twilio Support at .")}if(!(this instanceof Device)){return new Device(token,options)}twutil.monitorEventEmitter("Twilio.Device",this);if(!token){throw new twutil.Exception("Capability token is not valid or missing.")}options=options||{};var origOptions={};for(var i in options){origOptions[i]=options[i]}var DefaultSound=options.soundFactory||Sound;var defaults={logPrefix:"[Device]",eventgw:"eventgw.twilio.com",Sound:DefaultSound,connectionFactory:Connection,pStreamFactory:PStream,noRegister:false,closeProtection:false,secureSignaling:true,warnings:true,audioConstraints:true,iceServers:[],region:"gll",dscp:true,sounds:{}};options=options||{};for(var prop in defaults){if(prop in options)continue;options[prop]=defaults[prop]}if(options.dscp){options.rtcConstraints={optional:[{googDscp:true}]}}else{options.rtcConstraints={}}this.options=options;this.token=token;this._status="offline";this._region="offline";this._connectionSinkIds=["default"];this._connectionInputStream=null;this.connections=[];this._activeConnection=null;this.sounds=new Options({incoming:true,outgoing:true,disconnect:true});log.mixinLog(this,this.options.logPrefix);this.log.enabled=this.options.debug;var regions={gll:"chunderw-vpc-gll.twilio.com",au1:"chunderw-vpc-gll-au1.twilio.com",br1:"chunderw-vpc-gll-br1.twilio.com",de1:"chunderw-vpc-gll-de1.twilio.com",ie1:"chunderw-vpc-gll-ie1.twilio.com",jp1:"chunderw-vpc-gll-jp1.twilio.com",sg1:"chunderw-vpc-gll-sg1.twilio.com",us1:"chunderw-vpc-gll-us1.twilio.com","us1-tnx":"chunderw-vpc-gll-us1-tnx.twilio.com","us2-tnx":"chunderw-vpc-gll-us2-tnx.twilio.com","ie1-tnx":"chunderw-vpc-gll-ie1-tnx.twilio.com","us1-ix":"chunderw-vpc-gll-us1-ix.twilio.com","us2-ix":"chunderw-vpc-gll-us2-ix.twilio.com","ie1-ix":"chunderw-vpc-gll-ie1-ix.twilio.com"};var deprecatedRegions={au:"au1",br:"br1",ie:"ie1",jp:"jp1",sg:"sg1","us-va":"us1","us-or":"us1"};var region=options.region.toLowerCase();if(region in deprecatedRegions){this.log.deprecated("Region "+region+" is deprecated, please use "+deprecatedRegions[region]+".");region=deprecatedRegions[region]}if(!(region in regions)){throw new twutil.Exception("Region "+options.region+" is invalid. Valid values are: "+Object.keys(regions).join(", "))}options.chunderw="wss://"+(options.chunderw||regions[region||"gll"])+"/signal";this.soundcache=new Map;var a=typeof document!=="undefined"?document.createElement("audio"):{};var canPlayMp3;try{canPlayMp3=a.canPlayType&&!!a.canPlayType("audio/mpeg").replace(/no/,"")}catch(e){canPlayMp3=false}var canPlayVorbis;try{canPlayVorbis=a.canPlayType&&!!a.canPlayType("audio/ogg;codecs='vorbis'").replace(/no/,"")}catch(e){canPlayVorbis=false}var ext="mp3";if(canPlayVorbis&&!canPlayMp3){ext="ogg"}var defaultSounds={incoming:{filename:"incoming",shouldLoop:true},outgoing:{filename:"outgoing",maxDuration:3e3},disconnect:{filename:"disconnect",maxDuration:3e3},dtmf1:{filename:"dtmf-1",maxDuration:1e3},dtmf2:{filename:"dtmf-2",maxDuration:1e3},dtmf3:{filename:"dtmf-3",maxDuration:1e3},dtmf4:{filename:"dtmf-4",maxDuration:1e3},dtmf5:{filename:"dtmf-5",maxDuration:1e3},dtmf6:{filename:"dtmf-6",maxDuration:1e3},dtmf7:{filename:"dtmf-7",maxDuration:1e3},dtmf8:{filename:"dtmf-8",maxDuration:1e3},dtmf9:{filename:"dtmf-9",maxDuration:1e3},dtmf0:{filename:"dtmf-0",maxDuration:1e3},dtmfs:{filename:"dtmf-star",maxDuration:1e3},dtmfh:{filename:"dtmf-hash",maxDuration:1e3}};var base=twutil.getTwilioRoot()+"sounds/releases/"+twutil.getSoundVersion()+"/";for(var name_1 in defaultSounds){var soundDef=defaultSounds[name_1];var defaultUrl=base+soundDef.filename+"."+ext+"?cache=1_4_23";var soundUrl=options.sounds[name_1]||defaultUrl;var sound=new this.options.Sound(name_1,soundUrl,{maxDuration:soundDef.maxDuration,minDuration:soundDef.minDuration,shouldLoop:soundDef.shouldLoop,audioContext:this.options.disableAudioContextSounds?null:Device.audioContext});this.soundcache.set(name_1,sound)}var self=this;function createDefaultPayload(connection){var payload={client_name:self._clientName,platform:rtc.getMediaEngine(),sdk_version:twutil.getReleaseVersion(),selected_region:self.options.region};function setIfDefined(propertyName,value){if(value){payload[propertyName]=value}}if(connection){setIfDefined("call_sid",connection._getRealCallSid());setIfDefined("temp_call_sid",connection._getTempCallSid());payload.direction=connection._direction}var stream=self.stream;if(stream){setIfDefined("gateway",stream.gateway);setIfDefined("region",stream.region)}return payload}var publisher=this._publisher=new Publisher("twilio-js-sdk",this.token,{host:this.options.eventgw,defaultPayload:createDefaultPayload});if(options.publishEvents===false){publisher.disable()}function updateSinkIds(type,sinkIds){var promise=type==="ringtone"?updateRingtoneSinkIds(sinkIds):updateSpeakerSinkIds(sinkIds);return promise.then(function(){publisher.info("audio",type+"-devices-set",{audio_device_ids:sinkIds},self._activeConnection)},function(error){publisher.error("audio",type+"-devices-set-failed",{audio_device_ids:sinkIds,message:error.message},self._activeConnection);throw error})}function updateSpeakerSinkIds(sinkIds){sinkIds=sinkIds.forEach?sinkIds:[sinkIds];Array.from(self.soundcache.entries()).forEach(function(entry){if(entry[0]!=="incoming"){entry[1].setSinkIds(sinkIds)}});self._connectionSinkIds=sinkIds;var connection=self._activeConnection;return connection?connection._setSinkIds(sinkIds):Promise.resolve()}function updateRingtoneSinkIds(sinkIds){return Promise.resolve(self.soundcache.get("incoming").setSinkIds(sinkIds))}function updateInputStream(inputStream){var connection=self._activeConnection;if(connection&&!inputStream){return Promise.reject(new Error("Cannot unset input device while a call is in progress."))}self._connectionInputStream=inputStream;return connection?connection._setInputTracksFromStream(inputStream):Promise.resolve()}var audio=this.audio=new AudioHelper(updateSinkIds,updateInputStream,getUserMedia,{audioContext:Device.audioContext,logEnabled:!!this.options.debug,logWarnings:!!this.options.warnings,soundOptions:this.sounds});audio.on("deviceChange",function(lostActiveDevices){var activeConnection=self._activeConnection;var deviceIds=lostActiveDevices.map(function(device){return device.deviceId});publisher.info("audio","device-change",{lost_active_device_ids:deviceIds},activeConnection);if(activeConnection){activeConnection.mediaStream._onInputDevicesChanged()}});this.mediaPresence={audio:!this.options.noRegister};this.register(this.token);var closeProtection=this.options.closeProtection;function confirmClose(event){if(self._activeConnection){var defaultMsg="A call is currently in-progress. "+"Leaving or reloading this page will end the call.";var confirmationMsg=closeProtection===true?defaultMsg:closeProtection;(event||window.event).returnValue=confirmationMsg;return confirmationMsg}}if(closeProtection){if(typeof window!=="undefined"){if(window.addEventListener){window.addEventListener("beforeunload",confirmClose)}else if(window.attachEvent){window.attachEvent("onbeforeunload",confirmClose)}}}function onClose(){self.disconnectAll()}if(typeof window!=="undefined"){if(window.addEventListener){window.addEventListener("unload",onClose)}else if(window.attachEvent){window.attachEvent("onunload",onClose)}}this.on("error",function(){});return this}util.inherits(Device,EventEmitter);function makeConnection(device,params,options){var defaults={getSinkIds:function(){return device._connectionSinkIds},getInputStream:function(){return device._connectionInputStream},debug:device.options.debug,warnings:device.options.warnings,publisher:device._publisher,enableRingingState:device.options.enableRingingState};options=options||{};for(var prop in defaults){if(prop in options)continue;options[prop]=defaults[prop]}var connection=device.options.connectionFactory(device,params,getUserMedia,options);connection.once("accept",function(){device._activeConnection=connection;device._removeConnection(connection);device.audio._maybeStartPollingVolume();device.emit("connect",connection)});connection.addListener("error",function(error){if(connection.status()==="closed"){device._removeConnection(connection)}device.audio._maybeStopPollingVolume();device.emit("error",error)});connection.once("cancel",function(){device.log("Canceled: "+connection.parameters.CallSid);device._removeConnection(connection);device.audio._maybeStopPollingVolume();device.emit("cancel",connection)});connection.once("disconnect",function(){device.audio._maybeStopPollingVolume();device._removeConnection(connection);if(device._activeConnection===connection){device._activeConnection=null}device.emit("disconnect",connection)});connection.once("reject",function(){device.log("Rejected: "+connection.parameters.CallSid);device.audio._maybeStopPollingVolume();device._removeConnection(connection)});return connection}Object.defineProperties(Device,{isSupported:{get:function(){return rtc.enabled()}}});Device.toString=function(){return"[Twilio.Device class]"};Device.prototype.toString=function(){return"[Twilio.Device instance]"};Device.prototype.register=function(token){var objectized=twutil.objectize(token);this._accountSid=objectized.iss;this._clientName=objectized.scope["client:incoming"]?objectized.scope["client:incoming"].params.clientName:null;if(this.stream){this.stream.setToken(token);this._publisher.setToken(token)}else{this._setupStream(token)}};Device.prototype.registerPresence=function(){if(!this.token){return}var tokenIncomingObject=twutil.objectize(this.token).scope["client:incoming"];if(tokenIncomingObject){this.mediaPresence.audio=true}this._sendPresence()};Device.prototype.unregisterPresence=function(){this.mediaPresence.audio=false;this._sendPresence()};Device.prototype.connect=function(params,audioConstraints){if(typeof params==="function"){return this.addListener("connect",params)}if(this._activeConnection){throw new Error("A Connection is already active")}params=params||{};audioConstraints=audioConstraints||this.options.audioConstraints;var connection=this._activeConnection=makeConnection(this,params);this.connections.splice(0).forEach(function(conn){conn.ignore()});this.soundcache.get("incoming").stop();if(this.sounds.__dict__.outgoing){var self_1=this;connection.accept(function(){self_1.soundcache.get("outgoing").play()})}connection.accept(audioConstraints);return connection};Device.prototype.disconnectAll=function(){var connections=[].concat(this.connections);for(var i=0;i0){this.log("Connections left pending: "+this.connections.length)}};Device.prototype.destroy=function(){this._stopRegistrationTimer();this.audio._unbind();if(this.stream){this.stream.destroy();this.stream=null}};Device.prototype.disconnect=function(handler){this.addListener("disconnect",handler)};Device.prototype.incoming=function(handler){this.addListener("incoming",handler)};Device.prototype.offline=function(handler){this.addListener("offline",handler)};Device.prototype.ready=function(handler){this.addListener("ready",handler)};Device.prototype.error=function(handler){this.addListener("error",handler)};Device.prototype.status=function(){return this._activeConnection?"busy":this._status};Device.prototype.activeConnection=function(){return this._activeConnection||this.connections[0]};Device.prototype.region=function(){return this._region};Device.prototype._sendPresence=function(){if(!this.stream){return}this.stream.register(this.mediaPresence);if(this.mediaPresence.audio){this._startRegistrationTimer()}else{this._stopRegistrationTimer()}};Device.prototype._startRegistrationTimer=function(){clearTimeout(this.regTimer);var self=this;this.regTimer=setTimeout(function(){self._sendPresence()},REG_INTERVAL)};Device.prototype._stopRegistrationTimer=function(){clearTimeout(this.regTimer)};Device.prototype._setupStream=function(token){var self=this;this.log("Setting up PStream");var streamOptions={debug:this.options.debug,secureSignaling:this.options.secureSignaling};this.stream=this.options.pStreamFactory(token,this.options.chunderw,streamOptions);this.stream.addListener("connected",function(payload){var regions={US_EAST_VIRGINIA:"us1",US_WEST_OREGON:"us2",ASIAPAC_SYDNEY:"au1",SOUTH_AMERICA_SAO_PAULO:"br1",EU_IRELAND:"ie1",ASIAPAC_TOKYO:"jp1",ASIAPAC_SINGAPORE:"sg1"};self._region=regions[payload.region]||payload.region;self._sendPresence()});this.stream.addListener("close",function(){self.stream=null});this.stream.addListener("ready",function(){self.log("Stream is ready");if(self._status==="offline"){self._status="ready"}self.emit("ready",self)});this.stream.addListener("offline",function(){self.log("Stream is offline");self._status="offline";self._region="offline";self.emit("offline",self)});this.stream.addListener("error",function(payload){var error=payload.error;if(error){if(payload.callsid){error.connection=self._findConnection(payload.callsid)}if(error.code===31205){self._stopRegistrationTimer()}self.log("Received error: ",error);self.emit("error",error)}});this.stream.addListener("invite",function(payload){if(self._activeConnection){self.log("Device busy; ignoring incoming invite");return}if(!payload.callsid||!payload.sdp){self.emit("error",{message:"Malformed invite from gateway"});return}var params=payload.parameters||{};params.CallSid=params.CallSid||payload.callsid;function maybeStopIncomingSound(){if(!self.connections.length){self.soundcache.get("incoming").stop()}}var connection=makeConnection(self,{},{offerSdp:payload.sdp,callParameters:params});self.connections.push(connection);connection.once("accept",function(){self.soundcache.get("incoming").stop()});["cancel","error","reject"].forEach(function(event){connection.once(event,maybeStopIncomingSound)});var play=self.sounds.__dict__.incoming?function(){return self.soundcache.get("incoming").play()}:function(){return Promise.resolve()};self._showIncomingConnection(connection,play)})};Device.prototype._showIncomingConnection=function(connection,play){var self=this;var timeout;return Promise.race([play(),new Promise(function(resolve,reject){timeout=setTimeout(function(){reject(new Error("Playing incoming ringtone took too long; it might not play. Continuing execution..."))},RINGTONE_PLAY_TIMEOUT)})]).catch(function(reason){console.warn(reason.message)}).then(function(){clearTimeout(timeout);self.emit("incoming",connection)})};Device.prototype._removeConnection=function(connection){for(var i=this.connections.length-1;i>=0;i--){if(connection===this.connections[i]){this.connections.splice(i,1)}}};Device.prototype._findConnection=function(callsid){for(var i=0;i1){return}cls.instance.log(errorMessage)}throw new twutil.Exception(errorMessage)}var members={instance:null,setup:function(token,options){if(!cls.audioContext){if(typeof AudioContext!=="undefined"){cls.audioContext=new AudioContext}else if(typeof webkitAudioContext!=="undefined"){cls.audioContext=new webkitAudioContext}}var i;if(cls.instance){cls.instance.log("Found existing Device; using new token but ignoring options");cls.instance.token=token;cls.instance.register(token)}else{cls.instance=new Device(token,options);cls.error(defaultErrorHandler);cls.sounds=cls.instance.sounds;for(i=0;i0){cls.instance.emit("error",{message:"A connection is currently active"});return null}return cls.instance.connect(parameters,audioConstraints)},disconnectAll:function(){enqueue(function(){cls.instance.disconnectAll()});return cls},disconnect:function(handler){enqueue(function(){cls.instance.addListener("disconnect",handler)});return cls},status:function(){if(!cls.instance){throw new twutil.Exception("Run Twilio.Device.setup()")}return cls.instance.status()},region:function(){if(!cls.instance){throw new twutil.Exception("Run Twilio.Device.setup()")}return cls.instance.region()},ready:function(handler){enqueue(function(){cls.instance.addListener("ready",handler)});return cls},error:function(handler){enqueue(function(){if(handler!==defaultErrorHandler){cls.instance.removeListener("error",defaultErrorHandler)}cls.instance.addListener("error",handler)});return cls},offline:function(handler){enqueue(function(){cls.instance.addListener("offline",handler)});return cls},incoming:function(handler){enqueue(function(){cls.instance.addListener("incoming",handler)});return cls},destroy:function(){if(cls.instance){cls.instance.destroy()}return cls},cancel:function(handler){enqueue(function(){cls.instance.addListener("cancel",handler)});return cls},activeConnection:function(){if(!cls.instance){return null}return cls.instance.activeConnection()}};for(var method in members){cls[method]=members[method]}Object.defineProperties(cls,{audio:{get:function(){return cls.instance.audio}}});return cls}exports.Device=singletonwrapper(Device)},{"./audiohelper":4,"./connection":5,"./eventpublisher":7,"./log":8,"./options":9,"./pstream":11,"./rtc":14,"./rtc/getusermedia":13,"./sound":24,"./util":28,events:43,util:55}],7:[function(require,module,exports){var request=require("./request");function EventPublisher(productName,token,options){if(!(this instanceof EventPublisher)){return new EventPublisher(productName,token,options)}options=Object.assign({defaultPayload:function(){return{}},host:"eventgw.twilio.com"},options);var defaultPayload=options.defaultPayload;if(typeof defaultPayload!=="function"){defaultPayload=function(){return Object.assign({},options.defaultPayload)}}var isEnabled=true;Object.defineProperties(this,{_defaultPayload:{value:defaultPayload},_isEnabled:{get:function(){return isEnabled},set:function(_isEnabled){isEnabled=_isEnabled}},_host:{value:options.host},_request:{value:options.request||request},_token:{value:token,writable:true},isEnabled:{enumerable:true,get:function(){return isEnabled}},productName:{enumerable:true,value:productName},token:{enumerable:true,get:function(){return this._token}}})}EventPublisher.prototype._post=function _post(endpointName,level,group,name,payload,connection,force){if(!this.isEnabled&&!force){return Promise.resolve()}var event={publisher:this.productName,group:group,name:name,timestamp:(new Date).toISOString(),level:level.toUpperCase(),payload_type:"application/json",private:false,payload:payload&&payload.forEach?payload.slice(0):Object.assign(this._defaultPayload(connection),payload)};var requestParams={url:"https://"+this._host+"/v2/"+endpointName,body:event,headers:{"Content-Type":"application/json","X-Twilio-Token":this.token}};var self=this;return new Promise(function(resolve,reject){self._request.post(requestParams,function(err){if(err){reject(err)}else{resolve()}})})};EventPublisher.prototype.post=function post(level,group,name,payload,connection,force){return this._post("EndpointEvents",level,group,name,payload,connection,force)};EventPublisher.prototype.debug=function debug(group,name,payload,connection){return this.post("debug",group,name,payload,connection)};EventPublisher.prototype.info=function info(group,name,payload,connection){return this.post("info",group,name,payload,connection)};EventPublisher.prototype.warn=function warn(group,name,payload,connection){return this.post("warning",group,name,payload,connection)};EventPublisher.prototype.error=function error(group,name,payload,connection){return this.post("error",group,name,payload,connection)};EventPublisher.prototype.postMetrics=function postMetrics(group,name,metrics,customFields){var self=this;return new Promise(function(resolve){var samples=metrics.map(formatMetric).map(function(sample){return Object.assign(sample,customFields)});resolve(self._post("EndpointMetrics","info",group,name,samples))})};EventPublisher.prototype.setToken=function setToken(token){this._token=token};EventPublisher.prototype.enable=function enable(){this._isEnabled=true};EventPublisher.prototype.disable=function disable(){this._isEnabled=false};function formatMetric(sample){return{timestamp:new Date(sample.timestamp).toISOString(),total_packets_received:sample.totals.packetsReceived,total_packets_lost:sample.totals.packetsLost,total_packets_sent:sample.totals.packetsSent,total_bytes_received:sample.totals.bytesReceived,total_bytes_sent:sample.totals.bytesSent,packets_received:sample.packetsReceived,packets_lost:sample.packetsLost,packets_lost_fraction:sample.packetsLostFraction&&Math.round(sample.packetsLostFraction*100)/100,audio_level_in:sample.audioInputLevel,audio_level_out:sample.audioOutputLevel,call_volume_input:sample.inputVolume,call_volume_output:sample.outputVolume,jitter:sample.jitter,rtt:sample.rtt,mos:sample.mos&&Math.round(sample.mos*100)/100}}module.exports=EventPublisher},{"./request":12}],8:[function(require,module,exports){function mixinLog(object,prefix){function log(){var args=[];for(var _i=0;_i0?currentPacketsLost/currentInboundPackets*100:0;var totalInboundPackets=stats.packetsReceived+stats.packetsLost;var totalPacketsLostFraction=totalInboundPackets>0?stats.packetsLost/totalInboundPackets*100:100;return{timestamp:stats.timestamp,totals:{packetsReceived:stats.packetsReceived,packetsLost:stats.packetsLost,packetsSent:stats.packetsSent,packetsLostFraction:totalPacketsLostFraction,bytesReceived:stats.bytesReceived,bytesSent:stats.bytesSent},packetsSent:currentPacketsSent,packetsReceived:currentPacketsReceived,packetsLost:currentPacketsLost,packetsLostFraction:currentPacketsLostFraction,audioInputLevel:stats.audioInputLevel,audioOutputLevel:stats.audioOutputLevel,jitter:stats.jitter,rtt:stats.rtt,mos:Mos.calculate(stats,previousSample&¤tPacketsLostFraction)}};RTCMonitor.prototype.enable=function enable(peerConnection){if(peerConnection){if(this._peerConnection&&peerConnection!==this._peerConnection){throw new Error("Attempted to replace an existing PeerConnection in RTCMonitor.enable")}this._peerConnection=peerConnection}if(!this._peerConnection){throw new Error("Can not enable RTCMonitor without a PeerConnection")}this._sampleInterval=this._sampleInterval||setInterval(this._fetchSample.bind(this),SAMPLE_INTERVAL);return this};RTCMonitor.prototype.disable=function disable(){clearInterval(this._sampleInterval);this._sampleInterval=null;return this};RTCMonitor.prototype.getSample=function getSample(){var pc=this._peerConnection;var self=this;return getStatistics(pc).then(function(stats){var previousSample=self._sampleBuffer.length&&self._sampleBuffer[self._sampleBuffer.length-1];return RTCMonitor.createSample(stats,previousSample)})};RTCMonitor.prototype._fetchSample=function _fetchSample(){var self=this;return this.getSample().then(function addSample(sample){self._addSample(sample);self._raiseWarnings();self.emit("sample",sample);return sample},function getSampleFailed(error){self.disable();self.emit("error",error)})};RTCMonitor.prototype._addSample=function _addSample(sample){var samples=this._sampleBuffer;samples.push(sample);if(samples.length>SAMPLE_COUNT_METRICS){samples.splice(0,samples.length-SAMPLE_COUNT_METRICS)}};RTCMonitor.prototype._raiseWarnings=function _raiseWarnings(){if(!this._warningsEnabled){return}for(var name_1 in this._thresholds){this._raiseWarningsForStat(name_1)}};RTCMonitor.prototype.enableWarnings=function enableWarnings(){this._warningsEnabled=true;return this};RTCMonitor.prototype.disableWarnings=function disableWarnings(){if(this._warningsEnabled){this._activeWarnings.clear()}this._warningsEnabled=false;return this};RTCMonitor.prototype._raiseWarningsForStat=function _raiseWarningsForStat(statName){var samples=this._sampleBuffer;var limits=this._thresholds[statName];var relevantSamples=samples.slice(-SAMPLE_COUNT_METRICS);var values=relevantSamples.map(function(sample){return sample[statName]});var containsNull=values.some(function(value){return typeof value==="undefined"||value===null});if(containsNull){return}var count;if(typeof limits.max==="number"){count=countHigh(limits.max,values);if(count>=SAMPLE_COUNT_RAISE){this._raiseWarning(statName,"max",{values:values})}else if(count<=SAMPLE_COUNT_CLEAR){this._clearWarning(statName,"max",{values:values})}}if(typeof limits.min==="number"){count=countLow(limits.min,values);if(count>=SAMPLE_COUNT_RAISE){this._raiseWarning(statName,"min",{values:values})}else if(count<=SAMPLE_COUNT_CLEAR){this._clearWarning(statName,"min",{values:values})}}if(typeof limits.maxDuration==="number"&&samples.length>1){relevantSamples=samples.slice(-2);var prevValue=relevantSamples[0][statName];var curValue=relevantSamples[1][statName];var prevStreak=this._currentStreaks.get(statName)||0;var streak=prevValue===curValue?prevStreak+1:0;this._currentStreaks.set(statName,streak);if(streak>=limits.maxDuration){this._raiseWarning(statName,"maxDuration",{value:streak})}else if(streak===0){this._clearWarning(statName,"maxDuration",{value:prevStreak})}}};function countLow(min,values){return values.reduce(function(lowCount,value){return lowCount+=valuemax?1:0},0)}RTCMonitor.prototype._clearWarning=function _clearWarning(statName,thresholdName,data){var warningId=statName+":"+thresholdName;var activeWarning=this._activeWarnings.get(warningId);if(!activeWarning||Date.now()-activeWarning.timeRaised=1&&mos<4.6;return isValid?mos:null}function calculateRFactor(rtt,jitter,fractionLost){var effectiveLatency=rtt+jitter*2+10;var rFactor=0;switch(true){case effectiveLatency<160:rFactor=rfactorConstants.r0-effectiveLatency/40;break;case effectiveLatency<1e3:rFactor=rfactorConstants.r0-(effectiveLatency-120)/10;break;case effectiveLatency>=1e3:rFactor=rfactorConstants.r0-effectiveLatency/100;break}var multiplier=.01;switch(true){case fractionLost===-1:multiplier=0;rFactor=0;break;case fractionLost<=rFactor/2.5:multiplier=2.5;break;case fractionLost>rFactor/2.5&&fractionLost<100:multiplier=.25;break}rFactor-=fractionLost*multiplier;return rFactor}function isPositiveNumber(n){return typeof n==="number"&&!isNaN(n)&&isFinite(n)&&n>=0}module.exports={calculate:calcMos}},{}],18:[function(require,module,exports){var Log=require("../log");var StateMachine=require("../statemachine");var util=require("../util");var RTCPC=require("./rtcpc");var ICE_CONNECTION_STATES={new:["checking","closed"],checking:["new","connected","failed","closed","completed"],connected:["new","disconnected","completed","closed"],completed:["new","disconnected","closed","completed"],failed:["new","disconnected","closed"],disconnected:["connected","completed","failed","closed"],closed:[]};var INITIAL_ICE_CONNECTION_STATE="new";var SIGNALING_STATES={stable:["have-local-offer","have-remote-offer","closed"],"have-local-offer":["stable","closed"],"have-remote-offer":["stable","closed"],closed:[]};var INITIAL_SIGNALING_STATE="stable";function PeerConnection(device,getUserMedia,options){if(!device||!getUserMedia){throw new Error("Device and getUserMedia are required arguments")}if(!(this instanceof PeerConnection)){return new PeerConnection(device,getUserMedia,options)}function noop(){}this.onopen=noop;this.onerror=noop;this.onclose=noop;this.ondisconnect=noop;this.onreconnect=noop;this.onsignalingstatechange=noop;this.oniceconnectionstatechange=noop;this.onicecandidate=noop;this.onvolume=noop;this.version=null;this.pstream=device.stream;this.stream=null;this.sinkIds=new Set(["default"]);this.outputs=new Map;this.status="connecting";this.callSid=null;this.isMuted=false;this.getUserMedia=getUserMedia;var AudioContext=typeof window!=="undefined"&&(window.AudioContext||window.webkitAudioContext);this._isSinkSupported=!!AudioContext&&typeof HTMLAudioElement!=="undefined"&&HTMLAudioElement.prototype.setSinkId;this._audioContext=AudioContext&&device.audio._audioContext;this._masterAudio=null;this._masterAudioDeviceId=null;this._mediaStreamSource=null;this._dtmfSender=null;this._dtmfSenderUnsupported=false;this._callEvents=[];this._nextTimeToPublish=Date.now();this._onAnswerOrRinging=noop;this._remoteStream=null;this._shouldManageStream=true;Log.mixinLog(this,"[Twilio.PeerConnection]");this.log.enabled=device.options.debug;this.log.warnings=device.options.warnings;this._iceConnectionStateMachine=new StateMachine(ICE_CONNECTION_STATES,INITIAL_ICE_CONNECTION_STATE);this._signalingStateMachine=new StateMachine(SIGNALING_STATES,INITIAL_SIGNALING_STATE);this.options=options=options||{};this.navigator=options.navigator||(typeof navigator!=="undefined"?navigator:null);this.util=options.util||util;return this}PeerConnection.prototype.uri=function(){return this._uri};PeerConnection.prototype.openWithConstraints=function(constraints){return this.getUserMedia({audio:constraints}).then(this._setInputTracksFromStream.bind(this,false))};PeerConnection.prototype.setInputTracksFromStream=function(stream){var self=this;return this._setInputTracksFromStream(true,stream).then(function(){self._shouldManageStream=false})};PeerConnection.prototype._createAnalyser=function(stream,audioContext){var analyser=audioContext.createAnalyser();analyser.fftSize=32;analyser.smoothingTimeConstant=.3;var streamSource=audioContext.createMediaStreamSource(stream);streamSource.connect(analyser);return analyser};PeerConnection.prototype._setVolumeHandler=function(handler){this.onvolume=handler};PeerConnection.prototype._startPollingVolume=function(){if(!this._audioContext||!this.stream||!this._remoteStream){return}var audioContext=this._audioContext;var inputAnalyser=this._inputAnalyser=this._createAnalyser(this.stream,audioContext);var inputBufferLength=inputAnalyser.frequencyBinCount;var inputDataArray=new Uint8Array(inputBufferLength);var outputAnalyser=this._outputAnalyser=this._createAnalyser(this._remoteStream,audioContext);var outputBufferLength=outputAnalyser.frequencyBinCount;var outputDataArray=new Uint8Array(outputBufferLength);var self=this;requestAnimationFrame(function emitVolume(){if(!self._audioContext){return}else if(self.status==="closed"){self._inputAnalyser.disconnect();self._outputAnalyser.disconnect();return}self._inputAnalyser.getByteFrequencyData(inputDataArray);var inputVolume=self.util.average(inputDataArray);self._outputAnalyser.getByteFrequencyData(outputDataArray);var outputVolume=self.util.average(outputDataArray);self.onvolume(inputVolume/255,outputVolume/255);requestAnimationFrame(emitVolume)})};PeerConnection.prototype._stopStream=function _stopStream(stream){if(!this._shouldManageStream){return}if(typeof MediaStreamTrack.prototype.stop==="function"){var audioTracks=typeof stream.getAudioTracks==="function"?stream.getAudioTracks():stream.audioTracks;audioTracks.forEach(function(track){track.stop()})}else{stream.stop()}};PeerConnection.prototype._setInputTracksFromStream=function(shouldClone,newStream){var self=this;if(!newStream){return Promise.reject(new Error("Can not set input stream to null while in a call"))}if(!newStream.getAudioTracks().length){return Promise.reject(new Error("Supplied input stream has no audio tracks"))}var localStream=this.stream;if(!localStream){this.stream=shouldClone?cloneStream(newStream):newStream}else{this._stopStream(localStream);removeStream(this.version.pc,localStream);localStream.getAudioTracks().forEach(localStream.removeTrack,localStream);newStream.getAudioTracks().forEach(localStream.addTrack,localStream);addStream(this.version.pc,newStream)}this.mute(this.isMuted);if(!this.version){return Promise.resolve(this.stream)}return new Promise(function(resolve,reject){self.version.createOffer({audio:true},function onOfferSuccess(){self.version.processAnswer(self._answerSdp,function(){if(self._audioContext){self._inputAnalyser=self._createAnalyser(self.stream,self._audioContext)}resolve(self.stream)},reject)},reject)})};PeerConnection.prototype._onInputDevicesChanged=function(){if(!this.stream){return}var activeInputWasLost=this.stream.getAudioTracks().every(function(track){return track.readyState==="ended"});if(activeInputWasLost&&this._shouldManageStream){this.openWithConstraints(true)}};PeerConnection.prototype._setSinkIds=function(sinkIds){if(!this._isSinkSupported){return Promise.reject(new Error("Audio output selection is not supported by this browser"))}this.sinkIds=new Set(sinkIds.forEach?sinkIds:[sinkIds]);return this.version?this._updateAudioOutputs():Promise.resolve()};PeerConnection.prototype._updateAudioOutputs=function updateAudioOutputs(){var addedOutputIds=Array.from(this.sinkIds).filter(function(id){return!this.outputs.has(id)},this);var removedOutputIds=Array.from(this.outputs.keys()).filter(function(id){return!this.sinkIds.has(id)},this);var self=this;var createOutputPromises=addedOutputIds.map(this._createAudioOutput,this);return Promise.all(createOutputPromises).then(function(){return Promise.all(removedOutputIds.map(self._removeAudioOutput,self))})};PeerConnection.prototype._createAudio=function createAudio(arr){return new Audio(arr)};PeerConnection.prototype._createAudioOutput=function createAudioOutput(id){var dest=this._audioContext.createMediaStreamDestination();this._mediaStreamSource.connect(dest);var audio=this._createAudio();setAudioSource(audio,dest.stream);var self=this;return audio.setSinkId(id).then(function(){return audio.play()}).then(function(){self.outputs.set(id,{audio:audio,dest:dest})})};PeerConnection.prototype._removeAudioOutputs=function removeAudioOutputs(){return Array.from(this.outputs.keys()).map(this._removeAudioOutput,this)};PeerConnection.prototype._disableOutput=function disableOutput(pc,id){var output=pc.outputs.get(id);if(!output){return}if(output.audio){output.audio.pause();output.audio.src=""}if(output.dest){output.dest.disconnect()}};PeerConnection.prototype._reassignMasterOutput=function reassignMasterOutput(pc,masterId){var masterOutput=pc.outputs.get(masterId);pc.outputs.delete(masterId);var self=this;var idToReplace=Array.from(pc.outputs.keys())[0]||"default";return masterOutput.audio.setSinkId(idToReplace).then(function(){self._disableOutput(pc,idToReplace);pc.outputs.set(idToReplace,masterOutput);pc._masterAudioDeviceId=idToReplace}).catch(function rollback(reason){pc.outputs.set(masterId,masterOutput);throw reason})};PeerConnection.prototype._removeAudioOutput=function removeAudioOutput(id){if(this._masterAudioDeviceId===id){return this._reassignMasterOutput(this,id)}this._disableOutput(this,id);this.outputs.delete(id);return Promise.resolve()};PeerConnection.prototype._onAddTrack=function onAddTrack(pc,stream){var audio=pc._masterAudio=this._createAudio();setAudioSource(audio,stream);audio.play();var deviceId=Array.from(pc.outputs.keys())[0]||"default";pc._masterAudioDeviceId=deviceId;pc.outputs.set(deviceId,{audio:audio});pc._mediaStreamSource=pc._audioContext.createMediaStreamSource(stream);pc.pcStream=stream;pc._updateAudioOutputs()};PeerConnection.prototype._fallbackOnAddTrack=function fallbackOnAddTrack(pc,stream){var audio=document&&document.createElement("audio");audio.autoplay=true;if(!setAudioSource(audio,stream)){pc.log("Error attaching stream to element.")}pc.outputs.set("default",{audio:audio})};PeerConnection.prototype._setupPeerConnection=function(rtcConstraints,iceServers){var self=this;var version=this._getProtocol();version.create(this.log,rtcConstraints,iceServers);addStream(version.pc,this.stream);var eventName="ontrack"in version.pc?"ontrack":"onaddstream";version.pc[eventName]=function(event){var stream=self._remoteStream=event.stream||event.streams[0];if(self._isSinkSupported){self._onAddTrack(self,stream)}else{self._fallbackOnAddTrack(self,stream)}self._startPollingVolume()};return version};PeerConnection.prototype._setupChannel=function(){var self=this;var pc=this.version.pc;self.version.pc.onopen=function(){self.status="open";self.onopen()};self.version.pc.onstatechange=function(){if(self.version.pc&&self.version.pc.readyState==="stable"){self.status="open";self.onopen()}};self.version.pc.onsignalingstatechange=function(){var state=pc.signalingState;self.log('signalingState is "'+state+'"');try{self._signalingStateMachine.transition(state)}catch(error){self.log("Failed to transition to signaling state "+state+": "+error)}if(self.version.pc&&self.version.pc.signalingState==="stable"){self.status="open";self.onopen()}self.onsignalingstatechange(pc.signalingState)};pc.onicecandidate=function onicecandidate(event){self.onicecandidate(event.candidate)};pc.oniceconnectionstatechange=function(){var state=pc.iceConnectionState;var previousState=self._iceConnectionStateMachine.currentState;try{self._iceConnectionStateMachine.transition(state)}catch(error){self.log("Failed to transition to ice connection state "+state+": "+error)}var message;switch(state){case"connected":if(previousState==="disconnected"){message="ICE liveliness check succeeded. Connection with Twilio restored";self.log(message);self.onreconnect(message)}break;case"disconnected":message="ICE liveliness check failed. May be having trouble connecting to Twilio";self.log(message);self.ondisconnect(message);break;case"failed":message=(previousState==="checking"?"ICE negotiation with Twilio failed.":"Connection with Twilio was interrupted.")+" Call will terminate.";self.log(message);self.onerror({info:{code:31003,message:message},disconnect:true});break;default:self.log('iceConnectionState is "'+state+'"')}self.oniceconnectionstatechange(state)}};PeerConnection.prototype._initializeMediaStream=function(rtcConstraints,iceServers){if(this.status==="open"){return false}if(this.pstream.status==="disconnected"){this.onerror({info:{code:31e3,message:"Cannot establish connection. Client is disconnected"}});this.close();return false}this.version=this._setupPeerConnection(rtcConstraints,iceServers);this._setupChannel();return true};PeerConnection.prototype.makeOutgoingCall=function(token,params,callsid,rtcConstraints,iceServers,onMediaStarted){if(!this._initializeMediaStream(rtcConstraints,iceServers)){return}var self=this;this.callSid=callsid;function onAnswerSuccess(){onMediaStarted(self.version.pc)}function onAnswerError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error processing answer: "+errMsg}})}this._onAnswerOrRinging=function(payload){if(!payload.sdp){return}self._answerSdp=payload.sdp;if(self.status!=="closed"){self.version.processAnswer(payload.sdp,onAnswerSuccess,onAnswerError)}self.pstream.removeListener("answer",self._onAnswerOrRinging);self.pstream.removeListener("ringing",self._onAnswerOrRinging)};this.pstream.on("answer",this._onAnswerOrRinging);this.pstream.on("ringing",this._onAnswerOrRinging);function onOfferSuccess(){if(self.status!=="closed"){self.pstream.publish("invite",{sdp:self.version.getSDP(),callsid:self.callSid,twilio:{accountsid:token?self.util.objectize(token).iss:null,params:params}})}}function onOfferError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error creating the offer: "+errMsg}})}this.version.createOffer({audio:true},onOfferSuccess,onOfferError)};PeerConnection.prototype.answerIncomingCall=function(callSid,sdp,rtcConstraints,iceServers,onMediaStarted){if(!this._initializeMediaStream(rtcConstraints,iceServers)){return}this._answerSdp=sdp.replace(/^a=setup:actpass$/gm,"a=setup:passive");this.callSid=callSid;var self=this;function onAnswerSuccess(){if(self.status!=="closed"){self.pstream.publish("answer",{callsid:callSid,sdp:self.version.getSDP()});onMediaStarted(self.version.pc)}}function onAnswerError(err){var errMsg=err.message||err;self.onerror({info:{code:31e3,message:"Error creating the answer: "+errMsg}})}this.version.processSDP(sdp,{audio:true},onAnswerSuccess,onAnswerError)};PeerConnection.prototype.close=function(){if(this.version&&this.version.pc){if(this.version.pc.signalingState!=="closed"){this.version.pc.close()}this.version.pc=null}if(this.stream){this.mute(false);this._stopStream(this.stream)}this.stream=null;if(this.pstream){this.pstream.removeListener("answer",this._onAnswerOrRinging)}this._removeAudioOutputs();if(this._mediaStreamSource){this._mediaStreamSource.disconnect()}if(this._inputAnalyser){this._inputAnalyser.disconnect()}if(this._outputAnalyser){this._outputAnalyser.disconnect()}this.status="closed";this.onclose()};PeerConnection.prototype.reject=function(callSid){this.callSid=callSid};PeerConnection.prototype.ignore=function(callSid){this.callSid=callSid};PeerConnection.prototype.mute=function(shouldMute){this.isMuted=shouldMute;if(!this.stream){return}var audioTracks=typeof this.stream.getAudioTracks==="function"?this.stream.getAudioTracks():this.stream.audioTracks;audioTracks.forEach(function(track){track.enabled=!shouldMute})};PeerConnection.prototype.getOrCreateDTMFSender=function getOrCreateDTMFSender(){if(this._dtmfSender||this._dtmfSenderUnsupported){return this._dtmfSender||null}var self=this;var pc=this.version.pc;if(!pc){this.log("No RTCPeerConnection available to call createDTMFSender on");return null}if(typeof pc.getSenders==="function"&&(typeof RTCDTMFSender==="function"||typeof RTCDtmfSender==="function")){var chosenSender=pc.getSenders().find(function(sender){return sender.dtmf});if(chosenSender){this.log("Using RTCRtpSender#dtmf");this._dtmfSender=chosenSender.dtmf;return this._dtmfSender}}if(typeof pc.createDTMFSender==="function"&&typeof pc.getLocalStreams==="function"){var track=pc.getLocalStreams().map(function(stream){var tracks=self._getAudioTracks(stream);return tracks&&tracks[0]})[0];if(!track){this.log("No local audio MediaStreamTrack available on the RTCPeerConnection to pass to createDTMFSender");return null}this.log("Creating RTCDTMFSender");this._dtmfSender=pc.createDTMFSender(track);return this._dtmfSender}this.log("RTCPeerConnection does not support RTCDTMFSender");this._dtmfSenderUnsupported=true;return null};PeerConnection.prototype._canStopMediaStreamTrack=function(){return typeof MediaStreamTrack.prototype.stop==="function"};PeerConnection.prototype._getAudioTracks=function(stream){return typeof stream.getAudioTracks==="function"?stream.getAudioTracks():stream.audioTracks};PeerConnection.prototype._getProtocol=function(){return PeerConnection.protocol};PeerConnection.protocol=function(){return RTCPC.test()?new RTCPC:null}();function addStream(pc,stream){if(typeof pc.addTrack==="function"){stream.getAudioTracks().forEach(function(track){pc.addTrack(track,stream)})}else{pc.addStream(stream)}}function cloneStream(oldStream){var newStream=typeof MediaStream!=="undefined"?new MediaStream:new webkitMediaStream;oldStream.getAudioTracks().forEach(newStream.addTrack,newStream);return newStream}function removeStream(pc,stream){if(typeof pc.removeTrack==="function"){pc.getSenders().forEach(function(sender){pc.removeTrack(sender)})}else{pc.removeStream(stream)}}function setAudioSource(audio,stream){if(typeof audio.srcObject!=="undefined"){audio.srcObject=stream}else if(typeof audio.mozSrcObject!=="undefined"){audio.mozSrcObject=stream}else if(typeof audio.src!=="undefined"){var _window=audio.options.window||window;audio.src=(_window.URL||_window.webkitURL).createObjectURL(stream)}else{return false}return true}PeerConnection.enabled=!!PeerConnection.protocol;module.exports=PeerConnection},{"../log":8,"../statemachine":25,"../util":28,"./rtcpc":19}],19:[function(require,module,exports){(function(global){var RTCPeerConnectionShim=require("rtcpeerconnection-shim");var util=require("../util");function RTCPC(){if(typeof window==="undefined"){this.log("No RTCPeerConnection implementation available. The window object was not found.");return}if(util.isEdge()){this.RTCPeerConnection=new RTCPeerConnectionShim(typeof window!=="undefined"?window:global)}else if(typeof window.RTCPeerConnection==="function"){this.RTCPeerConnection=window.RTCPeerConnection}else if(typeof window.webkitRTCPeerConnection==="function"){this.RTCPeerConnection=webkitRTCPeerConnection}else if(typeof window.mozRTCPeerConnection==="function"){this.RTCPeerConnection=mozRTCPeerConnection;window.RTCSessionDescription=mozRTCSessionDescription;window.RTCIceCandidate=mozRTCIceCandidate}else{this.log("No RTCPeerConnection implementation available")}}RTCPC.prototype.create=function(log,rtcConstraints,iceServers){this.log=log;this.pc=new this.RTCPeerConnection({iceServers:iceServers},rtcConstraints)};RTCPC.prototype.createModernConstraints=function(c){if(typeof c==="undefined"){return null}var nc={};if(typeof webkitRTCPeerConnection!=="undefined"&&!util.isEdge()){nc.mandatory={};if(typeof c.audio!=="undefined"){nc.mandatory.OfferToReceiveAudio=c.audio}if(typeof c.video!=="undefined"){nc.mandatory.OfferToReceiveVideo=c.video}}else{if(typeof c.audio!=="undefined"){nc.offerToReceiveAudio=c.audio}if(typeof c.video!=="undefined"){nc.offerToReceiveVideo=c.video}}return nc};RTCPC.prototype.createOffer=function(constraints,onSuccess,onError){var self=this;constraints=this.createModernConstraints(constraints);promisifyCreate(this.pc.createOffer,this.pc)(constraints).then(function(sd){return self.pc&&promisifySet(self.pc.setLocalDescription,self.pc)(new RTCSessionDescription(sd))}).then(onSuccess,onError)};RTCPC.prototype.createAnswer=function(constraints,onSuccess,onError){var self=this;constraints=this.createModernConstraints(constraints);promisifyCreate(this.pc.createAnswer,this.pc)(constraints).then(function(sd){return self.pc&&promisifySet(self.pc.setLocalDescription,self.pc)(new RTCSessionDescription(sd))}).then(onSuccess,onError)};RTCPC.prototype.processSDP=function(sdp,constraints,onSuccess,onError){var self=this;var desc=new RTCSessionDescription({sdp:sdp,type:"offer"});promisifySet(this.pc.setRemoteDescription,this.pc)(desc).then(function(){self.createAnswer(constraints,onSuccess,onError)})};RTCPC.prototype.getSDP=function(){return this.pc.localDescription.sdp};RTCPC.prototype.processAnswer=function(sdp,onSuccess,onError){if(!this.pc){return}promisifySet(this.pc.setRemoteDescription,this.pc)(new RTCSessionDescription({sdp:sdp,type:"answer"})).then(onSuccess,onError)};RTCPC.test=function(){if(typeof navigator==="object"){var getUserMedia=navigator.mediaDevices&&navigator.mediaDevices.getUserMedia||navigator.webkitGetUserMedia||navigator.mozGetUserMedia||navigator.getUserMedia;if(getUserMedia&&typeof window.RTCPeerConnection==="function"){return true}else if(getUserMedia&&typeof window.webkitRTCPeerConnection==="function"){return true}else if(getUserMedia&&typeof window.mozRTCPeerConnection==="function"){try{var test_1=new window.mozRTCPeerConnection;if(typeof test_1.getLocalStreams!=="function")return false}catch(e){return false}return true}else if(typeof RTCIceGatherer!=="undefined"){return true}}return false};function promisify(fn,ctx,areCallbacksFirst){return function(){var args=Array.prototype.slice.call(arguments);return new Promise(function(resolve){resolve(fn.apply(ctx,args))}).catch(function(){return new Promise(function(resolve,reject){fn.apply(ctx,areCallbacksFirst?[resolve,reject].concat(args):args.concat([resolve,reject]))})})}}function promisifyCreate(fn,ctx){return promisify(fn,ctx,true)}function promisifySet(fn,ctx){return promisify(fn,ctx,false)}module.exports=RTCPC}).call(this,typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{})},{"../util":28,"rtcpeerconnection-shim":51}],20:[function(require,module,exports){var MockRTCStatsReport=require("./mockrtcstatsreport");var ERROR_PEER_CONNECTION_NULL="PeerConnection is null";var ERROR_WEB_RTC_UNSUPPORTED="WebRTC statistics are unsupported";var SIGNED_SHORT=32767;var isChrome=false;if(typeof window!=="undefined"){var isCriOS=!!window.navigator.userAgent.match("CriOS");var isElectron=!!window.navigator.userAgent.match("Electron");var isGoogle=typeof window.chrome!=="undefined"&&window.navigator.vendor==="Google Inc."&&window.navigator.userAgent.indexOf("OPR")===-1&&window.navigator.userAgent.indexOf("Edge")===-1;isChrome=isCriOS||isElectron||isGoogle}function getStatistics(peerConnection,options){options=Object.assign({createRTCSample:createRTCSample},options);if(!peerConnection){return Promise.reject(new Error(ERROR_PEER_CONNECTION_NULL))}if(typeof peerConnection.getStats!=="function"){return Promise.reject(new Error(ERROR_WEB_RTC_UNSUPPORTED))}if(isChrome){return new Promise(function(resolve,reject){return peerConnection.getStats(resolve,reject)}).then(MockRTCStatsReport.fromRTCStatsResponse).then(options.createRTCSample)}var promise;try{promise=peerConnection.getStats()}catch(e){promise=new Promise(function(resolve,reject){return peerConnection.getStats(resolve,reject)}).then(MockRTCStatsReport.fromRTCStatsResponse)}return promise.then(options.createRTCSample)}function RTCSample(){}function createRTCSample(statsReport){var activeTransportId=null;var sample=new RTCSample;var fallbackTimestamp;Array.from(statsReport.values()).forEach(function(stats){var type=stats.type.replace("-","");fallbackTimestamp=fallbackTimestamp||stats.timestamp;switch(type){case"inboundrtp":sample.timestamp=sample.timestamp||stats.timestamp;sample.jitter=stats.jitter*1e3;sample.packetsLost=stats.packetsLost;sample.packetsReceived=stats.packetsReceived;sample.bytesReceived=stats.bytesReceived;var inboundTrack=statsReport.get(stats.trackId);if(inboundTrack){sample.audioOutputLevel=inboundTrack.audioLevel*SIGNED_SHORT}break;case"outboundrtp":sample.timestamp=stats.timestamp;sample.packetsSent=stats.packetsSent;sample.bytesSent=stats.bytesSent;if(stats.codecId&&statsReport.get(stats.codecId)){var mimeType=statsReport.get(stats.codecId).mimeType;sample.codecName=mimeType&&mimeType.match(/(.*\/)?(.*)/)[2]}var outboundTrack=statsReport.get(stats.trackId);if(outboundTrack){sample.audioInputLevel=outboundTrack.audioLevel*SIGNED_SHORT}break;case"transport":if(stats.dtlsState==="connected"){activeTransportId=stats.id}break}});if(!sample.timestamp){sample.timestamp=fallbackTimestamp}var activeTransport=statsReport.get(activeTransportId);if(!activeTransport){return sample}var selectedCandidatePair=statsReport.get(activeTransport.selectedCandidatePairId);if(!selectedCandidatePair){return sample}var localCandidate=statsReport.get(selectedCandidatePair.localCandidateId);var remoteCandidate=statsReport.get(selectedCandidatePair.remoteCandidateId);Object.assign(sample,{localAddress:localCandidate&&localCandidate.ip,remoteAddress:remoteCandidate&&remoteCandidate.ip,rtt:selectedCandidatePair&&selectedCandidatePair.currentRoundTripTime*1e3});return sample}module.exports=getStatistics},{"./mockrtcstatsreport":15}],21:[function(require,module,exports){var EventEmitter=require("events").EventEmitter;function EventTarget(){Object.defineProperties(this,{_eventEmitter:{value:new EventEmitter},_handlers:{value:{}}})}EventTarget.prototype.dispatchEvent=function dispatchEvent(event){return this._eventEmitter.emit(event.type,event)};EventTarget.prototype.addEventListener=function addEventListener(){return(_a=this._eventEmitter).addListener.apply(_a,arguments);var _a};EventTarget.prototype.removeEventListener=function removeEventListener(){return(_a=this._eventEmitter).removeListener.apply(_a,arguments);var _a};EventTarget.prototype._defineEventHandler=function _defineEventHandler(eventName){var self=this;Object.defineProperty(this,"on"+eventName,{get:function(){return self._handlers[eventName]},set:function(newHandler){var oldHandler=self._handlers[eventName];if(oldHandler&&(typeof newHandler==="function"||typeof newHandler==="undefined"||newHandler===null)){self._handlers[eventName]=null;self.removeEventListener(eventName,oldHandler)}if(typeof newHandler==="function"){self._handlers[eventName]=newHandler;self.addEventListener(eventName,newHandler)}}})};module.exports=EventTarget},{events:43}],22:[function(require,module,exports){function MediaDeviceInfoShim(options){Object.defineProperties(this,{deviceId:{get:function(){return options.deviceId}},groupId:{get:function(){return options.groupId}},kind:{get:function(){return options.kind}},label:{get:function(){return options.label}}})}module.exports=MediaDeviceInfoShim},{}],23:[function(require,module,exports){var EventTarget=require("./eventtarget");var inherits=require("util").inherits;var POLL_INTERVAL_MS=500;var nativeMediaDevices=typeof navigator!=="undefined"&&navigator.mediaDevices;function MediaDevicesShim(){EventTarget.call(this);this._defineEventHandler("devicechange");this._defineEventHandler("deviceinfochange");var knownDevices=[];Object.defineProperties(this,{_deviceChangeIsNative:{value:reemitNativeEvent(this,"devicechange")},_deviceInfoChangeIsNative:{value:reemitNativeEvent(this,"deviceinfochange")},_knownDevices:{value:knownDevices},_pollInterval:{value:null,writable:true}});if(typeof nativeMediaDevices.enumerateDevices==="function"){nativeMediaDevices.enumerateDevices().then(function(devices){devices.sort(sortDevicesById).forEach([].push,knownDevices)})}this._eventEmitter.on("newListener",function maybeStartPolling(eventName){if(eventName!=="devicechange"&&eventName!=="deviceinfochange"){return}this._pollInterval=this._pollInterval||setInterval(sampleDevices.bind(null,this),POLL_INTERVAL_MS)}.bind(this));this._eventEmitter.on("removeListener",function maybeStopPolling(){if(this._pollInterval&&!hasChangeListeners(this)){clearInterval(this._pollInterval);this._pollInterval=null}}.bind(this))}inherits(MediaDevicesShim,EventTarget);if(nativeMediaDevices&&typeof nativeMediaDevices.enumerateDevices==="function"){MediaDevicesShim.prototype.enumerateDevices=function enumerateDevices(){return nativeMediaDevices.enumerateDevices.apply(nativeMediaDevices,arguments)}}MediaDevicesShim.prototype.getUserMedia=function getUserMedia(){return nativeMediaDevices.getUserMedia.apply(nativeMediaDevices,arguments)};function deviceInfosHaveChanged(newDevices,oldDevices){var oldLabels=oldDevices.reduce(function(map,device){return map.set(device.deviceId,device.label||null)},new Map);return newDevices.some(function(newDevice){var oldLabel=oldLabels.get(newDevice.deviceId);return typeof oldLabel!=="undefined"&&oldLabel!==newDevice.label})}function devicesHaveChanged(newDevices,oldDevices){return newDevices.length!==oldDevices.length||propertyHasChanged("deviceId",newDevices,oldDevices)}function hasChangeListeners(mediaDevices){return["devicechange","deviceinfochange"].reduce(function(count,event){return count+mediaDevices._eventEmitter.listenerCount(event)},0)>0}function sampleDevices(mediaDevices){nativeMediaDevices.enumerateDevices().then(function(newDevices){var knownDevices=mediaDevices._knownDevices;var oldDevices=knownDevices.slice();[].splice.apply(knownDevices,[0,knownDevices.length].concat(newDevices.sort(sortDevicesById)));if(!mediaDevices._deviceChangeIsNative&&devicesHaveChanged(knownDevices,oldDevices)){mediaDevices.dispatchEvent(new Event("devicechange"))}if(!mediaDevices._deviceInfoChangeIsNative&&deviceInfosHaveChanged(knownDevices,oldDevices)){mediaDevices.dispatchEvent(new Event("deviceinfochange"))}})}function propertyHasChanged(propertyName,as,bs){return as.some(function(a,i){return a[propertyName]!==bs[i][propertyName]})}function reemitNativeEvent(mediaDevices,eventName){var methodName="on"+eventName;function dispatchEvent(event){mediaDevices.dispatchEvent(event)}if(methodName in nativeMediaDevices){if("addEventListener"in nativeMediaDevices){nativeMediaDevices.addEventListener(eventName,dispatchEvent)}else{nativeMediaDevices[methodName]=dispatchEvent}return true}return false}function sortDevicesById(a,b){return a.deviceId0){this._maxDurationTimeout=setTimeout(this.stop.bind(this),this._maxDuration)}var self=this;var playPromise=this._playPromise=Promise.all(this._sinkIds.map(function createAudioElement(sinkId){if(!self._Audio){return Promise.resolve()}var audioElement=new self._Audio(self.url);audioElement.loop=self._shouldLoop;audioElement.addEventListener("ended",function(){self._activeEls.delete(audioElement)});return new Promise(function(resolve){audioElement.addEventListener("canplaythrough",resolve)}).then(function(){if(!self.isPlaying||self._playPromise!==playPromise){return Promise.resolve()}return(self._isSinkSupported?audioElement.setSinkId(sinkId):Promise.resolve()).then(function setSinkIdSuccess(){self._activeEls.add(audioElement);return audioElement.play()}).then(function playSuccess(){return audioElement},function playFailure(reason){self._activeEls.delete(audioElement);throw reason})})}));return playPromise};module.exports=Sound},{AudioPlayer:33}],25:[function(require,module,exports){var inherits=require("util").inherits;function StateMachine(states,initialState){if(!(this instanceof StateMachine)){return new StateMachine(states,initialState)}var currentState=initialState;Object.defineProperties(this,{_currentState:{get:function(){return currentState},set:function(_currentState){currentState=_currentState}},currentState:{enumerable:true,get:function(){return currentState}},states:{enumerable:true,value:states},transitions:{enumerable:true,value:[]}});Object.freeze(this)}StateMachine.prototype.transition=function transition(to){var from=this.currentState;var valid=this.states[from];var newTransition=valid&&valid.indexOf(to)!==-1?new StateTransition(from,to):new InvalidStateTransition(from,to);this.transitions.push(newTransition);this._currentState=to;if(newTransition instanceof InvalidStateTransition){throw newTransition}return this};function StateTransition(from,to){Object.defineProperties(this,{from:{enumerable:true,value:from},to:{enumerable:true,value:to}})}function InvalidStateTransition(from,to){if(!(this instanceof InvalidStateTransition)){return new InvalidStateTransition(from,to)}Error.call(this);StateTransition.call(this,from,to);var errorMessage="Invalid transition from "+(typeof from==="string"?'"'+from+'"':"null")+' to "'+to+'"';Object.defineProperties(this,{message:{enumerable:true,value:errorMessage}});Object.freeze(this)}inherits(InvalidStateTransition,Error);module.exports=StateMachine},{util:55}],26:[function(require,module,exports){exports.SOUNDS_DEPRECATION_WARNING="Device.sounds is deprecated and will be removed in the next breaking "+"release. Please use the new functionality available on Device.audio.";function generateEventWarning(event,name,maxListeners){return"The number of "+event+" listeners on "+name+" exceeds the recommended number of "+maxListeners+". While twilio.js will continue to function normally, this may be indicative of an application error. Note that "+event+" listeners exist for the lifetime of the "+name+"."}exports.generateEventWarning=generateEventWarning},{}],27:[function(require,module,exports){"use strict";Object.defineProperty(exports,"__esModule",{value:true});var LogLevel;(function(LogLevel){LogLevel["Off"]="off";LogLevel["Debug"]="debug";LogLevel["Info"]="info";LogLevel["Warn"]="warn";LogLevel["Error"]="error"})(LogLevel=exports.LogLevel||(exports.LogLevel={}));var logLevelMethods=(_a={},_a[LogLevel.Debug]="info",_a[LogLevel.Info]="info",_a[LogLevel.Warn]="warn",_a[LogLevel.Error]="error",_a);var logLevelRanks=(_b={},_b[LogLevel.Debug]=0,_b[LogLevel.Info]=1,_b[LogLevel.Warn]=2,_b[LogLevel.Error]=3,_b[LogLevel.Off]=4,_b);var Log=function(){function Log(_logLevel,options){this._logLevel=_logLevel;this._console=console;if(options&&options.console){this._console=options.console}}Object.defineProperty(Log.prototype,"logLevel",{get:function(){return this._logLevel},enumerable:true,configurable:true});Log.prototype.debug=function(){var args=[];for(var _i=0;_i0){var padlen=4-remainder;encodedPayload+=new Array(padlen+1).join("=")}encodedPayload=encodedPayload.replace(/-/g,"+").replace(/_/g,"/");var decodedPayload=_atob(encodedPayload);return JSON.parse(decodedPayload)}var memoizedDecodePayload=memoize(decodePayload);function decode(token){var segs=token.split(".");if(segs.length!==3){throw new TwilioException("Wrong number of segments")}var encodedPayload=segs[1];var payload=memoizedDecodePayload(encodedPayload);return payload}function makedict(params){if(params==="")return{};if(params.indexOf("&")===-1&¶ms.indexOf("=")===-1)return params;var pairs=params.split("&");var result={};for(var i=0;i=MAX_LISTENERS){if(typeof console!=="undefined"){if(console.warn){console.warn(warning)}else if(console.log){console.log(warning)}}object.removeListener("newListener",monitor)}}object.on("newListener",monitor)}function deepEqual(a,b){if(a===b){return true}else if(typeof a!==typeof b){return false}else if(a instanceof Date&&b instanceof Date){return a.getTime()===b.getTime()}else if(typeof a!=="object"&&typeof b!=="object"){return a===b}return objectDeepEqual(a,b)}var objectKeys=typeof Object.keys==="function"?Object.keys:function(obj){var keys=[];for(var key in obj){keys.push(key)}return keys};function isUndefinedOrNull(a){return typeof a==="undefined"||a===null}function objectDeepEqual(a,b){if(isUndefinedOrNull(a)||isUndefinedOrNull(b)){return false}else if(a.prototype!==b.prototype){return false}var ka;var kb;try{ka=objectKeys(a);kb=objectKeys(b)}catch(e){return false}if(ka.length!==kb.length){return false}ka.sort();kb.sort();for(var i=ka.length-1;i>=0;i--){var k=ka[i];if(!deepEqual(a[k],b[k])){return false}}return true}function average(values){return values.reduce(function(t,v){return t+v})/values.length}function difference(lefts,rights,getKey){getKey=getKey||function(a){return a};var rightKeys=new Set(rights.map(getKey));return lefts.filter(function(left){return!rightKeys.has(getKey(left))})}function encodescope(service,privilege,params){var capability=["scope",service,privilege].join(":");var empty=true;for(var _ in params){void _;empty=false;break}return empty?capability:capability+"?"+buildquery(params)}function buildquery(params){var pairs=[];for(var name_1 in params){var value=typeof params[name_1]==="object"?buildquery(params[name_1]):params[name_1];pairs.push(encodeURIComponent(name_1)+"="+encodeURIComponent(value))}}function isFirefox(navigator){navigator=navigator||(typeof window==="undefined"?global.navigator:window.navigator);return navigator&&typeof navigator.userAgent==="string"&&/firefox|fxios/i.test(navigator.userAgent)}function isEdge(navigator){navigator=navigator||(typeof window==="undefined"?global.navigator:window.navigator);return navigator&&typeof navigator.userAgent==="string"&&/edge\/\d+/i.test(navigator.userAgent)}exports.getReleaseVersion=getReleaseVersion;exports.getSoundVersion=getSoundVersion;exports.dummyToken=dummyToken;exports.Exception=TwilioException;exports.decode=decode;exports.btoa=_btoa;exports.atob=_atob;exports.objectize=memoizedObjectize;exports.urlencode=urlencode;exports.encodescope=encodescope;exports.Set=Set;exports.bind=bind;exports.getSystemInfo=getSystemInfo;exports.splitObjects=splitObjects;exports.generateConnectionUUID=generateConnectionUUID;exports.getTwilioRoot=getTwilioRoot;exports.monitorEventEmitter=monitorEventEmitter;exports.deepEqual=deepEqual;exports.average=average;exports.difference=difference;exports.isFirefox=isFirefox;exports.isEdge=isEdge}).call(this,typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{},require("buffer").Buffer)},{"../../package.json":56,"./strings":26,buffer:42,events:43}],29:[function(require,module,exports){"use strict";var __extends=this&&this.__extends||function(){var extendStatics=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(d,b){d.__proto__=b}||function(d,b){for(var p in b)if(b.hasOwnProperty(p))d[p]=b[p]};return function(d,b){extendStatics(d,b);function __(){this.constructor=d}d.prototype=b===null?Object.create(b):(__.prototype=b.prototype,new __)}}();Object.defineProperty(exports,"__esModule",{value:true});var events_1=require("events");var WebSocket=require("ws");var tslog_1=require("./tslog");var Backoff=require("backoff");var CONNECT_SUCCESS_TIMEOUT=1e4;var CONNECT_TIMEOUT=5e3;var HEARTBEAT_TIMEOUT=15e3;var WSTransportState;(function(WSTransportState){WSTransportState["Connecting"]="connecting";WSTransportState["Closed"]="closed";WSTransportState["Open"]="open"})(WSTransportState=exports.WSTransportState||(exports.WSTransportState={}));var WSTransport=function(_super){__extends(WSTransport,_super);function WSTransport(uri,options){if(options===void 0){options={}}var _this=_super.call(this)||this;_this.state=WSTransportState.Closed;_this._backoff=Backoff.exponential({factor:1.5,initialDelay:30,maxDelay:3e3,randomisationFactor:.25});_this._onSocketClose=function(){_this._closeSocket()};_this._onSocketError=function(err){_this._log.info("WebSocket received error: "+err.message);_this.emit("error",{code:31e3,message:err.message||"WSTransport socket error"})};_this._onSocketMessage=function(message){_this._setHeartbeatTimeout();if(_this._socket&&message.data==="\n"){_this._socket.send("\n");return}_this.emit("message",message)};_this._onSocketOpen=function(){_this._log.info("WebSocket opened successfully.");_this._timeOpened=Date.now();_this.state=WSTransportState.Open;clearTimeout(_this._connectTimeout);_this._setHeartbeatTimeout();_this.emit("open")};_this._log=new tslog_1.default(options.logLevel||tslog_1.LogLevel.Off);_this._uri=uri;_this._WebSocket=options.WebSocket||WebSocket;_this._backoff.on("backoff",function(_,delay){if(_this.state===WSTransportState.Closed){return}_this._log.info("Will attempt to reconnect WebSocket in "+delay+"ms")});_this._backoff.on("ready",function(attempt){if(_this.state===WSTransportState.Closed){return}_this._connect(attempt+1)});return _this}WSTransport.prototype.close=function(){this._log.info("WSTransport.close() called...");this._close()};WSTransport.prototype.open=function(){this._log.info("WSTransport.open() called...");if(this._socket&&(this._socket.readyState===WebSocket.CONNECTING||this._socket.readyState===WebSocket.OPEN)){this._log.info("WebSocket already open.");return}this._connect()};WSTransport.prototype.send=function(message){if(!this._socket||this._socket.readyState!==WebSocket.OPEN){return false}try{this._socket.send(message)}catch(e){this._log.info("Error while sending message:",e.message);this._closeSocket();return false}return true};WSTransport.prototype._close=function(){this.state=WSTransportState.Closed;this._closeSocket()};WSTransport.prototype._closeSocket=function(){clearTimeout(this._connectTimeout);clearTimeout(this._heartbeatTimeout);this._log.info("Closing and cleaning up WebSocket...");if(!this._socket){this._log.info("No WebSocket to clean up.");return}this._socket.removeEventListener("close",this._onSocketClose);this._socket.removeEventListener("error",this._onSocketError);this._socket.removeEventListener("message",this._onSocketMessage);this._socket.removeEventListener("open",this._onSocketOpen);if(this._socket.readyState===WebSocket.CONNECTING||this._socket.readyState===WebSocket.OPEN){this._socket.close()}if(this._timeOpened&&Date.now()-this._timeOpened>CONNECT_SUCCESS_TIMEOUT){this._backoff.reset()}this._backoff.backoff();delete this._socket;this.emit("close")};WSTransport.prototype._connect=function(retryCount){var _this=this;if(retryCount){this._log.info("Attempting to reconnect (retry #"+retryCount+")...")}else{this._log.info("Attempting to connect...")}this._closeSocket();this.state=WSTransportState.Connecting;var socket=null;try{socket=new this._WebSocket(this._uri)}catch(e){this._log.info("Could not connect to endpoint:",e.message);this._close();this.emit("error",{code:31e3,message:e.message||"Could not connect to "+this._uri});return}delete this._timeOpened;this._connectTimeout=setTimeout(function(){_this._log.info("WebSocket connection attempt timed out.");_this._closeSocket()},CONNECT_TIMEOUT);socket.addEventListener("close",this._onSocketClose);socket.addEventListener("error",this._onSocketError);socket.addEventListener("message",this._onSocketMessage);socket.addEventListener("open",this._onSocketOpen);this._socket=socket};WSTransport.prototype._setHeartbeatTimeout=function(){var _this=this;clearTimeout(this._heartbeatTimeout);this._heartbeatTimeout=setTimeout(function(){_this._log.info("No messages received in "+HEARTBEAT_TIMEOUT/1e3+" seconds. Reconnecting...");_this._closeSocket()},HEARTBEAT_TIMEOUT)};return WSTransport}(events_1.EventEmitter);exports.default=WSTransport},{"./tslog":27,backoff:35,events:43,ws:1}],30:[function(require,module,exports){"use strict";var _regenerator=require("babel-runtime/regenerator");var _regenerator2=_interopRequireDefault(_regenerator);var _createClass=function(){function defineProperties(target,props){for(var i=0;i1&&arguments[1]!==undefined?arguments[1]:{};var options=arguments.length>2&&arguments[2]!==undefined?arguments[2]:{};_classCallCheck(this,AudioPlayer);var _this=_possibleConstructorReturn(this,(AudioPlayer.__proto__||Object.getPrototypeOf(AudioPlayer)).call(this));_this._audioNode=null;_this._pendingPlayDeferreds=[];_this._loop=false;_this._src="";_this._sinkId="default";if(typeof srcOrOptions!=="string"){options=srcOrOptions}_this._audioContext=audioContext;_this._audioElement=new(options.AudioFactory||Audio);_this._bufferPromise=_this._createPlayDeferred().promise;_this._destination=_this._audioContext.destination;_this._gainNode=_this._audioContext.createGain();_this._gainNode.connect(_this._destination);_this._XMLHttpRequest=options.XMLHttpRequestFactory||XMLHttpRequest;_this.addEventListener("canplaythrough",function(){_this._resolvePlayDeferreds()});if(typeof srcOrOptions==="string"){_this.src=srcOrOptions}return _this}_createClass(AudioPlayer,[{key:"load",value:function load(){this._load(this._src)}},{key:"pause",value:function pause(){if(this.paused){return}this._audioElement.pause();this._audioNode.stop();this._audioNode.disconnect(this._gainNode);this._audioNode=null;this._rejectPlayDeferreds(new Error("The play() request was interrupted by a call to pause()."))}},{key:"play",value:function play(){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee(){var _this2=this;var buffer;return _regenerator2.default.wrap(function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:if(this.paused){_context.next=6;break}_context.next=3;return this._bufferPromise;case 3:if(this.paused){_context.next=5;break}return _context.abrupt("return");case 5:throw new Error("The play() request was interrupted by a call to pause().");case 6:this._audioNode=this._audioContext.createBufferSource();this._audioNode.loop=this.loop;this._audioNode.addEventListener("ended",function(){if(_this2._audioNode&&_this2._audioNode.loop){return}_this2.dispatchEvent("ended")});_context.next=11;return this._bufferPromise;case 11:buffer=_context.sent;if(!this.paused){_context.next=14;break}throw new Error("The play() request was interrupted by a call to pause().");case 14:this._audioNode.buffer=buffer;this._audioNode.connect(this._gainNode);this._audioNode.start();if(!this._audioElement.srcObject){_context.next=19;break}return _context.abrupt("return",this._audioElement.play());case 19:case"end":return _context.stop()}}},_callee,this)}))}},{key:"setSinkId",value:function setSinkId(sinkId){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee2(){return _regenerator2.default.wrap(function _callee2$(_context2){while(1){switch(_context2.prev=_context2.next){case 0:if(!(typeof this._audioElement.setSinkId!=="function")){_context2.next=2;break}throw new Error("This browser does not support setSinkId.");case 2:if(!(sinkId===this.sinkId)){_context2.next=4;break}return _context2.abrupt("return");case 4:if(!(sinkId==="default")){_context2.next=11;break}if(!this.paused){this._gainNode.disconnect(this._destination)}this._audioElement.srcObject=null;this._destination=this._audioContext.destination;this._gainNode.connect(this._destination);this._sinkId=sinkId;return _context2.abrupt("return");case 11:_context2.next=13;return this._audioElement.setSinkId(sinkId);case 13:if(!this._audioElement.srcObject){_context2.next=15;break}return _context2.abrupt("return");case 15:this._gainNode.disconnect(this._audioContext.destination);this._destination=this._audioContext.createMediaStreamDestination();this._audioElement.srcObject=this._destination.stream;this._sinkId=sinkId;this._gainNode.connect(this._destination);case 20:case"end":return _context2.stop()}}},_callee2,this)}))}},{key:"_createPlayDeferred",value:function _createPlayDeferred(){var deferred=new Deferred_1.default;this._pendingPlayDeferreds.push(deferred);return deferred}},{key:"_load",value:function _load(src){var _this3=this;if(this._src&&this._src!==src){this.pause()}this._src=src;this._bufferPromise=new Promise(function(resolve,reject){return __awaiter(_this3,void 0,void 0,_regenerator2.default.mark(function _callee3(){var buffer;return _regenerator2.default.wrap(function _callee3$(_context3){while(1){switch(_context3.prev=_context3.next){case 0:if(src){_context3.next=2;break}return _context3.abrupt("return",this._createPlayDeferred().promise);case 2:_context3.next=4;return bufferSound(this._audioContext,this._XMLHttpRequest,src);case 4:buffer=_context3.sent;this.dispatchEvent("canplaythrough");resolve(buffer);case 7:case"end":return _context3.stop()}}},_callee3,this)}))})}},{key:"_rejectPlayDeferreds",value:function _rejectPlayDeferreds(reason){var deferreds=this._pendingPlayDeferreds;deferreds.splice(0,deferreds.length).forEach(function(_ref){var reject=_ref.reject;return reject(reason)})}},{key:"_resolvePlayDeferreds",value:function _resolvePlayDeferreds(result){var deferreds=this._pendingPlayDeferreds;deferreds.splice(0,deferreds.length).forEach(function(_ref2){var resolve=_ref2.resolve;return resolve(result)})}},{key:"destination",get:function get(){return this._destination}},{key:"loop",get:function get(){return this._loop},set:function set(shouldLoop){if(!shouldLoop&&this.loop&&!this.paused){var _pauseAfterPlaythrough=function _pauseAfterPlaythrough(){self._audioNode.removeEventListener("ended",_pauseAfterPlaythrough);self.pause()};var self=this;this._audioNode.addEventListener("ended",_pauseAfterPlaythrough)}this._loop=shouldLoop}},{key:"muted",get:function get(){return this._gainNode.gain.value===0},set:function set(shouldBeMuted){this._gainNode.gain.value=shouldBeMuted?0:1}},{key:"paused",get:function get(){return this._audioNode===null}},{key:"src",get:function get(){return this._src},set:function set(src){this._load(src)}},{key:"sinkId",get:function get(){return this._sinkId}}]);return AudioPlayer}(EventTarget_1.default);exports.default=AudioPlayer;function bufferSound(context,RequestFactory,src){return __awaiter(this,void 0,void 0,_regenerator2.default.mark(function _callee4(){var request,event;return _regenerator2.default.wrap(function _callee4$(_context4){while(1){switch(_context4.prev=_context4.next){case 0:request=new RequestFactory;request.open("GET",src,true);request.responseType="arraybuffer";_context4.next=5;return new Promise(function(resolve){request.addEventListener("load",resolve);request.send()});case 5:event=_context4.sent;_context4.prev=6;return _context4.abrupt("return",context.decodeAudioData(event.target.response));case 10:_context4.prev=10;_context4.t0=_context4["catch"](6);return _context4.abrupt("return",new Promise(function(resolve){context.decodeAudioData(event.target.response,resolve)}));case 13:case"end":return _context4.stop()}}},_callee4,this,[[6,10]])}))}},{"./Deferred":31,"./EventTarget":32,"babel-runtime/regenerator":34}],31:[function(require,module,exports){"use strict";var _createClass=function(){function defineProperties(target,props){for(var i=0;i1?_len-1:0),_key=1;_key<_len;_key++){args[_key-1]=arguments[_key]}return(_eventEmitter=this._eventEmitter).emit.apply(_eventEmitter,[name].concat(args))}},{key:"removeEventListener",value:function removeEventListener(name,handler){return this._eventEmitter.removeListener(name,handler)}}]);return EventTarget}();exports.default=EventTarget},{events:43}],33:[function(require,module,exports){"use strict";var AudioPlayer=require("./AudioPlayer");module.exports=AudioPlayer.default},{"./AudioPlayer":30}],34:[function(require,module,exports){module.exports=require("regenerator-runtime")},{"regenerator-runtime":49}],35:[function(require,module,exports){var Backoff=require("./lib/backoff");var ExponentialBackoffStrategy=require("./lib/strategy/exponential");var FibonacciBackoffStrategy=require("./lib/strategy/fibonacci");var FunctionCall=require("./lib/function_call.js");module.exports.Backoff=Backoff;module.exports.FunctionCall=FunctionCall;module.exports.FibonacciStrategy=FibonacciBackoffStrategy;module.exports.ExponentialStrategy=ExponentialBackoffStrategy;module.exports.fibonacci=function(options){return new Backoff(new FibonacciBackoffStrategy(options))};module.exports.exponential=function(options){return new Backoff(new ExponentialBackoffStrategy(options))};module.exports.call=function(fn,vargs,callback){var args=Array.prototype.slice.call(arguments);fn=args[0];vargs=args.slice(1,args.length-1);callback=args[args.length-1];return new FunctionCall(fn,vargs,callback)}},{"./lib/backoff":36,"./lib/function_call.js":37,"./lib/strategy/exponential":38,"./lib/strategy/fibonacci":39}],36:[function(require,module,exports){var events=require("events");var precond=require("precond");var util=require("util");function Backoff(backoffStrategy){events.EventEmitter.call(this);this.backoffStrategy_=backoffStrategy;this.maxNumberOfRetry_=-1;this.backoffNumber_=0;this.backoffDelay_=0;this.timeoutID_=-1;this.handlers={backoff:this.onBackoff_.bind(this)}}util.inherits(Backoff,events.EventEmitter);Backoff.prototype.failAfter=function(maxNumberOfRetry){precond.checkArgument(maxNumberOfRetry>0,"Expected a maximum number of retry greater than 0 but got %s.",maxNumberOfRetry);this.maxNumberOfRetry_=maxNumberOfRetry};Backoff.prototype.backoff=function(err){precond.checkState(this.timeoutID_===-1,"Backoff in progress.");if(this.backoffNumber_===this.maxNumberOfRetry_){this.emit("fail",err);this.reset()}else{this.backoffDelay_=this.backoffStrategy_.next();this.timeoutID_=setTimeout(this.handlers.backoff,this.backoffDelay_);this.emit("backoff",this.backoffNumber_,this.backoffDelay_,err)}};Backoff.prototype.onBackoff_=function(){this.timeoutID_=-1;this.emit("ready",this.backoffNumber_,this.backoffDelay_);this.backoffNumber_++};Backoff.prototype.reset=function(){this.backoffNumber_=0;this.backoffStrategy_.reset();clearTimeout(this.timeoutID_);this.timeoutID_=-1};module.exports=Backoff},{events:43,precond:45,util:55}],37:[function(require,module,exports){var events=require("events");var precond=require("precond");var util=require("util");var Backoff=require("./backoff");var FibonacciBackoffStrategy=require("./strategy/fibonacci");function FunctionCall(fn,args,callback){events.EventEmitter.call(this);precond.checkIsFunction(fn,"Expected fn to be a function.");precond.checkIsArray(args,"Expected args to be an array.");precond.checkIsFunction(callback,"Expected callback to be a function.");this.function_=fn;this.arguments_=args;this.callback_=callback;this.lastResult_=[];this.numRetries_=0;this.backoff_=null;this.strategy_=null;this.failAfter_=-1;this.retryPredicate_=FunctionCall.DEFAULT_RETRY_PREDICATE_;this.state_=FunctionCall.State_.PENDING}util.inherits(FunctionCall,events.EventEmitter);FunctionCall.State_={PENDING:0,RUNNING:1,COMPLETED:2,ABORTED:3};FunctionCall.DEFAULT_RETRY_PREDICATE_=function(err){return true};FunctionCall.prototype.isPending=function(){return this.state_==FunctionCall.State_.PENDING};FunctionCall.prototype.isRunning=function(){return this.state_==FunctionCall.State_.RUNNING};FunctionCall.prototype.isCompleted=function(){return this.state_==FunctionCall.State_.COMPLETED};FunctionCall.prototype.isAborted=function(){return this.state_==FunctionCall.State_.ABORTED};FunctionCall.prototype.setStrategy=function(strategy){precond.checkState(this.isPending(),"FunctionCall in progress.");this.strategy_=strategy;return this};FunctionCall.prototype.retryIf=function(retryPredicate){precond.checkState(this.isPending(),"FunctionCall in progress.");this.retryPredicate_=retryPredicate;return this};FunctionCall.prototype.getLastResult=function(){return this.lastResult_.concat()};FunctionCall.prototype.getNumRetries=function(){return this.numRetries_};FunctionCall.prototype.failAfter=function(maxNumberOfRetry){precond.checkState(this.isPending(),"FunctionCall in progress.");this.failAfter_=maxNumberOfRetry;return this};FunctionCall.prototype.abort=function(){if(this.isCompleted()||this.isAborted()){return}if(this.isRunning()){this.backoff_.reset()}this.state_=FunctionCall.State_.ABORTED;this.lastResult_=[new Error("Backoff aborted.")];this.emit("abort");this.doCallback_()};FunctionCall.prototype.start=function(backoffFactory){precond.checkState(!this.isAborted(),"FunctionCall is aborted.");precond.checkState(this.isPending(),"FunctionCall already started.");var strategy=this.strategy_||new FibonacciBackoffStrategy;this.backoff_=backoffFactory?backoffFactory(strategy):new Backoff(strategy);this.backoff_.on("ready",this.doCall_.bind(this,true));this.backoff_.on("fail",this.doCallback_.bind(this));this.backoff_.on("backoff",this.handleBackoff_.bind(this));if(this.failAfter_>0){this.backoff_.failAfter(this.failAfter_)}this.state_=FunctionCall.State_.RUNNING;this.doCall_(false)};FunctionCall.prototype.doCall_=function(isRetry){if(isRetry){this.numRetries_++}var eventArgs=["call"].concat(this.arguments_);events.EventEmitter.prototype.emit.apply(this,eventArgs);var callback=this.handleFunctionCallback_.bind(this);this.function_.apply(null,this.arguments_.concat(callback))};FunctionCall.prototype.doCallback_=function(){this.callback_.apply(null,this.lastResult_)};FunctionCall.prototype.handleFunctionCallback_=function(){if(this.isAborted()){return}var args=Array.prototype.slice.call(arguments);this.lastResult_=args;events.EventEmitter.prototype.emit.apply(this,["callback"].concat(args));var err=args[0];if(err&&this.retryPredicate_(err)){this.backoff_.backoff(err)}else{this.state_=FunctionCall.State_.COMPLETED;this.doCallback_()}};FunctionCall.prototype.handleBackoff_=function(number,delay,err){this.emit("backoff",number,delay,err)};module.exports=FunctionCall},{"./backoff":36,"./strategy/fibonacci":39,events:43,precond:45,util:55}],38:[function(require,module,exports){var util=require("util");var precond=require("precond");var BackoffStrategy=require("./strategy");function ExponentialBackoffStrategy(options){BackoffStrategy.call(this,options);this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay();this.factor_=ExponentialBackoffStrategy.DEFAULT_FACTOR;if(options&&options.factor!==undefined){precond.checkArgument(options.factor>1,"Exponential factor should be greater than 1 but got %s.",options.factor);this.factor_=options.factor}}util.inherits(ExponentialBackoffStrategy,BackoffStrategy);ExponentialBackoffStrategy.DEFAULT_FACTOR=2;ExponentialBackoffStrategy.prototype.next_=function(){this.backoffDelay_=Math.min(this.nextBackoffDelay_,this.getMaxDelay());this.nextBackoffDelay_=this.backoffDelay_*this.factor_;return this.backoffDelay_};ExponentialBackoffStrategy.prototype.reset_=function(){this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay()};module.exports=ExponentialBackoffStrategy},{"./strategy":40,precond:45,util:55}],39:[function(require,module,exports){var util=require("util");var BackoffStrategy=require("./strategy");function FibonacciBackoffStrategy(options){BackoffStrategy.call(this,options);this.backoffDelay_=0;this.nextBackoffDelay_=this.getInitialDelay()}util.inherits(FibonacciBackoffStrategy,BackoffStrategy);FibonacciBackoffStrategy.prototype.next_=function(){var backoffDelay=Math.min(this.nextBackoffDelay_,this.getMaxDelay());this.nextBackoffDelay_+=this.backoffDelay_;this.backoffDelay_=backoffDelay;return backoffDelay};FibonacciBackoffStrategy.prototype.reset_=function(){this.nextBackoffDelay_=this.getInitialDelay();this.backoffDelay_=0};module.exports=FibonacciBackoffStrategy},{"./strategy":40,util:55}],40:[function(require,module,exports){var events=require("events");var util=require("util");function isDef(value){return value!==undefined&&value!==null}function BackoffStrategy(options){options=options||{};if(isDef(options.initialDelay)&&options.initialDelay<1){throw new Error("The initial timeout must be greater than 0.")}else if(isDef(options.maxDelay)&&options.maxDelay<1){throw new Error("The maximal timeout must be greater than 0.")}this.initialDelay_=options.initialDelay||100;this.maxDelay_=options.maxDelay||1e4;if(this.maxDelay_<=this.initialDelay_){throw new Error("The maximal backoff delay must be "+"greater than the initial backoff delay.")}if(isDef(options.randomisationFactor)&&(options.randomisationFactor<0||options.randomisationFactor>1)){throw new Error("The randomisation factor must be between 0 and 1.")}this.randomisationFactor_=options.randomisationFactor||0}BackoffStrategy.prototype.getMaxDelay=function(){return this.maxDelay_};BackoffStrategy.prototype.getInitialDelay=function(){return this.initialDelay_};BackoffStrategy.prototype.next=function(){var backoffDelay=this.next_();var randomisationMultiple=1+Math.random()*this.randomisationFactor_;var randomizedDelay=Math.round(backoffDelay*randomisationMultiple);return randomizedDelay};BackoffStrategy.prototype.next_=function(){throw new Error("BackoffStrategy.next_() unimplemented.")};BackoffStrategy.prototype.reset=function(){this.reset_()};BackoffStrategy.prototype.reset_=function(){throw new Error("BackoffStrategy.reset_() unimplemented.")};module.exports=BackoffStrategy},{events:43,util:55}],41:[function(require,module,exports){"use strict";exports.byteLength=byteLength;exports.toByteArray=toByteArray;exports.fromByteArray=fromByteArray;var lookup=[];var revLookup=[];var Arr=typeof Uint8Array!=="undefined"?Uint8Array:Array;var code="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";for(var i=0,len=code.length;i0){throw new Error("Invalid string. Length must be a multiple of 4")}var validLen=b64.indexOf("=");if(validLen===-1)validLen=len;var placeHoldersLen=validLen===len?0:4-validLen%4;return[validLen,placeHoldersLen]}function byteLength(b64){var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function _byteLength(b64,validLen,placeHoldersLen){return(validLen+placeHoldersLen)*3/4-placeHoldersLen}function toByteArray(b64){var tmp;var lens=getLens(b64);var validLen=lens[0];var placeHoldersLen=lens[1];var arr=new Arr(_byteLength(b64,validLen,placeHoldersLen));var curByte=0;var len=placeHoldersLen>0?validLen-4:validLen;for(var i=0;i>16&255;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}if(placeHoldersLen===2){tmp=revLookup[b64.charCodeAt(i)]<<2|revLookup[b64.charCodeAt(i+1)]>>4;arr[curByte++]=tmp&255}if(placeHoldersLen===1){tmp=revLookup[b64.charCodeAt(i)]<<10|revLookup[b64.charCodeAt(i+1)]<<4|revLookup[b64.charCodeAt(i+2)]>>2;arr[curByte++]=tmp>>8&255;arr[curByte++]=tmp&255}return arr}function tripletToBase64(num){return lookup[num>>18&63]+lookup[num>>12&63]+lookup[num>>6&63]+lookup[num&63]}function encodeChunk(uint8,start,end){var tmp;var output=[];for(var i=start;ilen2?len2:i+maxChunkLength))}if(extraBytes===1){tmp=uint8[len-1];parts.push(lookup[tmp>>2]+lookup[tmp<<4&63]+"==")}else if(extraBytes===2){tmp=(uint8[len-2]<<8)+uint8[len-1];parts.push(lookup[tmp>>10]+lookup[tmp>>4&63]+lookup[tmp<<2&63]+"=")}return parts.join("")}},{}],42:[function(require,module,exports){"use strict";var base64=require("base64-js");var ieee754=require("ieee754");exports.Buffer=Buffer;exports.SlowBuffer=SlowBuffer;exports.INSPECT_MAX_BYTES=50;var K_MAX_LENGTH=2147483647;exports.kMaxLength=K_MAX_LENGTH;Buffer.TYPED_ARRAY_SUPPORT=typedArraySupport();if(!Buffer.TYPED_ARRAY_SUPPORT&&typeof console!=="undefined"&&typeof console.error==="function"){console.error("This browser lacks typed array (Uint8Array) support which is required by "+"`buffer` v5.x. Use `buffer` v4.x if you require old browser support.")}function typedArraySupport(){try{var arr=new Uint8Array(1);arr.__proto__={__proto__:Uint8Array.prototype,foo:function(){return 42}};return arr.foo()===42}catch(e){return false}}Object.defineProperty(Buffer.prototype,"parent",{get:function(){if(!(this instanceof Buffer)){return undefined}return this.buffer}});Object.defineProperty(Buffer.prototype,"offset",{get:function(){if(!(this instanceof Buffer)){return undefined}return this.byteOffset}});function createBuffer(length){if(length>K_MAX_LENGTH){throw new RangeError("Invalid typed array length")}var buf=new Uint8Array(length);buf.__proto__=Buffer.prototype;return buf}function Buffer(arg,encodingOrOffset,length){if(typeof arg==="number"){if(typeof encodingOrOffset==="string"){throw new Error("If encoding is specified then the first argument must be a string")}return allocUnsafe(arg)}return from(arg,encodingOrOffset,length)}if(typeof Symbol!=="undefined"&&Symbol.species&&Buffer[Symbol.species]===Buffer){Object.defineProperty(Buffer,Symbol.species,{value:null,configurable:true,enumerable:false,writable:false})}Buffer.poolSize=8192;function from(value,encodingOrOffset,length){if(typeof value==="number"){throw new TypeError('"value" argument must not be a number')}if(isArrayBuffer(value)||value&&isArrayBuffer(value.buffer)){return fromArrayBuffer(value,encodingOrOffset,length)}if(typeof value==="string"){return fromString(value,encodingOrOffset)}return fromObject(value)}Buffer.from=function(value,encodingOrOffset,length){return from(value,encodingOrOffset,length)};Buffer.prototype.__proto__=Uint8Array.prototype;Buffer.__proto__=Uint8Array;function assertSize(size){if(typeof size!=="number"){throw new TypeError('"size" argument must be of type number')}else if(size<0){throw new RangeError('"size" argument must not be negative')}}function alloc(size,fill,encoding){assertSize(size);if(size<=0){return createBuffer(size)}if(fill!==undefined){return typeof encoding==="string"?createBuffer(size).fill(fill,encoding):createBuffer(size).fill(fill)}return createBuffer(size)}Buffer.alloc=function(size,fill,encoding){return alloc(size,fill,encoding)};function allocUnsafe(size){assertSize(size);return createBuffer(size<0?0:checked(size)|0)}Buffer.allocUnsafe=function(size){return allocUnsafe(size)};Buffer.allocUnsafeSlow=function(size){return allocUnsafe(size)};function fromString(string,encoding){if(typeof encoding!=="string"||encoding===""){encoding="utf8"}if(!Buffer.isEncoding(encoding)){throw new TypeError("Unknown encoding: "+encoding)}var length=byteLength(string,encoding)|0;var buf=createBuffer(length);var actual=buf.write(string,encoding);if(actual!==length){buf=buf.slice(0,actual)}return buf}function fromArrayLike(array){var length=array.length<0?0:checked(array.length)|0;var buf=createBuffer(length);for(var i=0;i=K_MAX_LENGTH){throw new RangeError("Attempt to allocate Buffer larger than maximum "+"size: 0x"+K_MAX_LENGTH.toString(16)+" bytes")}return length|0}function SlowBuffer(length){if(+length!=length){length=0}return Buffer.alloc(+length)}Buffer.isBuffer=function isBuffer(b){return b!=null&&b._isBuffer===true};Buffer.compare=function compare(a,b){if(!Buffer.isBuffer(a)||!Buffer.isBuffer(b)){throw new TypeError("Arguments must be Buffers")}if(a===b)return 0;var x=a.length;var y=b.length;for(var i=0,len=Math.min(x,y);i>>1;case"base64":return base64ToBytes(string).length;default:if(loweredCase)return utf8ToBytes(string).length;encoding=(""+encoding).toLowerCase();loweredCase=true}}}Buffer.byteLength=byteLength;function slowToString(encoding,start,end){var loweredCase=false;if(start===undefined||start<0){start=0}if(start>this.length){return""}if(end===undefined||end>this.length){end=this.length}if(end<=0){return""}end>>>=0;start>>>=0;if(end<=start){return""}if(!encoding)encoding="utf8";while(true){switch(encoding){case"hex":return hexSlice(this,start,end);case"utf8":case"utf-8":return utf8Slice(this,start,end);case"ascii":return asciiSlice(this,start,end);case"latin1":case"binary":return latin1Slice(this,start,end);case"base64":return base64Slice(this,start,end);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return utf16leSlice(this,start,end);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(encoding+"").toLowerCase();loweredCase=true}}}Buffer.prototype._isBuffer=true;function swap(b,n,m){var i=b[n];b[n]=b[m];b[m]=i}Buffer.prototype.swap16=function swap16(){var len=this.length;if(len%2!==0){throw new RangeError("Buffer size must be a multiple of 16-bits")}for(var i=0;i0){str=this.toString("hex",0,max).match(/.{2}/g).join(" ");if(this.length>max)str+=" ... "}return""};Buffer.prototype.compare=function compare(target,start,end,thisStart,thisEnd){if(!Buffer.isBuffer(target)){throw new TypeError("Argument must be a Buffer")}if(start===undefined){start=0}if(end===undefined){end=target?target.length:0}if(thisStart===undefined){thisStart=0}if(thisEnd===undefined){thisEnd=this.length}if(start<0||end>target.length||thisStart<0||thisEnd>this.length){throw new RangeError("out of range index")}if(thisStart>=thisEnd&&start>=end){return 0}if(thisStart>=thisEnd){return-1}if(start>=end){return 1}start>>>=0;end>>>=0;thisStart>>>=0;thisEnd>>>=0;if(this===target)return 0;var x=thisEnd-thisStart;var y=end-start;var len=Math.min(x,y);var thisCopy=this.slice(thisStart,thisEnd);var targetCopy=target.slice(start,end);for(var i=0;i2147483647){byteOffset=2147483647}else if(byteOffset<-2147483648){byteOffset=-2147483648}byteOffset=+byteOffset;if(numberIsNaN(byteOffset)){byteOffset=dir?0:buffer.length-1}if(byteOffset<0)byteOffset=buffer.length+byteOffset;if(byteOffset>=buffer.length){if(dir)return-1;else byteOffset=buffer.length-1}else if(byteOffset<0){if(dir)byteOffset=0;else return-1}if(typeof val==="string"){val=Buffer.from(val,encoding)}if(Buffer.isBuffer(val)){if(val.length===0){return-1}return arrayIndexOf(buffer,val,byteOffset,encoding,dir)}else if(typeof val==="number"){val=val&255;if(typeof Uint8Array.prototype.indexOf==="function"){if(dir){return Uint8Array.prototype.indexOf.call(buffer,val,byteOffset)}else{return Uint8Array.prototype.lastIndexOf.call(buffer,val,byteOffset)}}return arrayIndexOf(buffer,[val],byteOffset,encoding,dir)}throw new TypeError("val must be string, number or Buffer")}function arrayIndexOf(arr,val,byteOffset,encoding,dir){var indexSize=1;var arrLength=arr.length;var valLength=val.length;if(encoding!==undefined){encoding=String(encoding).toLowerCase();if(encoding==="ucs2"||encoding==="ucs-2"||encoding==="utf16le"||encoding==="utf-16le"){if(arr.length<2||val.length<2){return-1}indexSize=2;arrLength/=2;valLength/=2;byteOffset/=2}}function read(buf,i){if(indexSize===1){return buf[i]}else{return buf.readUInt16BE(i*indexSize)}}var i;if(dir){var foundIndex=-1;for(i=byteOffset;iarrLength)byteOffset=arrLength-valLength;for(i=byteOffset;i>=0;i--){var found=true;for(var j=0;jremaining){length=remaining}}var strLen=string.length;if(length>strLen/2){length=strLen/2}for(var i=0;i>>0;if(isFinite(length)){length=length>>>0;if(encoding===undefined)encoding="utf8"}else{encoding=length;length=undefined}}else{throw new Error("Buffer.write(string, encoding, offset[, length]) is no longer supported")}var remaining=this.length-offset;if(length===undefined||length>remaining)length=remaining;if(string.length>0&&(length<0||offset<0)||offset>this.length){throw new RangeError("Attempt to write outside buffer bounds")}if(!encoding)encoding="utf8";var loweredCase=false;for(;;){switch(encoding){case"hex":return hexWrite(this,string,offset,length);case"utf8":case"utf-8":return utf8Write(this,string,offset,length);case"ascii":return asciiWrite(this,string,offset,length);case"latin1":case"binary":return latin1Write(this,string,offset,length);case"base64":return base64Write(this,string,offset,length);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return ucs2Write(this,string,offset,length);default:if(loweredCase)throw new TypeError("Unknown encoding: "+encoding);encoding=(""+encoding).toLowerCase();loweredCase=true}}};Buffer.prototype.toJSON=function toJSON(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};function base64Slice(buf,start,end){if(start===0&&end===buf.length){return base64.fromByteArray(buf)}else{return base64.fromByteArray(buf.slice(start,end))}}function utf8Slice(buf,start,end){end=Math.min(buf.length,end);var res=[];var i=start;while(i239?4:firstByte>223?3:firstByte>191?2:1;if(i+bytesPerSequence<=end){var secondByte,thirdByte,fourthByte,tempCodePoint;switch(bytesPerSequence){case 1:if(firstByte<128){codePoint=firstByte}break;case 2:secondByte=buf[i+1];if((secondByte&192)===128){tempCodePoint=(firstByte&31)<<6|secondByte&63;if(tempCodePoint>127){codePoint=tempCodePoint}}break;case 3:secondByte=buf[i+1];thirdByte=buf[i+2];if((secondByte&192)===128&&(thirdByte&192)===128){tempCodePoint=(firstByte&15)<<12|(secondByte&63)<<6|thirdByte&63;if(tempCodePoint>2047&&(tempCodePoint<55296||tempCodePoint>57343)){codePoint=tempCodePoint}}break;case 4:secondByte=buf[i+1];thirdByte=buf[i+2];fourthByte=buf[i+3];if((secondByte&192)===128&&(thirdByte&192)===128&&(fourthByte&192)===128){tempCodePoint=(firstByte&15)<<18|(secondByte&63)<<12|(thirdByte&63)<<6|fourthByte&63;if(tempCodePoint>65535&&tempCodePoint<1114112){codePoint=tempCodePoint}}}}if(codePoint===null){codePoint=65533;bytesPerSequence=1}else if(codePoint>65535){codePoint-=65536;res.push(codePoint>>>10&1023|55296);codePoint=56320|codePoint&1023}res.push(codePoint);i+=bytesPerSequence}return decodeCodePointsArray(res)}var MAX_ARGUMENTS_LENGTH=4096;function decodeCodePointsArray(codePoints){var len=codePoints.length;if(len<=MAX_ARGUMENTS_LENGTH){return String.fromCharCode.apply(String,codePoints)}var res="";var i=0;while(ilen)end=len;var out="";for(var i=start;ilen){start=len}if(end<0){end+=len;if(end<0)end=0}else if(end>len){end=len}if(endlength)throw new RangeError("Trying to access beyond buffer length")}Buffer.prototype.readUIntLE=function readUIntLE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var val=this[offset];var mul=1;var i=0;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){checkOffset(offset,byteLength,this.length)}var val=this[offset+--byteLength];var mul=1;while(byteLength>0&&(mul*=256)){val+=this[offset+--byteLength]*mul}return val};Buffer.prototype.readUInt8=function readUInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);return this[offset]};Buffer.prototype.readUInt16LE=function readUInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]|this[offset+1]<<8};Buffer.prototype.readUInt16BE=function readUInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);return this[offset]<<8|this[offset+1]};Buffer.prototype.readUInt32LE=function readUInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+this[offset+3]*16777216};Buffer.prototype.readUInt32BE=function readUInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]*16777216+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])};Buffer.prototype.readIntLE=function readIntLE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var val=this[offset];var mul=1;var i=0;while(++i=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readIntBE=function readIntBE(offset,byteLength,noAssert){offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert)checkOffset(offset,byteLength,this.length);var i=byteLength;var mul=1;var val=this[offset+--i];while(i>0&&(mul*=256)){val+=this[offset+--i]*mul}mul*=128;if(val>=mul)val-=Math.pow(2,8*byteLength);return val};Buffer.prototype.readInt8=function readInt8(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,1,this.length);if(!(this[offset]&128))return this[offset];return(255-this[offset]+1)*-1};Buffer.prototype.readInt16LE=function readInt16LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);var val=this[offset]|this[offset+1]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt16BE=function readInt16BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,2,this.length);var val=this[offset+1]|this[offset]<<8;return val&32768?val|4294901760:val};Buffer.prototype.readInt32LE=function readInt32LE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24};Buffer.prototype.readInt32BE=function readInt32BE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]};Buffer.prototype.readFloatLE=function readFloatLE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,true,23,4)};Buffer.prototype.readFloatBE=function readFloatBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,4,this.length);return ieee754.read(this,offset,false,23,4)};Buffer.prototype.readDoubleLE=function readDoubleLE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,true,52,8)};Buffer.prototype.readDoubleBE=function readDoubleBE(offset,noAssert){offset=offset>>>0;if(!noAssert)checkOffset(offset,8,this.length);return ieee754.read(this,offset,false,52,8)};function checkInt(buf,value,offset,ext,max,min){if(!Buffer.isBuffer(buf))throw new TypeError('"buffer" argument must be a Buffer instance');if(value>max||valuebuf.length)throw new RangeError("Index out of range")}Buffer.prototype.writeUIntLE=function writeUIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;byteLength=byteLength>>>0;if(!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var mul=1;var i=0;this[offset]=value&255;while(++i>>0;byteLength=byteLength>>>0;if(!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var i=byteLength-1;var mul=1;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){this[offset+i]=value/mul&255}return offset+byteLength};Buffer.prototype.writeUInt8=function writeUInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,255,0);this[offset]=value&255;return offset+1};Buffer.prototype.writeUInt16LE=function writeUInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeUInt16BE=function writeUInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,65535,0);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeUInt32LE=function writeUInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset+3]=value>>>24;this[offset+2]=value>>>16;this[offset+1]=value>>>8;this[offset]=value&255;return offset+4};Buffer.prototype.writeUInt32BE=function writeUInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,4294967295,0);this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};Buffer.prototype.writeIntLE=function writeIntLE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=0;var mul=1;var sub=0;this[offset]=value&255;while(++i>0)-sub&255}return offset+byteLength};Buffer.prototype.writeIntBE=function writeIntBE(value,offset,byteLength,noAssert){value=+value;offset=offset>>>0;if(!noAssert){var limit=Math.pow(2,8*byteLength-1);checkInt(this,value,offset,byteLength,limit-1,-limit)}var i=byteLength-1;var mul=1;var sub=0;this[offset+i]=value&255;while(--i>=0&&(mul*=256)){if(value<0&&sub===0&&this[offset+i+1]!==0){sub=1}this[offset+i]=(value/mul>>0)-sub&255}return offset+byteLength};Buffer.prototype.writeInt8=function writeInt8(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,1,127,-128);if(value<0)value=255+value+1;this[offset]=value&255;return offset+1};Buffer.prototype.writeInt16LE=function writeInt16LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value&255;this[offset+1]=value>>>8;return offset+2};Buffer.prototype.writeInt16BE=function writeInt16BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,2,32767,-32768);this[offset]=value>>>8;this[offset+1]=value&255;return offset+2};Buffer.prototype.writeInt32LE=function writeInt32LE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);this[offset]=value&255;this[offset+1]=value>>>8;this[offset+2]=value>>>16;this[offset+3]=value>>>24;return offset+4};Buffer.prototype.writeInt32BE=function writeInt32BE(value,offset,noAssert){value=+value;offset=offset>>>0;if(!noAssert)checkInt(this,value,offset,4,2147483647,-2147483648);if(value<0)value=4294967295+value+1;this[offset]=value>>>24;this[offset+1]=value>>>16;this[offset+2]=value>>>8;this[offset+3]=value&255;return offset+4};function checkIEEE754(buf,value,offset,ext,max,min){if(offset+ext>buf.length)throw new RangeError("Index out of range");if(offset<0)throw new RangeError("Index out of range")}function writeFloat(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,4,3.4028234663852886e38,-3.4028234663852886e38)}ieee754.write(buf,value,offset,littleEndian,23,4);return offset+4}Buffer.prototype.writeFloatLE=function writeFloatLE(value,offset,noAssert){return writeFloat(this,value,offset,true,noAssert)};Buffer.prototype.writeFloatBE=function writeFloatBE(value,offset,noAssert){return writeFloat(this,value,offset,false,noAssert)};function writeDouble(buf,value,offset,littleEndian,noAssert){value=+value;offset=offset>>>0;if(!noAssert){checkIEEE754(buf,value,offset,8,1.7976931348623157e308,-1.7976931348623157e308)}ieee754.write(buf,value,offset,littleEndian,52,8);return offset+8}Buffer.prototype.writeDoubleLE=function writeDoubleLE(value,offset,noAssert){return writeDouble(this,value,offset,true,noAssert)};Buffer.prototype.writeDoubleBE=function writeDoubleBE(value,offset,noAssert){return writeDouble(this,value,offset,false,noAssert)};Buffer.prototype.copy=function copy(target,targetStart,start,end){if(!Buffer.isBuffer(target))throw new TypeError("argument should be a Buffer");if(!start)start=0;if(!end&&end!==0)end=this.length;if(targetStart>=target.length)targetStart=target.length;if(!targetStart)targetStart=0;if(end>0&&end=this.length)throw new RangeError("Index out of range");if(end<0)throw new RangeError("sourceEnd out of bounds");if(end>this.length)end=this.length;if(target.length-targetStart=0;--i){target[i+targetStart]=this[i+start]}}else{Uint8Array.prototype.set.call(target,this.subarray(start,end),targetStart)}return len};Buffer.prototype.fill=function fill(val,start,end,encoding){if(typeof val==="string"){if(typeof start==="string"){encoding=start;start=0;end=this.length}else if(typeof end==="string"){encoding=end;end=this.length}if(encoding!==undefined&&typeof encoding!=="string"){throw new TypeError("encoding must be a string")}if(typeof encoding==="string"&&!Buffer.isEncoding(encoding)){throw new TypeError("Unknown encoding: "+encoding)}if(val.length===1){var code=val.charCodeAt(0);if(encoding==="utf8"&&code<128||encoding==="latin1"){val=code}}}else if(typeof val==="number"){val=val&255}if(start<0||this.length>>0;end=end===undefined?this.length:end>>>0;if(!val)val=0;var i;if(typeof val==="number"){for(i=start;i55295&&codePoint<57344){if(!leadSurrogate){if(codePoint>56319){if((units-=3)>-1)bytes.push(239,191,189);continue}else if(i+1===length){if((units-=3)>-1)bytes.push(239,191,189);continue}leadSurrogate=codePoint;continue}if(codePoint<56320){if((units-=3)>-1)bytes.push(239,191,189);leadSurrogate=codePoint;continue}codePoint=(leadSurrogate-55296<<10|codePoint-56320)+65536}else if(leadSurrogate){if((units-=3)>-1)bytes.push(239,191,189)}leadSurrogate=null;if(codePoint<128){if((units-=1)<0)break;bytes.push(codePoint)}else if(codePoint<2048){if((units-=2)<0)break;bytes.push(codePoint>>6|192,codePoint&63|128)}else if(codePoint<65536){if((units-=3)<0)break;bytes.push(codePoint>>12|224,codePoint>>6&63|128,codePoint&63|128)}else if(codePoint<1114112){if((units-=4)<0)break;bytes.push(codePoint>>18|240,codePoint>>12&63|128,codePoint>>6&63|128,codePoint&63|128)}else{throw new Error("Invalid code point")}}return bytes}function asciiToBytes(str){var byteArray=[];for(var i=0;i>8;lo=c%256;byteArray.push(lo);byteArray.push(hi)}return byteArray}function base64ToBytes(str){return base64.toByteArray(base64clean(str))}function blitBuffer(src,dst,offset,length){for(var i=0;i=dst.length||i>=src.length)break;dst[i+offset]=src[i]}return i}function isArrayBuffer(obj){return obj instanceof ArrayBuffer||obj!=null&&obj.constructor!=null&&obj.constructor.name==="ArrayBuffer"&&typeof obj.byteLength==="number"}function numberIsNaN(obj){return obj!==obj}},{"base64-js":41,ieee754:44}],43:[function(require,module,exports){function EventEmitter(){this._events=this._events||{};this._maxListeners=this._maxListeners||undefined}module.exports=EventEmitter;EventEmitter.EventEmitter=EventEmitter;EventEmitter.prototype._events=undefined;EventEmitter.prototype._maxListeners=undefined;EventEmitter.defaultMaxListeners=10;EventEmitter.prototype.setMaxListeners=function(n){if(!isNumber(n)||n<0||isNaN(n))throw TypeError("n must be a positive number");this._maxListeners=n;return this};EventEmitter.prototype.emit=function(type){var er,handler,len,args,i,listeners;if(!this._events)this._events={};if(type==="error"){if(!this._events.error||isObject(this._events.error)&&!this._events.error.length){er=arguments[1];if(er instanceof Error){throw er}else{var err=new Error('Uncaught, unspecified "error" event. ('+er+")");err.context=er;throw err}}}handler=this._events[type];if(isUndefined(handler))return false;if(isFunction(handler)){switch(arguments.length){case 1:handler.call(this);break;case 2:handler.call(this,arguments[1]);break;case 3:handler.call(this,arguments[1],arguments[2]);break;default:args=Array.prototype.slice.call(arguments,1);handler.apply(this,args)}}else if(isObject(handler)){args=Array.prototype.slice.call(arguments,1);listeners=handler.slice();len=listeners.length;for(i=0;i0&&this._events[type].length>m){this._events[type].warned=true;console.error("(node) warning: possible EventEmitter memory "+"leak detected. %d listeners added. "+"Use emitter.setMaxListeners() to increase limit.",this._events[type].length);if(typeof console.trace==="function"){console.trace()}}}return this};EventEmitter.prototype.on=EventEmitter.prototype.addListener;EventEmitter.prototype.once=function(type,listener){if(!isFunction(listener))throw TypeError("listener must be a function");var fired=false;function g(){this.removeListener(type,g);if(!fired){fired=true;listener.apply(this,arguments)}}g.listener=listener;this.on(type,g);return this};EventEmitter.prototype.removeListener=function(type,listener){var list,position,length,i;if(!isFunction(listener))throw TypeError("listener must be a function");if(!this._events||!this._events[type])return this;list=this._events[type];length=list.length;position=-1;if(list===listener||isFunction(list.listener)&&list.listener===listener){delete this._events[type];if(this._events.removeListener)this.emit("removeListener",type,listener)}else if(isObject(list)){for(i=length;i-- >0;){if(list[i]===listener||list[i].listener&&list[i].listener===listener){position=i;break}}if(position<0)return this;if(list.length===1){list.length=0;delete this._events[type]}else{list.splice(position,1)}if(this._events.removeListener)this.emit("removeListener",type,listener)}return this};EventEmitter.prototype.removeAllListeners=function(type){var key,listeners;if(!this._events)return this;if(!this._events.removeListener){if(arguments.length===0)this._events={};else if(this._events[type])delete this._events[type];return this}if(arguments.length===0){for(key in this._events){if(key==="removeListener")continue;this.removeAllListeners(key)}this.removeAllListeners("removeListener");this._events={};return this}listeners=this._events[type];if(isFunction(listeners)){this.removeListener(type,listeners)}else if(listeners){while(listeners.length)this.removeListener(type,listeners[listeners.length-1])}delete this._events[type];return this};EventEmitter.prototype.listeners=function(type){var ret;if(!this._events||!this._events[type])ret=[];else if(isFunction(this._events[type]))ret=[this._events[type]];else ret=this._events[type].slice();return ret};EventEmitter.prototype.listenerCount=function(type){if(this._events){var evlistener=this._events[type];if(isFunction(evlistener))return 1;else if(evlistener)return evlistener.length}return 0};EventEmitter.listenerCount=function(emitter,type){return emitter.listenerCount(type)};function isFunction(arg){return typeof arg==="function"}function isNumber(arg){return typeof arg==="number"}function isObject(arg){return typeof arg==="object"&&arg!==null}function isUndefined(arg){return arg===void 0}},{}],44:[function(require,module,exports){exports.read=function(buffer,offset,isLE,mLen,nBytes){var e,m;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var nBits=-7;var i=isLE?nBytes-1:0;var d=isLE?-1:1;var s=buffer[offset+i];i+=d;e=s&(1<<-nBits)-1;s>>=-nBits;nBits+=eLen;for(;nBits>0;e=e*256+buffer[offset+i],i+=d,nBits-=8){}m=e&(1<<-nBits)-1;e>>=-nBits;nBits+=mLen;for(;nBits>0;m=m*256+buffer[offset+i],i+=d,nBits-=8){}if(e===0){e=1-eBias}else if(e===eMax){return m?NaN:(s?-1:1)*Infinity}else{m=m+Math.pow(2,mLen);e=e-eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)};exports.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c;var eLen=nBytes*8-mLen-1;var eMax=(1<>1;var rt=mLen===23?Math.pow(2,-24)-Math.pow(2,-77):0;var i=isLE?0:nBytes-1;var d=isLE?1:-1;var s=value<0||value===0&&1/value<0?1:0;value=Math.abs(value);if(isNaN(value)||value===Infinity){m=isNaN(value)?1:0;e=eMax}else{e=Math.floor(Math.log(value)/Math.LN2);if(value*(c=Math.pow(2,-e))<1){e--;c*=2}if(e+eBias>=1){value+=rt/c}else{value+=rt*Math.pow(2,1-eBias)}if(value*c>=2){e++;c/=2}if(e+eBias>=eMax){m=0;e=eMax}else if(e+eBias>=1){m=(value*c-1)*Math.pow(2,mLen);e=e+eBias}else{m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen);e=0}}for(;mLen>=8;buffer[offset+i]=m&255,i+=d,m/=256,mLen-=8){}e=e<0;buffer[offset+i]=e&255,i+=d,e/=256,eLen-=8){}buffer[offset+i-d]|=s*128}},{}],45:[function(require,module,exports){module.exports=require("./lib/checks")},{"./lib/checks":46}],46:[function(require,module,exports){var util=require("util");var errors=module.exports=require("./errors");function failCheck(ExceptionConstructor,callee,messageFormat,formatArgs){messageFormat=messageFormat||"";var message=util.format.apply(this,[messageFormat].concat(formatArgs));var error=new ExceptionConstructor(message);Error.captureStackTrace(error,callee);throw error}function failArgumentCheck(callee,message,formatArgs){failCheck(errors.IllegalArgumentError,callee,message,formatArgs)}function failStateCheck(callee,message,formatArgs){failCheck(errors.IllegalStateError,callee,message,formatArgs)}module.exports.checkArgument=function(value,message){if(!value){failArgumentCheck(arguments.callee,message,Array.prototype.slice.call(arguments,2))}};module.exports.checkState=function(value,message){if(!value){failStateCheck(arguments.callee,message,Array.prototype.slice.call(arguments,2))}};module.exports.checkIsDef=function(value,message){if(value!==undefined){return value}failArgumentCheck(arguments.callee,message||"Expected value to be defined but was undefined.",Array.prototype.slice.call(arguments,2))};module.exports.checkIsDefAndNotNull=function(value,message){if(value!=null){return value}failArgumentCheck(arguments.callee,message||'Expected value to be defined and not null but got "'+typeOf(value)+'".',Array.prototype.slice.call(arguments,2))};function typeOf(value){var s=typeof value;if(s=="object"){if(!value){return"null"}else if(value instanceof Array){return"array"}}return s}function typeCheck(expect){return function(value,message){var type=typeOf(value);if(type==expect){return value}failArgumentCheck(arguments.callee,message||'Expected "'+expect+'" but got "'+type+'".',Array.prototype.slice.call(arguments,2))}}module.exports.checkIsString=typeCheck("string");module.exports.checkIsArray=typeCheck("array");module.exports.checkIsNumber=typeCheck("number");module.exports.checkIsBoolean=typeCheck("boolean");module.exports.checkIsFunction=typeCheck("function");module.exports.checkIsObject=typeCheck("object")},{"./errors":47,util:55}],47:[function(require,module,exports){var util=require("util");function IllegalArgumentError(message){Error.call(this,message);this.message=message}util.inherits(IllegalArgumentError,Error);IllegalArgumentError.prototype.name="IllegalArgumentError";function IllegalStateError(message){Error.call(this,message);this.message=message}util.inherits(IllegalStateError,Error);IllegalStateError.prototype.name="IllegalStateError";module.exports.IllegalStateError=IllegalStateError;module.exports.IllegalArgumentError=IllegalArgumentError},{util:55}],48:[function(require,module,exports){var process=module.exports={};var cachedSetTimeout;var cachedClearTimeout;function defaultSetTimout(){throw new Error("setTimeout has not been defined")}function defaultClearTimeout(){throw new Error("clearTimeout has not been defined")}(function(){try{if(typeof setTimeout==="function"){cachedSetTimeout=setTimeout}else{cachedSetTimeout=defaultSetTimout}}catch(e){cachedSetTimeout=defaultSetTimout}try{if(typeof clearTimeout==="function"){cachedClearTimeout=clearTimeout}else{cachedClearTimeout=defaultClearTimeout}}catch(e){cachedClearTimeout=defaultClearTimeout}})();function runTimeout(fun){if(cachedSetTimeout===setTimeout){return setTimeout(fun,0)}if((cachedSetTimeout===defaultSetTimout||!cachedSetTimeout)&&setTimeout){cachedSetTimeout=setTimeout;return setTimeout(fun,0)}try{return cachedSetTimeout(fun,0)}catch(e){try{return cachedSetTimeout.call(null,fun,0)}catch(e){return cachedSetTimeout.call(this,fun,0)}}}function runClearTimeout(marker){if(cachedClearTimeout===clearTimeout){return clearTimeout(marker)}if((cachedClearTimeout===defaultClearTimeout||!cachedClearTimeout)&&clearTimeout){cachedClearTimeout=clearTimeout;return clearTimeout(marker)}try{return cachedClearTimeout(marker)}catch(e){try{return cachedClearTimeout.call(null,marker)}catch(e){return cachedClearTimeout.call(this,marker)}}}var queue=[];var draining=false;var currentQueue;var queueIndex=-1;function cleanUpNextTick(){if(!draining||!currentQueue){return}draining=false;if(currentQueue.length){queue=currentQueue.concat(queue)}else{queueIndex=-1}if(queue.length){drainQueue()}}function drainQueue(){if(draining){return}var timeout=runTimeout(cleanUpNextTick);draining=true;var len=queue.length;while(len){currentQueue=queue;queue=[];while(++queueIndex1){for(var i=1;i=0;var oldRuntime=hadRuntime&&g.regeneratorRuntime;g.regeneratorRuntime=undefined;module.exports=require("./runtime");if(hadRuntime){g.regeneratorRuntime=oldRuntime}else{try{delete g.regeneratorRuntime}catch(e){g.regeneratorRuntime=undefined}}},{"./runtime":50}],50:[function(require,module,exports){!function(global){"use strict";var Op=Object.prototype;var hasOwn=Op.hasOwnProperty;var undefined;var $Symbol=typeof Symbol==="function"?Symbol:{};var iteratorSymbol=$Symbol.iterator||"@@iterator";var asyncIteratorSymbol=$Symbol.asyncIterator||"@@asyncIterator";var toStringTagSymbol=$Symbol.toStringTag||"@@toStringTag";var inModule=typeof module==="object";var runtime=global.regeneratorRuntime;if(runtime){if(inModule){module.exports=runtime}return}runtime=global.regeneratorRuntime=inModule?module.exports:{};function wrap(innerFn,outerFn,self,tryLocsList){var protoGenerator=outerFn&&outerFn.prototype instanceof Generator?outerFn:Generator;var generator=Object.create(protoGenerator.prototype);var context=new Context(tryLocsList||[]);generator._invoke=makeInvokeMethod(innerFn,self,context);return generator}runtime.wrap=wrap;function tryCatch(fn,obj,arg){try{return{type:"normal",arg:fn.call(obj,arg)}}catch(err){return{type:"throw",arg:err}}}var GenStateSuspendedStart="suspendedStart";var GenStateSuspendedYield="suspendedYield";var GenStateExecuting="executing";var GenStateCompleted="completed";var ContinueSentinel={};function Generator(){}function GeneratorFunction(){}function GeneratorFunctionPrototype(){}var IteratorPrototype={};IteratorPrototype[iteratorSymbol]=function(){return this};var getProto=Object.getPrototypeOf;var NativeIteratorPrototype=getProto&&getProto(getProto(values([])));if(NativeIteratorPrototype&&NativeIteratorPrototype!==Op&&hasOwn.call(NativeIteratorPrototype,iteratorSymbol)){IteratorPrototype=NativeIteratorPrototype}var Gp=GeneratorFunctionPrototype.prototype=Generator.prototype=Object.create(IteratorPrototype);GeneratorFunction.prototype=Gp.constructor=GeneratorFunctionPrototype;GeneratorFunctionPrototype.constructor=GeneratorFunction;GeneratorFunctionPrototype[toStringTagSymbol]=GeneratorFunction.displayName="GeneratorFunction";function defineIteratorMethods(prototype){["next","throw","return"].forEach(function(method){prototype[method]=function(arg){return this._invoke(method,arg)}})}runtime.isGeneratorFunction=function(genFun){var ctor=typeof genFun==="function"&&genFun.constructor;return ctor?ctor===GeneratorFunction||(ctor.displayName||ctor.name)==="GeneratorFunction":false};runtime.mark=function(genFun){if(Object.setPrototypeOf){Object.setPrototypeOf(genFun,GeneratorFunctionPrototype)}else{genFun.__proto__=GeneratorFunctionPrototype;if(!(toStringTagSymbol in genFun)){genFun[toStringTagSymbol]="GeneratorFunction"}}genFun.prototype=Object.create(Gp);return genFun};runtime.awrap=function(arg){return{__await:arg}};function AsyncIterator(generator){function invoke(method,arg,resolve,reject){var record=tryCatch(generator[method],generator,arg);if(record.type==="throw"){reject(record.arg)}else{var result=record.arg;var value=result.value;if(value&&typeof value==="object"&&hasOwn.call(value,"__await")){return Promise.resolve(value.__await).then(function(value){invoke("next",value,resolve,reject)},function(err){invoke("throw",err,resolve,reject)})}return Promise.resolve(value).then(function(unwrapped){result.value=unwrapped;resolve(result)},reject)}}var previousPromise;function enqueue(method,arg){function callInvokeWithMethodAndArg(){return new Promise(function(resolve,reject){invoke(method,arg,resolve,reject)})}return previousPromise=previousPromise?previousPromise.then(callInvokeWithMethodAndArg,callInvokeWithMethodAndArg):callInvokeWithMethodAndArg()}this._invoke=enqueue}defineIteratorMethods(AsyncIterator.prototype);AsyncIterator.prototype[asyncIteratorSymbol]=function(){return this};runtime.AsyncIterator=AsyncIterator;runtime.async=function(innerFn,outerFn,self,tryLocsList){var iter=new AsyncIterator(wrap(innerFn,outerFn,self,tryLocsList));return runtime.isGeneratorFunction(outerFn)?iter:iter.next().then(function(result){return result.done?result.value:iter.next()})};function makeInvokeMethod(innerFn,self,context){var state=GenStateSuspendedStart;return function invoke(method,arg){if(state===GenStateExecuting){throw new Error("Generator is already running")}if(state===GenStateCompleted){if(method==="throw"){throw arg}return doneResult()}context.method=method;context.arg=arg;while(true){var delegate=context.delegate;if(delegate){var delegateResult=maybeInvokeDelegate(delegate,context);if(delegateResult){if(delegateResult===ContinueSentinel)continue;return delegateResult}}if(context.method==="next"){context.sent=context._sent=context.arg}else if(context.method==="throw"){if(state===GenStateSuspendedStart){state=GenStateCompleted;throw context.arg}context.dispatchException(context.arg)}else if(context.method==="return"){context.abrupt("return",context.arg)}state=GenStateExecuting;var record=tryCatch(innerFn,self,context);if(record.type==="normal"){state=context.done?GenStateCompleted:GenStateSuspendedYield;if(record.arg===ContinueSentinel){continue}return{value:record.arg,done:context.done}}else if(record.type==="throw"){state=GenStateCompleted;context.method="throw";context.arg=record.arg}}}}function maybeInvokeDelegate(delegate,context){var method=delegate.iterator[context.method];if(method===undefined){context.delegate=null;if(context.method==="throw"){if(delegate.iterator.return){context.method="return";context.arg=undefined;maybeInvokeDelegate(delegate,context);if(context.method==="throw"){return ContinueSentinel}}context.method="throw";context.arg=new TypeError("The iterator does not provide a 'throw' method")}return ContinueSentinel}var record=tryCatch(method,delegate.iterator,context.arg);if(record.type==="throw"){context.method="throw";context.arg=record.arg;context.delegate=null;return ContinueSentinel}var info=record.arg;if(!info){context.method="throw";context.arg=new TypeError("iterator result is not an object");context.delegate=null;return ContinueSentinel}if(info.done){context[delegate.resultName]=info.value;context.next=delegate.nextLoc;if(context.method!=="return"){context.method="next";context.arg=undefined}}else{return info}context.delegate=null;return ContinueSentinel}defineIteratorMethods(Gp);Gp[toStringTagSymbol]="Generator";Gp[iteratorSymbol]=function(){return this};Gp.toString=function(){return"[object Generator]"};function pushTryEntry(locs){var entry={tryLoc:locs[0]};if(1 in locs){entry.catchLoc=locs[1]}if(2 in locs){entry.finallyLoc=locs[2];entry.afterLoc=locs[3]}this.tryEntries.push(entry)}function resetTryEntry(entry){var record=entry.completion||{};record.type="normal";delete record.arg;entry.completion=record}function Context(tryLocsList){this.tryEntries=[{tryLoc:"root"}];tryLocsList.forEach(pushTryEntry,this);this.reset(true)}runtime.keys=function(object){var keys=[];for(var key in object){keys.push(key)}keys.reverse();return function next(){while(keys.length){var key=keys.pop();if(key in object){next.value=key;next.done=false;return next}}next.done=true;return next}};function values(iterable){if(iterable){var iteratorMethod=iterable[iteratorSymbol];if(iteratorMethod){return iteratorMethod.call(iterable)}if(typeof iterable.next==="function"){return iterable}if(!isNaN(iterable.length)){var i=-1,next=function next(){while(++i=0;--i){var entry=this.tryEntries[i];var record=entry.completion;if(entry.tryLoc==="root"){return handle("end")}if(entry.tryLoc<=this.prev){var hasCatch=hasOwn.call(entry,"catchLoc");var hasFinally=hasOwn.call(entry,"finallyLoc");if(hasCatch&&hasFinally){if(this.prev=0;--i){var entry=this.tryEntries[i];if(entry.tryLoc<=this.prev&&hasOwn.call(entry,"finallyLoc")&&this.prev=0;--i){var entry=this.tryEntries[i];if(entry.finallyLoc===finallyLoc){this.complete(entry.completion,entry.afterLoc);resetTryEntry(entry);return ContinueSentinel}}},catch:function(tryLoc){for(var i=this.tryEntries.length-1;i>=0;--i){var entry=this.tryEntries[i];if(entry.tryLoc===tryLoc){var record=entry.completion;if(record.type==="throw"){var thrown=record.arg;resetTryEntry(entry)}return thrown}}throw new Error("illegal catch attempt")},delegateYield:function(iterable,resultName,nextLoc){this.delegate={iterator:values(iterable),resultName:resultName,nextLoc:nextLoc};if(this.method==="next"){this.arg=undefined}return ContinueSentinel}}}(function(){return this}()||Function("return this")())},{}],51:[function(require,module,exports){"use strict";var SDPUtils=require("sdp");function fixStatsType(stat){return{inboundrtp:"inbound-rtp",outboundrtp:"outbound-rtp",candidatepair:"candidate-pair",localcandidate:"local-candidate",remotecandidate:"remote-candidate"}[stat.type]||stat.type}function writeMediaSection(transceiver,caps,type,stream,dtlsRole){var sdp=SDPUtils.writeRtpDescription(transceiver.kind,caps);sdp+=SDPUtils.writeIceParameters(transceiver.iceGatherer.getLocalParameters());sdp+=SDPUtils.writeDtlsParameters(transceiver.dtlsTransport.getLocalParameters(),type==="offer"?"actpass":dtlsRole||"active");sdp+="a=mid:"+transceiver.mid+"\r\n";if(transceiver.rtpSender&&transceiver.rtpReceiver){sdp+="a=sendrecv\r\n"}else if(transceiver.rtpSender){sdp+="a=sendonly\r\n"}else if(transceiver.rtpReceiver){sdp+="a=recvonly\r\n"}else{sdp+="a=inactive\r\n"}if(transceiver.rtpSender){var trackId=transceiver.rtpSender._initialTrackId||transceiver.rtpSender.track.id;transceiver.rtpSender._initialTrackId=trackId;var msid="msid:"+(stream?stream.id:"-")+" "+trackId+"\r\n";sdp+="a="+msid;sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" "+msid;if(transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" "+msid;sdp+="a=ssrc-group:FID "+transceiver.sendEncodingParameters[0].ssrc+" "+transceiver.sendEncodingParameters[0].rtx.ssrc+"\r\n"}}sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" cname:"+SDPUtils.localCName+"\r\n";if(transceiver.rtpSender&&transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" cname:"+SDPUtils.localCName+"\r\n"}return sdp}function filterIceServers(iceServers,edgeVersion){var hasTurn=false;iceServers=JSON.parse(JSON.stringify(iceServers));return iceServers.filter(function(server){if(server&&(server.urls||server.url)){var urls=server.urls||server.url;if(server.url&&!server.urls){console.warn("RTCIceServer.url is deprecated! Use urls instead.")}var isString=typeof urls==="string";if(isString){urls=[urls]}urls=urls.filter(function(url){var validTurn=url.indexOf("turn:")===0&&url.indexOf("transport=udp")!==-1&&url.indexOf("turn:[")===-1&&!hasTurn;if(validTurn){hasTurn=true;return true}return url.indexOf("stun:")===0&&edgeVersion>=14393&&url.indexOf("?transport=udp")===-1});delete server.url;server.urls=isString?urls[0]:urls;return!!urls.length}})}function getCommonCapabilities(localCapabilities,remoteCapabilities){var commonCapabilities={codecs:[],headerExtensions:[],fecMechanisms:[]};var findCodecByPayloadType=function(pt,codecs){pt=parseInt(pt,10);for(var i=0;i0;i--){this._iceGatherers.push(new window.RTCIceGatherer({iceServers:config.iceServers,gatherPolicy:config.iceTransportPolicy}))}}else{config.iceCandidatePoolSize=0}this._config=config;this.transceivers=[];this._sdpSessionId=SDPUtils.generateSessionId();this._sdpSessionVersion=0;this._dtlsRole=undefined;this._isClosed=false};RTCPeerConnection.prototype.onicecandidate=null;RTCPeerConnection.prototype.onaddstream=null;RTCPeerConnection.prototype.ontrack=null;RTCPeerConnection.prototype.onremovestream=null;RTCPeerConnection.prototype.onsignalingstatechange=null;RTCPeerConnection.prototype.oniceconnectionstatechange=null;RTCPeerConnection.prototype.onconnectionstatechange=null;RTCPeerConnection.prototype.onicegatheringstatechange=null;RTCPeerConnection.prototype.onnegotiationneeded=null;RTCPeerConnection.prototype.ondatachannel=null;RTCPeerConnection.prototype._dispatchEvent=function(name,event){if(this._isClosed){return}this.dispatchEvent(event);if(typeof this["on"+name]==="function"){this["on"+name](event)}};RTCPeerConnection.prototype._emitGatheringStateChange=function(){var event=new Event("icegatheringstatechange");this._dispatchEvent("icegatheringstatechange",event)};RTCPeerConnection.prototype.getConfiguration=function(){return this._config};RTCPeerConnection.prototype.getLocalStreams=function(){return this.localStreams};RTCPeerConnection.prototype.getRemoteStreams=function(){return this.remoteStreams};RTCPeerConnection.prototype._createTransceiver=function(kind,doNotAdd){var hasBundleTransport=this.transceivers.length>0;var transceiver={track:null,iceGatherer:null,iceTransport:null,dtlsTransport:null,localCapabilities:null,remoteCapabilities:null,rtpSender:null,rtpReceiver:null,kind:kind,mid:null,sendEncodingParameters:null,recvEncodingParameters:null,stream:null,associatedRemoteMediaStreams:[],wantReceive:true};if(this.usingBundle&&hasBundleTransport){transceiver.iceTransport=this.transceivers[0].iceTransport;transceiver.dtlsTransport=this.transceivers[0].dtlsTransport}else{var transports=this._createIceAndDtlsTransports();transceiver.iceTransport=transports.iceTransport;transceiver.dtlsTransport=transports.dtlsTransport}if(!doNotAdd){this.transceivers.push(transceiver)}return transceiver};RTCPeerConnection.prototype.addTrack=function(track,stream){if(this._isClosed){throw makeError("InvalidStateError","Attempted to call addTrack on a closed peerconnection.")}var alreadyExists=this.transceivers.find(function(s){return s.track===track});if(alreadyExists){throw makeError("InvalidAccessError","Track already exists.")}var transceiver;for(var i=0;i=15025){stream.getTracks().forEach(function(track){pc.addTrack(track,stream)})}else{var clonedStream=stream.clone();stream.getTracks().forEach(function(track,idx){var clonedTrack=clonedStream.getTracks()[idx];track.addEventListener("enabled",function(event){clonedTrack.enabled=event.enabled})});clonedStream.getTracks().forEach(function(track){pc.addTrack(track,clonedStream)})}};RTCPeerConnection.prototype.removeTrack=function(sender){if(this._isClosed){throw makeError("InvalidStateError","Attempted to call removeTrack on a closed peerconnection.")}if(!(sender instanceof window.RTCRtpSender)){throw new TypeError("Argument 1 of RTCPeerConnection.removeTrack "+"does not implement interface RTCRtpSender.")}var transceiver=this.transceivers.find(function(t){return t.rtpSender===sender});if(!transceiver){throw makeError("InvalidAccessError","Sender was not created by this connection.")}var stream=transceiver.stream;transceiver.rtpSender.stop();transceiver.rtpSender=null;transceiver.track=null;transceiver.stream=null;var localStreams=this.transceivers.map(function(t){return t.stream});if(localStreams.indexOf(stream)===-1&&this.localStreams.indexOf(stream)>-1){this.localStreams.splice(this.localStreams.indexOf(stream),1)}this._maybeFireNegotiationNeeded()};RTCPeerConnection.prototype.removeStream=function(stream){var pc=this;stream.getTracks().forEach(function(track){var sender=pc.getSenders().find(function(s){return s.track===track});if(sender){pc.removeTrack(sender)}})};RTCPeerConnection.prototype.getSenders=function(){return this.transceivers.filter(function(transceiver){return!!transceiver.rtpSender}).map(function(transceiver){return transceiver.rtpSender})};RTCPeerConnection.prototype.getReceivers=function(){return this.transceivers.filter(function(transceiver){return!!transceiver.rtpReceiver}).map(function(transceiver){return transceiver.rtpReceiver})};RTCPeerConnection.prototype._createIceGatherer=function(sdpMLineIndex,usingBundle){var pc=this;if(usingBundle&&sdpMLineIndex>0){return this.transceivers[0].iceGatherer}else if(this._iceGatherers.length){return this._iceGatherers.shift()}var iceGatherer=new window.RTCIceGatherer({iceServers:this._config.iceServers,gatherPolicy:this._config.iceTransportPolicy});Object.defineProperty(iceGatherer,"state",{value:"new",writable:true});this.transceivers[sdpMLineIndex].bufferedCandidateEvents=[];this.transceivers[sdpMLineIndex].bufferCandidates=function(event){var end=!event.candidate||Object.keys(event.candidate).length===0;iceGatherer.state=end?"completed":"gathering";if(pc.transceivers[sdpMLineIndex].bufferedCandidateEvents!==null){pc.transceivers[sdpMLineIndex].bufferedCandidateEvents.push(event)}};iceGatherer.addEventListener("localcandidate",this.transceivers[sdpMLineIndex].bufferCandidates);return iceGatherer};RTCPeerConnection.prototype._gather=function(mid,sdpMLineIndex){var pc=this;var iceGatherer=this.transceivers[sdpMLineIndex].iceGatherer;if(iceGatherer.onlocalcandidate){return}var bufferedCandidateEvents=this.transceivers[sdpMLineIndex].bufferedCandidateEvents;this.transceivers[sdpMLineIndex].bufferedCandidateEvents=null;iceGatherer.removeEventListener("localcandidate",this.transceivers[sdpMLineIndex].bufferCandidates);iceGatherer.onlocalcandidate=function(evt){if(pc.usingBundle&&sdpMLineIndex>0){return}var event=new Event("icecandidate");event.candidate={sdpMid:mid,sdpMLineIndex:sdpMLineIndex};var cand=evt.candidate;var end=!cand||Object.keys(cand).length===0;if(end){if(iceGatherer.state==="new"||iceGatherer.state==="gathering"){iceGatherer.state="completed"}}else{if(iceGatherer.state==="new"){iceGatherer.state="gathering"}cand.component=1;cand.ufrag=iceGatherer.getLocalParameters().usernameFragment;var serializedCandidate=SDPUtils.writeCandidate(cand);event.candidate=Object.assign(event.candidate,SDPUtils.parseCandidate(serializedCandidate));event.candidate.candidate=serializedCandidate;event.candidate.toJSON=function(){return{candidate:event.candidate.candidate,sdpMid:event.candidate.sdpMid,sdpMLineIndex:event.candidate.sdpMLineIndex,usernameFragment:event.candidate.usernameFragment}}}var sections=SDPUtils.getMediaSections(pc.localDescription.sdp);if(!end){sections[event.candidate.sdpMLineIndex]+="a="+event.candidate.candidate+"\r\n"}else{sections[event.candidate.sdpMLineIndex]+="a=end-of-candidates\r\n"}pc.localDescription.sdp=SDPUtils.getDescription(pc.localDescription.sdp)+sections.join("");var complete=pc.transceivers.every(function(transceiver){return transceiver.iceGatherer&&transceiver.iceGatherer.state==="completed"});if(pc.iceGatheringState!=="gathering"){pc.iceGatheringState="gathering";pc._emitGatheringStateChange()}if(!end){pc._dispatchEvent("icecandidate",event)}if(complete){pc._dispatchEvent("icecandidate",new Event("icecandidate"));pc.iceGatheringState="complete";pc._emitGatheringStateChange()}};window.setTimeout(function(){bufferedCandidateEvents.forEach(function(e){iceGatherer.onlocalcandidate(e)})},0)};RTCPeerConnection.prototype._createIceAndDtlsTransports=function(){var pc=this;var iceTransport=new window.RTCIceTransport(null);iceTransport.onicestatechange=function(){pc._updateIceConnectionState();pc._updateConnectionState()};var dtlsTransport=new window.RTCDtlsTransport(iceTransport);dtlsTransport.ondtlsstatechange=function(){pc._updateConnectionState()};dtlsTransport.onerror=function(){Object.defineProperty(dtlsTransport,"state",{value:"failed",writable:true});pc._updateConnectionState()};return{iceTransport:iceTransport,dtlsTransport:dtlsTransport}};RTCPeerConnection.prototype._disposeIceAndDtlsTransports=function(sdpMLineIndex){var iceGatherer=this.transceivers[sdpMLineIndex].iceGatherer;if(iceGatherer){delete iceGatherer.onlocalcandidate;delete this.transceivers[sdpMLineIndex].iceGatherer}var iceTransport=this.transceivers[sdpMLineIndex].iceTransport;if(iceTransport){delete iceTransport.onicestatechange;delete this.transceivers[sdpMLineIndex].iceTransport}var dtlsTransport=this.transceivers[sdpMLineIndex].dtlsTransport;if(dtlsTransport){delete dtlsTransport.ondtlsstatechange;delete dtlsTransport.onerror;delete this.transceivers[sdpMLineIndex].dtlsTransport}};RTCPeerConnection.prototype._transceive=function(transceiver,send,recv){var params=getCommonCapabilities(transceiver.localCapabilities,transceiver.remoteCapabilities);if(send&&transceiver.rtpSender){params.encodings=transceiver.sendEncodingParameters;params.rtcp={cname:SDPUtils.localCName,compound:transceiver.rtcpParameters.compound};if(transceiver.recvEncodingParameters.length){params.rtcp.ssrc=transceiver.recvEncodingParameters[0].ssrc}transceiver.rtpSender.send(params)}if(recv&&transceiver.rtpReceiver&¶ms.codecs.length>0){if(transceiver.kind==="video"&&transceiver.recvEncodingParameters&&edgeVersion<15019){transceiver.recvEncodingParameters.forEach(function(p){delete p.rtx})}if(transceiver.recvEncodingParameters.length){params.encodings=transceiver.recvEncodingParameters}else{params.encodings=[{}]}params.rtcp={compound:transceiver.rtcpParameters.compound};if(transceiver.rtcpParameters.cname){params.rtcp.cname=transceiver.rtcpParameters.cname}if(transceiver.sendEncodingParameters.length){params.rtcp.ssrc=transceiver.sendEncodingParameters[0].ssrc}transceiver.rtpReceiver.receive(params)}};RTCPeerConnection.prototype.setLocalDescription=function(description){var pc=this;if(["offer","answer"].indexOf(description.type)===-1){return Promise.reject(makeError("TypeError",'Unsupported type "'+description.type+'"'))}if(!isActionAllowedInSignalingState("setLocalDescription",description.type,pc.signalingState)||pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not set local "+description.type+" in state "+pc.signalingState))}var sections;var sessionpart;if(description.type==="offer"){sections=SDPUtils.splitSections(description.sdp);sessionpart=sections.shift();sections.forEach(function(mediaSection,sdpMLineIndex){var caps=SDPUtils.parseRtpParameters(mediaSection);pc.transceivers[sdpMLineIndex].localCapabilities=caps});pc.transceivers.forEach(function(transceiver,sdpMLineIndex){pc._gather(transceiver.mid,sdpMLineIndex)})}else if(description.type==="answer"){sections=SDPUtils.splitSections(pc.remoteDescription.sdp);sessionpart=sections.shift();var isIceLite=SDPUtils.matchPrefix(sessionpart,"a=ice-lite").length>0;sections.forEach(function(mediaSection,sdpMLineIndex){var transceiver=pc.transceivers[sdpMLineIndex];var iceGatherer=transceiver.iceGatherer;var iceTransport=transceiver.iceTransport;var dtlsTransport=transceiver.dtlsTransport;var localCapabilities=transceiver.localCapabilities;var remoteCapabilities=transceiver.remoteCapabilities;var rejected=SDPUtils.isRejected(mediaSection)&&SDPUtils.matchPrefix(mediaSection,"a=bundle-only").length===0;if(!rejected&&!transceiver.rejected){var remoteIceParameters=SDPUtils.getIceParameters(mediaSection,sessionpart);var remoteDtlsParameters=SDPUtils.getDtlsParameters(mediaSection,sessionpart);if(isIceLite){remoteDtlsParameters.role="server"}if(!pc.usingBundle||sdpMLineIndex===0){pc._gather(transceiver.mid,sdpMLineIndex);if(iceTransport.state==="new"){iceTransport.start(iceGatherer,remoteIceParameters,isIceLite?"controlling":"controlled")}if(dtlsTransport.state==="new"){dtlsTransport.start(remoteDtlsParameters)}}var params=getCommonCapabilities(localCapabilities,remoteCapabilities);pc._transceive(transceiver,params.codecs.length>0,false)}})}pc.localDescription={type:description.type,sdp:description.sdp};if(description.type==="offer"){pc._updateSignalingState("have-local-offer")}else{pc._updateSignalingState("stable")}return Promise.resolve()};RTCPeerConnection.prototype.setRemoteDescription=function(description){var pc=this;if(["offer","answer"].indexOf(description.type)===-1){return Promise.reject(makeError("TypeError",'Unsupported type "'+description.type+'"'))}if(!isActionAllowedInSignalingState("setRemoteDescription",description.type,pc.signalingState)||pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not set remote "+description.type+" in state "+pc.signalingState))}var streams={};pc.remoteStreams.forEach(function(stream){streams[stream.id]=stream});var receiverList=[];var sections=SDPUtils.splitSections(description.sdp);var sessionpart=sections.shift();var isIceLite=SDPUtils.matchPrefix(sessionpart,"a=ice-lite").length>0;var usingBundle=SDPUtils.matchPrefix(sessionpart,"a=group:BUNDLE ").length>0;pc.usingBundle=usingBundle;var iceOptions=SDPUtils.matchPrefix(sessionpart,"a=ice-options:")[0];if(iceOptions){pc.canTrickleIceCandidates=iceOptions.substr(14).split(" ").indexOf("trickle")>=0}else{pc.canTrickleIceCandidates=false}sections.forEach(function(mediaSection,sdpMLineIndex){var lines=SDPUtils.splitLines(mediaSection);var kind=SDPUtils.getKind(mediaSection);var rejected=SDPUtils.isRejected(mediaSection)&&SDPUtils.matchPrefix(mediaSection,"a=bundle-only").length===0;var protocol=lines[0].substr(2).split(" ")[2];var direction=SDPUtils.getDirection(mediaSection,sessionpart);var remoteMsid=SDPUtils.parseMsid(mediaSection);var mid=SDPUtils.getMid(mediaSection)||SDPUtils.generateIdentifier();if(kind==="application"&&protocol==="DTLS/SCTP"||rejected){pc.transceivers[sdpMLineIndex]={mid:mid,kind:kind,rejected:true};return}if(!rejected&&pc.transceivers[sdpMLineIndex]&&pc.transceivers[sdpMLineIndex].rejected){pc.transceivers[sdpMLineIndex]=pc._createTransceiver(kind,true)}var transceiver;var iceGatherer;var iceTransport;var dtlsTransport;var rtpReceiver;var sendEncodingParameters;var recvEncodingParameters;var localCapabilities;var track;var remoteCapabilities=SDPUtils.parseRtpParameters(mediaSection);var remoteIceParameters;var remoteDtlsParameters;if(!rejected){remoteIceParameters=SDPUtils.getIceParameters(mediaSection,sessionpart);remoteDtlsParameters=SDPUtils.getDtlsParameters(mediaSection,sessionpart);remoteDtlsParameters.role="client"}recvEncodingParameters=SDPUtils.parseRtpEncodingParameters(mediaSection);var rtcpParameters=SDPUtils.parseRtcpParameters(mediaSection);var isComplete=SDPUtils.matchPrefix(mediaSection,"a=end-of-candidates",sessionpart).length>0;var cands=SDPUtils.matchPrefix(mediaSection,"a=candidate:").map(function(cand){return SDPUtils.parseCandidate(cand)}).filter(function(cand){return cand.component===1});if((description.type==="offer"||description.type==="answer")&&!rejected&&usingBundle&&sdpMLineIndex>0&&pc.transceivers[sdpMLineIndex]){pc._disposeIceAndDtlsTransports(sdpMLineIndex);pc.transceivers[sdpMLineIndex].iceGatherer=pc.transceivers[0].iceGatherer;pc.transceivers[sdpMLineIndex].iceTransport=pc.transceivers[0].iceTransport;pc.transceivers[sdpMLineIndex].dtlsTransport=pc.transceivers[0].dtlsTransport;if(pc.transceivers[sdpMLineIndex].rtpSender){pc.transceivers[sdpMLineIndex].rtpSender.setTransport(pc.transceivers[0].dtlsTransport)}if(pc.transceivers[sdpMLineIndex].rtpReceiver){pc.transceivers[sdpMLineIndex].rtpReceiver.setTransport(pc.transceivers[0].dtlsTransport)}}if(description.type==="offer"&&!rejected){transceiver=pc.transceivers[sdpMLineIndex]||pc._createTransceiver(kind);transceiver.mid=mid;if(!transceiver.iceGatherer){transceiver.iceGatherer=pc._createIceGatherer(sdpMLineIndex,usingBundle)}if(cands.length&&transceiver.iceTransport.state==="new"){if(isComplete&&(!usingBundle||sdpMLineIndex===0)){transceiver.iceTransport.setRemoteCandidates(cands)}else{cands.forEach(function(candidate){maybeAddCandidate(transceiver.iceTransport,candidate)})}}localCapabilities=window.RTCRtpReceiver.getCapabilities(kind);if(edgeVersion<15019){localCapabilities.codecs=localCapabilities.codecs.filter(function(codec){return codec.name!=="rtx"})}sendEncodingParameters=transceiver.sendEncodingParameters||[{ssrc:(2*sdpMLineIndex+2)*1001}];var isNewTrack=false;if(direction==="sendrecv"||direction==="sendonly"){isNewTrack=!transceiver.rtpReceiver;rtpReceiver=transceiver.rtpReceiver||new window.RTCRtpReceiver(transceiver.dtlsTransport,kind);if(isNewTrack){var stream;track=rtpReceiver.track;if(remoteMsid&&remoteMsid.stream==="-"){}else if(remoteMsid){if(!streams[remoteMsid.stream]){streams[remoteMsid.stream]=new window.MediaStream;Object.defineProperty(streams[remoteMsid.stream],"id",{get:function(){return remoteMsid.stream}})}Object.defineProperty(track,"id",{get:function(){return remoteMsid.track}});stream=streams[remoteMsid.stream]}else{if(!streams.default){streams.default=new window.MediaStream}stream=streams.default}if(stream){addTrackToStreamAndFireEvent(track,stream);transceiver.associatedRemoteMediaStreams.push(stream)}receiverList.push([track,rtpReceiver,stream])}}else if(transceiver.rtpReceiver&&transceiver.rtpReceiver.track){transceiver.associatedRemoteMediaStreams.forEach(function(s){var nativeTrack=s.getTracks().find(function(t){return t.id===transceiver.rtpReceiver.track.id});if(nativeTrack){removeTrackFromStreamAndFireEvent(nativeTrack,s)}});transceiver.associatedRemoteMediaStreams=[]}transceiver.localCapabilities=localCapabilities;transceiver.remoteCapabilities=remoteCapabilities;transceiver.rtpReceiver=rtpReceiver;transceiver.rtcpParameters=rtcpParameters;transceiver.sendEncodingParameters=sendEncodingParameters;transceiver.recvEncodingParameters=recvEncodingParameters;pc._transceive(pc.transceivers[sdpMLineIndex],false,isNewTrack)}else if(description.type==="answer"&&!rejected){transceiver=pc.transceivers[sdpMLineIndex];iceGatherer=transceiver.iceGatherer;iceTransport=transceiver.iceTransport;dtlsTransport=transceiver.dtlsTransport;rtpReceiver=transceiver.rtpReceiver;sendEncodingParameters=transceiver.sendEncodingParameters;localCapabilities=transceiver.localCapabilities;pc.transceivers[sdpMLineIndex].recvEncodingParameters=recvEncodingParameters;pc.transceivers[sdpMLineIndex].remoteCapabilities=remoteCapabilities;pc.transceivers[sdpMLineIndex].rtcpParameters=rtcpParameters;if(cands.length&&iceTransport.state==="new"){if((isIceLite||isComplete)&&(!usingBundle||sdpMLineIndex===0)){iceTransport.setRemoteCandidates(cands)}else{cands.forEach(function(candidate){maybeAddCandidate(transceiver.iceTransport,candidate)})}}if(!usingBundle||sdpMLineIndex===0){if(iceTransport.state==="new"){iceTransport.start(iceGatherer,remoteIceParameters,"controlling")}if(dtlsTransport.state==="new"){dtlsTransport.start(remoteDtlsParameters)}}pc._transceive(transceiver,direction==="sendrecv"||direction==="recvonly",direction==="sendrecv"||direction==="sendonly");if(rtpReceiver&&(direction==="sendrecv"||direction==="sendonly")){track=rtpReceiver.track;if(remoteMsid){if(!streams[remoteMsid.stream]){streams[remoteMsid.stream]=new window.MediaStream}addTrackToStreamAndFireEvent(track,streams[remoteMsid.stream]);receiverList.push([track,rtpReceiver,streams[remoteMsid.stream]])}else{if(!streams.default){streams.default=new window.MediaStream}addTrackToStreamAndFireEvent(track,streams.default);receiverList.push([track,rtpReceiver,streams.default])}}else{delete transceiver.rtpReceiver}}});if(pc._dtlsRole===undefined){pc._dtlsRole=description.type==="offer"?"active":"passive"}pc.remoteDescription={type:description.type,sdp:description.sdp};if(description.type==="offer"){pc._updateSignalingState("have-remote-offer")}else{pc._updateSignalingState("stable")}Object.keys(streams).forEach(function(sid){var stream=streams[sid];if(stream.getTracks().length){if(pc.remoteStreams.indexOf(stream)===-1){pc.remoteStreams.push(stream);var event=new Event("addstream");event.stream=stream;window.setTimeout(function(){pc._dispatchEvent("addstream",event)})}receiverList.forEach(function(item){var track=item[0];var receiver=item[1];if(stream.id!==item[2].id){return}fireAddTrack(pc,track,receiver,[stream])})}});receiverList.forEach(function(item){if(item[2]){return}fireAddTrack(pc,item[0],item[1],[])});window.setTimeout(function(){if(!(pc&&pc.transceivers)){return}pc.transceivers.forEach(function(transceiver){if(transceiver.iceTransport&&transceiver.iceTransport.state==="new"&&transceiver.iceTransport.getRemoteCandidates().length>0){console.warn("Timeout for addRemoteCandidate. Consider sending "+"an end-of-candidates notification");transceiver.iceTransport.addRemoteCandidate({})}})},4e3);return Promise.resolve()};RTCPeerConnection.prototype.close=function(){this.transceivers.forEach(function(transceiver){if(transceiver.iceTransport){transceiver.iceTransport.stop()}if(transceiver.dtlsTransport){transceiver.dtlsTransport.stop()}if(transceiver.rtpSender){transceiver.rtpSender.stop()}if(transceiver.rtpReceiver){transceiver.rtpReceiver.stop()}});this._isClosed=true;this._updateSignalingState("closed")};RTCPeerConnection.prototype._updateSignalingState=function(newState){this.signalingState=newState;var event=new Event("signalingstatechange");this._dispatchEvent("signalingstatechange",event)};RTCPeerConnection.prototype._maybeFireNegotiationNeeded=function(){var pc=this;if(this.signalingState!=="stable"||this.needNegotiation===true){return}this.needNegotiation=true;window.setTimeout(function(){if(pc.needNegotiation){pc.needNegotiation=false;var event=new Event("negotiationneeded");pc._dispatchEvent("negotiationneeded",event)}},0)};RTCPeerConnection.prototype._updateIceConnectionState=function(){var newState;var states={new:0,closed:0,checking:0,connected:0,completed:0,disconnected:0,failed:0};this.transceivers.forEach(function(transceiver){states[transceiver.iceTransport.state]++});newState="new";if(states.failed>0){newState="failed"}else if(states.checking>0){newState="checking"}else if(states.disconnected>0){newState="disconnected"}else if(states.new>0){newState="new"}else if(states.connected>0){newState="connected"}else if(states.completed>0){newState="completed"}if(newState!==this.iceConnectionState){this.iceConnectionState=newState;var event=new Event("iceconnectionstatechange");this._dispatchEvent("iceconnectionstatechange",event)}};RTCPeerConnection.prototype._updateConnectionState=function(){var newState;var states={new:0,closed:0,connecting:0,connected:0,completed:0,disconnected:0,failed:0};this.transceivers.forEach(function(transceiver){states[transceiver.iceTransport.state]++;states[transceiver.dtlsTransport.state]++});states.connected+=states.completed;newState="new";if(states.failed>0){newState="failed"}else if(states.connecting>0){newState="connecting"}else if(states.disconnected>0){newState="disconnected"}else if(states.new>0){newState="new"}else if(states.connected>0){newState="connected"}if(newState!==this.connectionState){this.connectionState=newState;var event=new Event("connectionstatechange");this._dispatchEvent("connectionstatechange",event)}};RTCPeerConnection.prototype.createOffer=function(){var pc=this;if(pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not call createOffer after close"))}var numAudioTracks=pc.transceivers.filter(function(t){return t.kind==="audio"}).length;var numVideoTracks=pc.transceivers.filter(function(t){return t.kind==="video"}).length;var offerOptions=arguments[0];if(offerOptions){if(offerOptions.mandatory||offerOptions.optional){throw new TypeError("Legacy mandatory/optional constraints not supported.")}if(offerOptions.offerToReceiveAudio!==undefined){if(offerOptions.offerToReceiveAudio===true){numAudioTracks=1}else if(offerOptions.offerToReceiveAudio===false){numAudioTracks=0}else{numAudioTracks=offerOptions.offerToReceiveAudio}}if(offerOptions.offerToReceiveVideo!==undefined){if(offerOptions.offerToReceiveVideo===true){numVideoTracks=1}else if(offerOptions.offerToReceiveVideo===false){numVideoTracks=0}else{numVideoTracks=offerOptions.offerToReceiveVideo}}}pc.transceivers.forEach(function(transceiver){if(transceiver.kind==="audio"){numAudioTracks--;if(numAudioTracks<0){transceiver.wantReceive=false}}else if(transceiver.kind==="video"){numVideoTracks--;if(numVideoTracks<0){transceiver.wantReceive=false}}});while(numAudioTracks>0||numVideoTracks>0){if(numAudioTracks>0){pc._createTransceiver("audio");numAudioTracks--}if(numVideoTracks>0){pc._createTransceiver("video");numVideoTracks--}}var sdp=SDPUtils.writeSessionBoilerplate(pc._sdpSessionId,pc._sdpSessionVersion++);pc.transceivers.forEach(function(transceiver,sdpMLineIndex){var track=transceiver.track;var kind=transceiver.kind;var mid=transceiver.mid||SDPUtils.generateIdentifier();transceiver.mid=mid;if(!transceiver.iceGatherer){transceiver.iceGatherer=pc._createIceGatherer(sdpMLineIndex,pc.usingBundle)}var localCapabilities=window.RTCRtpSender.getCapabilities(kind);if(edgeVersion<15019){localCapabilities.codecs=localCapabilities.codecs.filter(function(codec){return codec.name!=="rtx"})}localCapabilities.codecs.forEach(function(codec){if(codec.name==="H264"&&codec.parameters["level-asymmetry-allowed"]===undefined){codec.parameters["level-asymmetry-allowed"]="1"}if(transceiver.remoteCapabilities&&transceiver.remoteCapabilities.codecs){transceiver.remoteCapabilities.codecs.forEach(function(remoteCodec){if(codec.name.toLowerCase()===remoteCodec.name.toLowerCase()&&codec.clockRate===remoteCodec.clockRate){codec.preferredPayloadType=remoteCodec.payloadType}})}});localCapabilities.headerExtensions.forEach(function(hdrExt){var remoteExtensions=transceiver.remoteCapabilities&&transceiver.remoteCapabilities.headerExtensions||[];remoteExtensions.forEach(function(rHdrExt){if(hdrExt.uri===rHdrExt.uri){hdrExt.id=rHdrExt.id}})});var sendEncodingParameters=transceiver.sendEncodingParameters||[{ssrc:(2*sdpMLineIndex+1)*1001}];if(track){if(edgeVersion>=15019&&kind==="video"&&!sendEncodingParameters[0].rtx){sendEncodingParameters[0].rtx={ssrc:sendEncodingParameters[0].ssrc+1}}}if(transceiver.wantReceive){transceiver.rtpReceiver=new window.RTCRtpReceiver(transceiver.dtlsTransport,kind)}transceiver.localCapabilities=localCapabilities;transceiver.sendEncodingParameters=sendEncodingParameters});if(pc._config.bundlePolicy!=="max-compat"){sdp+="a=group:BUNDLE "+pc.transceivers.map(function(t){return t.mid}).join(" ")+"\r\n"}sdp+="a=ice-options:trickle\r\n";pc.transceivers.forEach(function(transceiver,sdpMLineIndex){sdp+=writeMediaSection(transceiver,transceiver.localCapabilities,"offer",transceiver.stream,pc._dtlsRole);sdp+="a=rtcp-rsize\r\n";if(transceiver.iceGatherer&&pc.iceGatheringState!=="new"&&(sdpMLineIndex===0||!pc.usingBundle)){transceiver.iceGatherer.getLocalCandidates().forEach(function(cand){cand.component=1;sdp+="a="+SDPUtils.writeCandidate(cand)+"\r\n"});if(transceiver.iceGatherer.state==="completed"){sdp+="a=end-of-candidates\r\n"}}});var desc=new window.RTCSessionDescription({type:"offer",sdp:sdp});return Promise.resolve(desc)};RTCPeerConnection.prototype.createAnswer=function(){var pc=this;if(pc._isClosed){return Promise.reject(makeError("InvalidStateError","Can not call createAnswer after close"))}if(!(pc.signalingState==="have-remote-offer"||pc.signalingState==="have-local-pranswer")){return Promise.reject(makeError("InvalidStateError","Can not call createAnswer in signalingState "+pc.signalingState))}var sdp=SDPUtils.writeSessionBoilerplate(pc._sdpSessionId,pc._sdpSessionVersion++);if(pc.usingBundle){sdp+="a=group:BUNDLE "+pc.transceivers.map(function(t){return t.mid}).join(" ")+"\r\n"}var mediaSectionsInOffer=SDPUtils.getMediaSections(pc.remoteDescription.sdp).length;pc.transceivers.forEach(function(transceiver,sdpMLineIndex){if(sdpMLineIndex+1>mediaSectionsInOffer){return}if(transceiver.rejected){if(transceiver.kind==="application"){sdp+="m=application 0 DTLS/SCTP 5000\r\n"}else if(transceiver.kind==="audio"){sdp+="m=audio 0 UDP/TLS/RTP/SAVPF 0\r\n"+"a=rtpmap:0 PCMU/8000\r\n"}else if(transceiver.kind==="video"){sdp+="m=video 0 UDP/TLS/RTP/SAVPF 120\r\n"+"a=rtpmap:120 VP8/90000\r\n"}sdp+="c=IN IP4 0.0.0.0\r\n"+"a=inactive\r\n"+"a=mid:"+transceiver.mid+"\r\n";return}if(transceiver.stream){var localTrack;if(transceiver.kind==="audio"){localTrack=transceiver.stream.getAudioTracks()[0]}else if(transceiver.kind==="video"){localTrack=transceiver.stream.getVideoTracks()[0]}if(localTrack){if(edgeVersion>=15019&&transceiver.kind==="video"&&!transceiver.sendEncodingParameters[0].rtx){transceiver.sendEncodingParameters[0].rtx={ssrc:transceiver.sendEncodingParameters[0].ssrc+1}}}}var commonCapabilities=getCommonCapabilities(transceiver.localCapabilities,transceiver.remoteCapabilities);var hasRtx=commonCapabilities.codecs.filter(function(c){return c.name.toLowerCase()==="rtx"}).length;if(!hasRtx&&transceiver.sendEncodingParameters[0].rtx){delete transceiver.sendEncodingParameters[0].rtx}sdp+=writeMediaSection(transceiver,commonCapabilities,"answer",transceiver.stream,pc._dtlsRole);if(transceiver.rtcpParameters&&transceiver.rtcpParameters.reducedSize){sdp+="a=rtcp-rsize\r\n"}});var desc=new window.RTCSessionDescription({type:"answer",sdp:sdp});return Promise.resolve(desc)};RTCPeerConnection.prototype.addIceCandidate=function(candidate){var pc=this;var sections;if(candidate&&!(candidate.sdpMLineIndex!==undefined||candidate.sdpMid)){return Promise.reject(new TypeError("sdpMLineIndex or sdpMid required"))}return new Promise(function(resolve,reject){if(!pc.remoteDescription){return reject(makeError("InvalidStateError","Can not add ICE candidate without a remote description"))}else if(!candidate||candidate.candidate===""){for(var j=0;j0?SDPUtils.parseCandidate(candidate.candidate):{};if(cand.protocol==="tcp"&&(cand.port===0||cand.port===9)){return resolve()}if(cand.component&&cand.component!==1){return resolve()}if(sdpMLineIndex===0||sdpMLineIndex>0&&transceiver.iceTransport!==pc.transceivers[0].iceTransport){if(!maybeAddCandidate(transceiver.iceTransport,cand)){return reject(makeError("OperationError","Can not add ICE candidate"))}}var candidateString=candidate.candidate.trim();if(candidateString.indexOf("a=")===0){candidateString=candidateString.substr(2)}sections=SDPUtils.getMediaSections(pc.remoteDescription.sdp);sections[sdpMLineIndex]+="a="+(cand.type?candidateString:"end-of-candidates")+"\r\n";pc.remoteDescription.sdp=SDPUtils.getDescription(pc.remoteDescription.sdp)+sections.join("")}else{return reject(makeError("OperationError","Can not add ICE candidate"))}}resolve()})};RTCPeerConnection.prototype.getStats=function(selector){if(selector&&selector instanceof window.MediaStreamTrack){var senderOrReceiver=null;this.transceivers.forEach(function(transceiver){if(transceiver.rtpSender&&transceiver.rtpSender.track===selector){senderOrReceiver=transceiver.rtpSender}else if(transceiver.rtpReceiver&&transceiver.rtpReceiver.track===selector){senderOrReceiver=transceiver.rtpReceiver}});if(!senderOrReceiver){throw makeError("InvalidAccessError","Invalid selector.")}return senderOrReceiver.getStats()}var promises=[];this.transceivers.forEach(function(transceiver){["rtpSender","rtpReceiver","iceGatherer","iceTransport","dtlsTransport"].forEach(function(method){if(transceiver[method]){promises.push(transceiver[method].getStats())}})});return Promise.all(promises).then(function(allStats){var results=new Map;allStats.forEach(function(stats){stats.forEach(function(stat){results.set(stat.id,stat)})});return results})};var ortcObjects=["RTCRtpSender","RTCRtpReceiver","RTCIceGatherer","RTCIceTransport","RTCDtlsTransport"];ortcObjects.forEach(function(ortcObjectName){var obj=window[ortcObjectName];if(obj&&obj.prototype&&obj.prototype.getStats){var nativeGetstats=obj.prototype.getStats;obj.prototype.getStats=function(){return nativeGetstats.apply(this).then(function(nativeStats){var mapStats=new Map;Object.keys(nativeStats).forEach(function(id){nativeStats[id].type=fixStatsType(nativeStats[id]);mapStats.set(id,nativeStats[id])});return mapStats})}}});var methods=["createOffer","createAnswer"];methods.forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[0]==="function"||typeof args[1]==="function"){return nativeMethod.apply(this,[arguments[2]]).then(function(description){if(typeof args[0]==="function"){args[0].apply(null,[description])}},function(error){if(typeof args[1]==="function"){args[1].apply(null,[error])}})}return nativeMethod.apply(this,arguments)}});methods=["setLocalDescription","setRemoteDescription","addIceCandidate"];methods.forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[1]==="function"||typeof args[2]==="function"){return nativeMethod.apply(this,arguments).then(function(){if(typeof args[1]==="function"){args[1].apply(null)}},function(error){if(typeof args[2]==="function"){args[2].apply(null,[error])}})}return nativeMethod.apply(this,arguments)}});["getStats"].forEach(function(method){var nativeMethod=RTCPeerConnection.prototype[method];RTCPeerConnection.prototype[method]=function(){var args=arguments;if(typeof args[1]==="function"){return nativeMethod.apply(this,arguments).then(function(){if(typeof args[1]==="function"){args[1].apply(null)}})}return nativeMethod.apply(this,arguments)}});return RTCPeerConnection}},{sdp:52}],52:[function(require,module,exports){"use strict";var SDPUtils={};SDPUtils.generateIdentifier=function(){return Math.random().toString(36).substr(2,10)};SDPUtils.localCName=SDPUtils.generateIdentifier();SDPUtils.splitLines=function(blob){return blob.trim().split("\n").map(function(line){return line.trim()})};SDPUtils.splitSections=function(blob){var parts=blob.split("\nm=");return parts.map(function(part,index){return(index>0?"m="+part:part).trim()+"\r\n"})};SDPUtils.getDescription=function(blob){var sections=SDPUtils.splitSections(blob);return sections&§ions[0]};SDPUtils.getMediaSections=function(blob){var sections=SDPUtils.splitSections(blob);sections.shift();return sections};SDPUtils.matchPrefix=function(blob,prefix){return SDPUtils.splitLines(blob).filter(function(line){return line.indexOf(prefix)===0})};SDPUtils.parseCandidate=function(line){var parts;if(line.indexOf("a=candidate:")===0){parts=line.substring(12).split(" ")}else{parts=line.substring(10).split(" ")}var candidate={foundation:parts[0],component:parseInt(parts[1],10),protocol:parts[2].toLowerCase(),priority:parseInt(parts[3],10),ip:parts[4],port:parseInt(parts[5],10),type:parts[7]};for(var i=8;i0?parts[0].split("/")[1]:"sendrecv",uri:parts[1]}};SDPUtils.writeExtmap=function(headerExtension){return"a=extmap:"+(headerExtension.id||headerExtension.preferredId)+(headerExtension.direction&&headerExtension.direction!=="sendrecv"?"/"+headerExtension.direction:"")+" "+headerExtension.uri+"\r\n"};SDPUtils.parseFmtp=function(line){var parsed={};var kv;var parts=line.substr(line.indexOf(" ")+1).split(";");for(var j=0;j-1){parts.attribute=line.substr(sp+1,colon-sp-1);parts.value=line.substr(colon+1)}else{parts.attribute=line.substr(sp+1)}return parts};SDPUtils.getMid=function(mediaSection){var mid=SDPUtils.matchPrefix(mediaSection,"a=mid:")[0];if(mid){return mid.substr(6)}};SDPUtils.parseFingerprint=function(line){var parts=line.substr(14).split(" ");return{algorithm:parts[0].toLowerCase(),value:parts[1]}};SDPUtils.getDtlsParameters=function(mediaSection,sessionpart){var lines=SDPUtils.matchPrefix(mediaSection+sessionpart,"a=fingerprint:");return{role:"auto",fingerprints:lines.map(SDPUtils.parseFingerprint)}};SDPUtils.writeDtlsParameters=function(params,setupType){var sdp="a=setup:"+setupType+"\r\n";params.fingerprints.forEach(function(fp){sdp+="a=fingerprint:"+fp.algorithm+" "+fp.value+"\r\n"});return sdp};SDPUtils.getIceParameters=function(mediaSection,sessionpart){var lines=SDPUtils.splitLines(mediaSection);lines=lines.concat(SDPUtils.splitLines(sessionpart));var iceParameters={usernameFragment:lines.filter(function(line){return line.indexOf("a=ice-ufrag:")===0})[0].substr(12),password:lines.filter(function(line){return line.indexOf("a=ice-pwd:")===0})[0].substr(10)};return iceParameters};SDPUtils.writeIceParameters=function(params){return"a=ice-ufrag:"+params.usernameFragment+"\r\n"+"a=ice-pwd:"+params.password+"\r\n"};SDPUtils.parseRtpParameters=function(mediaSection){var description={codecs:[],headerExtensions:[],fecMechanisms:[],rtcp:[]};var lines=SDPUtils.splitLines(mediaSection);var mline=lines[0].split(" ");for(var i=3;i0?"9":"0";sdp+=" UDP/TLS/RTP/SAVPF ";sdp+=caps.codecs.map(function(codec){if(codec.preferredPayloadType!==undefined){return codec.preferredPayloadType}return codec.payloadType}).join(" ")+"\r\n";sdp+="c=IN IP4 0.0.0.0\r\n";sdp+="a=rtcp:9 IN IP4 0.0.0.0\r\n";caps.codecs.forEach(function(codec){sdp+=SDPUtils.writeRtpMap(codec);sdp+=SDPUtils.writeFmtp(codec);sdp+=SDPUtils.writeRtcpFb(codec)});var maxptime=0;caps.codecs.forEach(function(codec){if(codec.maxptime>maxptime){maxptime=codec.maxptime}});if(maxptime>0){sdp+="a=maxptime:"+maxptime+"\r\n"}sdp+="a=rtcp-mux\r\n";caps.headerExtensions.forEach(function(extension){sdp+=SDPUtils.writeExtmap(extension)});return sdp};SDPUtils.parseRtpEncodingParameters=function(mediaSection){var encodingParameters=[];var description=SDPUtils.parseRtpParameters(mediaSection);var hasRed=description.fecMechanisms.indexOf("RED")!==-1;var hasUlpfec=description.fecMechanisms.indexOf("ULPFEC")!==-1;var ssrcs=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(parts){return parts.attribute==="cname"});var primarySsrc=ssrcs.length>0&&ssrcs[0].ssrc;var secondarySsrc;var flows=SDPUtils.matchPrefix(mediaSection,"a=ssrc-group:FID").map(function(line){var parts=line.split(" ");parts.shift();return parts.map(function(part){return parseInt(part,10)})});if(flows.length>0&&flows[0].length>1&&flows[0][0]===primarySsrc){secondarySsrc=flows[0][1]}description.codecs.forEach(function(codec){if(codec.name.toUpperCase()==="RTX"&&codec.parameters.apt){var encParam={ssrc:primarySsrc,codecPayloadType:parseInt(codec.parameters.apt,10),rtx:{ssrc:secondarySsrc}};encodingParameters.push(encParam);if(hasRed){encParam=JSON.parse(JSON.stringify(encParam));encParam.fec={ssrc:secondarySsrc,mechanism:hasUlpfec?"red+ulpfec":"red"};encodingParameters.push(encParam)}}});if(encodingParameters.length===0&&primarySsrc){encodingParameters.push({ssrc:primarySsrc})}var bandwidth=SDPUtils.matchPrefix(mediaSection,"b=");if(bandwidth.length){if(bandwidth[0].indexOf("b=TIAS:")===0){bandwidth=parseInt(bandwidth[0].substr(7),10)}else if(bandwidth[0].indexOf("b=AS:")===0){bandwidth=parseInt(bandwidth[0].substr(5),10)*1e3*.95-50*40*8}else{bandwidth=undefined}encodingParameters.forEach(function(params){params.maxBitrate=bandwidth})}return encodingParameters};SDPUtils.parseRtcpParameters=function(mediaSection){var rtcpParameters={};var cname;var remoteSsrc=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(obj){return obj.attribute==="cname"})[0];if(remoteSsrc){rtcpParameters.cname=remoteSsrc.value;rtcpParameters.ssrc=remoteSsrc.ssrc}var rsize=SDPUtils.matchPrefix(mediaSection,"a=rtcp-rsize");rtcpParameters.reducedSize=rsize.length>0;rtcpParameters.compound=rsize.length===0;var mux=SDPUtils.matchPrefix(mediaSection,"a=rtcp-mux");rtcpParameters.mux=mux.length>0;return rtcpParameters};SDPUtils.parseMsid=function(mediaSection){var parts;var spec=SDPUtils.matchPrefix(mediaSection,"a=msid:");if(spec.length===1){parts=spec[0].substr(7).split(" ");return{stream:parts[0],track:parts[1]}}var planB=SDPUtils.matchPrefix(mediaSection,"a=ssrc:").map(function(line){return SDPUtils.parseSsrcMedia(line)}).filter(function(parts){return parts.attribute==="msid"});if(planB.length>0){parts=planB[0].value.split(" ");return{stream:parts[0],track:parts[1]}}};SDPUtils.generateSessionId=function(){return Math.random().toString().substr(2,21)};SDPUtils.writeSessionBoilerplate=function(sessId,sessVer){var sessionId;var version=sessVer!==undefined?sessVer:2;if(sessId){sessionId=sessId}else{sessionId=SDPUtils.generateSessionId()}return"v=0\r\n"+"o=thisisadapterortc "+sessionId+" "+version+" IN IP4 127.0.0.1\r\n"+"s=-\r\n"+"t=0 0\r\n"};SDPUtils.writeMediaSection=function(transceiver,caps,type,stream){var sdp=SDPUtils.writeRtpDescription(transceiver.kind,caps);sdp+=SDPUtils.writeIceParameters(transceiver.iceGatherer.getLocalParameters());sdp+=SDPUtils.writeDtlsParameters(transceiver.dtlsTransport.getLocalParameters(),type==="offer"?"actpass":"active");sdp+="a=mid:"+transceiver.mid+"\r\n";if(transceiver.direction){sdp+="a="+transceiver.direction+"\r\n"}else if(transceiver.rtpSender&&transceiver.rtpReceiver){sdp+="a=sendrecv\r\n"}else if(transceiver.rtpSender){sdp+="a=sendonly\r\n"}else if(transceiver.rtpReceiver){sdp+="a=recvonly\r\n"}else{sdp+="a=inactive\r\n"}if(transceiver.rtpSender){var msid="msid:"+stream.id+" "+transceiver.rtpSender.track.id+"\r\n";sdp+="a="+msid;sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" "+msid;if(transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" "+msid;sdp+="a=ssrc-group:FID "+transceiver.sendEncodingParameters[0].ssrc+" "+transceiver.sendEncodingParameters[0].rtx.ssrc+"\r\n"}}sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].ssrc+" cname:"+SDPUtils.localCName+"\r\n";if(transceiver.rtpSender&&transceiver.sendEncodingParameters[0].rtx){sdp+="a=ssrc:"+transceiver.sendEncodingParameters[0].rtx.ssrc+" cname:"+SDPUtils.localCName+"\r\n"}return sdp};SDPUtils.getDirection=function(mediaSection,sessionpart){var lines=SDPUtils.splitLines(mediaSection);for(var i=0;i=len)return x;switch(x){case"%s":return String(args[i++]);case"%d":return Number(args[i++]);case"%j":try{return JSON.stringify(args[i++])}catch(_){return"[Circular]"}default:return x}});for(var x=args[i];i=3)ctx.depth=arguments[2];if(arguments.length>=4)ctx.colors=arguments[3];if(isBoolean(opts)){ctx.showHidden=opts}else if(opts){exports._extend(ctx,opts)}if(isUndefined(ctx.showHidden))ctx.showHidden=false;if(isUndefined(ctx.depth))ctx.depth=2;if(isUndefined(ctx.colors))ctx.colors=false;if(isUndefined(ctx.customInspect))ctx.customInspect=true;if(ctx.colors)ctx.stylize=stylizeWithColor;return formatValue(ctx,obj,ctx.depth)}exports.inspect=inspect;inspect.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]};inspect.styles={special:"cyan",number:"yellow",boolean:"yellow",undefined:"grey",null:"bold",string:"green",date:"magenta",regexp:"red"};function stylizeWithColor(str,styleType){var style=inspect.styles[styleType];if(style){return"\x1b["+inspect.colors[style][0]+"m"+str+"\x1b["+inspect.colors[style][1]+"m"}else{return str}}function stylizeNoColor(str,styleType){return str}function arrayToHash(array){var hash={};array.forEach(function(val,idx){hash[val]=true});return hash}function formatValue(ctx,value,recurseTimes){if(ctx.customInspect&&value&&isFunction(value.inspect)&&value.inspect!==exports.inspect&&!(value.constructor&&value.constructor.prototype===value)){var ret=value.inspect(recurseTimes,ctx);if(!isString(ret)){ret=formatValue(ctx,ret,recurseTimes)}return ret}var primitive=formatPrimitive(ctx,value);if(primitive){return primitive}var keys=Object.keys(value);var visibleKeys=arrayToHash(keys);if(ctx.showHidden){keys=Object.getOwnPropertyNames(value)}if(isError(value)&&(keys.indexOf("message")>=0||keys.indexOf("description")>=0)){return formatError(value)}if(keys.length===0){if(isFunction(value)){var name=value.name?": "+value.name:"";return ctx.stylize("[Function"+name+"]","special")}if(isRegExp(value)){return ctx.stylize(RegExp.prototype.toString.call(value),"regexp")}if(isDate(value)){return ctx.stylize(Date.prototype.toString.call(value),"date")}if(isError(value)){return formatError(value)}}var base="",array=false,braces=["{","}"];if(isArray(value)){array=true;braces=["[","]"]}if(isFunction(value)){var n=value.name?": "+value.name:"";base=" [Function"+n+"]"}if(isRegExp(value)){base=" "+RegExp.prototype.toString.call(value)}if(isDate(value)){base=" "+Date.prototype.toUTCString.call(value)}if(isError(value)){base=" "+formatError(value)}if(keys.length===0&&(!array||value.length==0)){return braces[0]+base+braces[1]}if(recurseTimes<0){if(isRegExp(value)){return ctx.stylize(RegExp.prototype.toString.call(value),"regexp")}else{return ctx.stylize("[Object]","special")}}ctx.seen.push(value);var output;if(array){output=formatArray(ctx,value,recurseTimes,visibleKeys,keys)}else{output=keys.map(function(key){return formatProperty(ctx,value,recurseTimes,visibleKeys,key,array)})}ctx.seen.pop();return reduceToSingleString(output,base,braces)}function formatPrimitive(ctx,value){if(isUndefined(value))return ctx.stylize("undefined","undefined");if(isString(value)){var simple="'"+JSON.stringify(value).replace(/^"|"$/g,"").replace(/'/g,"\\'").replace(/\\"/g,'"')+"'";return ctx.stylize(simple,"string")}if(isNumber(value))return ctx.stylize(""+value,"number");if(isBoolean(value))return ctx.stylize(""+value,"boolean");if(isNull(value))return ctx.stylize("null","null")}function formatError(value){return"["+Error.prototype.toString.call(value)+"]"}function formatArray(ctx,value,recurseTimes,visibleKeys,keys){var output=[];for(var i=0,l=value.length;i-1){if(array){str=str.split("\n").map(function(line){return" "+line}).join("\n").substr(2)}else{str="\n"+str.split("\n").map(function(line){return" "+line}).join("\n")}}}else{str=ctx.stylize("[Circular]","special")}}if(isUndefined(name)){if(array&&key.match(/^\d+$/)){return str}name=JSON.stringify(""+key);if(name.match(/^"([a-zA-Z_][a-zA-Z_0-9]*)"$/)){name=name.substr(1,name.length-2);name=ctx.stylize(name,"name")}else{name=name.replace(/'/g,"\\'").replace(/\\"/g,'"').replace(/(^"|"$)/g,"'");name=ctx.stylize(name,"string")}}return name+": "+str}function reduceToSingleString(output,base,braces){var numLinesEst=0;var length=output.reduce(function(prev,cur){numLinesEst++;if(cur.indexOf("\n")>=0)numLinesEst++;return prev+cur.replace(/\u001b\[\d\d?m/g,"").length+1},0);if(length>60){return braces[0]+(base===""?"":base+"\n ")+" "+output.join(",\n ")+" "+braces[1]}return braces[0]+base+" "+output.join(", ")+" "+braces[1]}function isArray(ar){return Array.isArray(ar)}exports.isArray=isArray;function isBoolean(arg){return typeof arg==="boolean"}exports.isBoolean=isBoolean;function isNull(arg){return arg===null}exports.isNull=isNull;function isNullOrUndefined(arg){return arg==null}exports.isNullOrUndefined=isNullOrUndefined;function isNumber(arg){return typeof arg==="number"}exports.isNumber=isNumber;function isString(arg){return typeof arg==="string"}exports.isString=isString;function isSymbol(arg){return typeof arg==="symbol"}exports.isSymbol=isSymbol;function isUndefined(arg){return arg===void 0}exports.isUndefined=isUndefined;function isRegExp(re){return isObject(re)&&objectToString(re)==="[object RegExp]"}exports.isRegExp=isRegExp;function isObject(arg){return typeof arg==="object"&&arg!==null}exports.isObject=isObject;function isDate(d){return isObject(d)&&objectToString(d)==="[object Date]"}exports.isDate=isDate;function isError(e){return isObject(e)&&(objectToString(e)==="[object Error]"||e instanceof Error)}exports.isError=isError;function isFunction(arg){return typeof arg==="function"}exports.isFunction=isFunction;function isPrimitive(arg){return arg===null||typeof arg==="boolean"||typeof arg==="number"||typeof arg==="string"||typeof arg==="symbol"||typeof arg==="undefined"}exports.isPrimitive=isPrimitive;exports.isBuffer=require("./support/isBuffer");function objectToString(o){return Object.prototype.toString.call(o)}function pad(n){return n<10?"0"+n.toString(10):n.toString(10)}var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];function timestamp(){var d=new Date;var time=[pad(d.getHours()),pad(d.getMinutes()),pad(d.getSeconds())].join(":");return[d.getDate(),months[d.getMonth()],time].join(" ")}exports.log=function(){console.log("%s - %s",timestamp(),exports.format.apply(exports,arguments))};exports.inherits=require("inherits");exports._extend=function(origin,add){if(!add||!isObject(add))return origin;var keys=Object.keys(add);var i=keys.length;while(i--){origin[keys[i]]=add[keys[i]]}return origin};function hasOwnProperty(obj,prop){return Object.prototype.hasOwnProperty.call(obj,prop)}}).call(this,require("_process"),typeof global!=="undefined"?global:typeof self!=="undefined"?self:typeof window!=="undefined"?window:{})},{"./support/isBuffer":54,_process:48,inherits:53}],56:[function(require,module,exports){module.exports={name:"twilio-client",version:"1.4.32",description:"Javascript SDK for Twilio Client",homepage:"https://www.twilio.com/docs/client/twilio-js",main:"./es5/twilio.js",license:"Apache-2.0",repository:{type:"git",url:"git@code.hq.twilio.com:client/twiliojs.git"},scripts:{build:"npm-run-all clean build:es5 build:ts build:dist build:dist-min","build:es5":"rimraf ./es5 && babel lib -d es5","build:dist":"node ./scripts/build.js ./lib/browser.js ./LICENSE.md ./dist/twilio.js","build:dist-min":'uglifyjs ./dist/twilio.js -o ./dist/twilio.min.js --comments "/^! twilio-client.js/" -b beautify=false,ascii_only=true',"build:travis":"npm-run-all lint build test:unit test:webpack test:es5","build:ts":"tsc",clean:"rimraf ./coverage ./dist ./es5",coverage:"nyc --reporter=html ./node_modules/mocha/bin/mocha --reporter=spec tests/index.js",extension:"browserify -t brfs extension/token/index.js > extension/token.js",lint:"npm-run-all lint:js lint:ts","lint:js":"eslint lib","lint:ts":"tslint -c tslint.json --project tsconfig.json -t stylish",release:"release",start:"node server.js",test:"npm-run-all test:unit test:frameworks","test:es5":'es-check es5 "./es5/**/*.js" ./dist/*.js',"test:framework:no-framework":"mocha tests/framework/no-framework.js","test:framework:react:install":"cd ./tests/framework/react && rimraf ./node_modules package-lock.json && npm install","test:framework:react:build":"cd ./tests/framework/react && npm run build","test:framework:react:run":"mocha ./tests/framework/react.js","test:framework:react":"npm-run-all test:framework:react:*","test:frameworks":"npm-run-all test:framework:no-framework test:framework:react","test:selenium":"mocha tests/browser/index.js","test:unit":"mocha --reporter=spec -r ts-node/register ./tests/index.ts","test:webpack":"cd ./tests/webpack && npm install && npm test"},devDependencies:{"@types/mocha":"^5.0.0","@types/node":"^9.6.5","@types/ws":"^4.0.2","babel-cli":"^6.26.0","babel-eslint":"^8.2.2","babel-plugin-transform-class-properties":"^6.24.1","babel-preset-es2015":"^6.24.1",brfs:"^1.4.3",browserify:"^14.3.0",chromedriver:"^2.31.0",envify:"2.0.1","es-check":"^2.0.3",eslint:"^4.19.1","eslint-plugin-babel":"^4.1.2",express:"^4.14.1",geckodriver:"^1.8.1","js-yaml":"^3.9.1",jsonwebtoken:"^7.4.3",lodash:"^4.17.4",mocha:"^3.5.0","npm-run-all":"^4.1.2",nyc:"^10.1.2",querystring:"^0.2.0","release-tool":"^0.2.2","selenium-webdriver":"^3.5.0",sinon:"^4.0.0","ts-node":"^6.0.0",tslint:"^5.9.1",twilio:"^2.11.1",typescript:"^2.8.1","uglify-js":"^3.3.11","vinyl-fs":"^3.0.2","vinyl-source-stream":"^2.0.0"},dependencies:{AudioPlayer:"git+https://github.com/twilio/AudioPlayer.git#1.0.1",backoff:"^2.5.0","rtcpeerconnection-shim":"^1.2.8",ws:"0.4.31",xmlhttprequest:"^1.8.0"},browser:{xmlhttprequest:"./browser/xmlhttprequest.js",ws:"./browser/ws.js"}}},{}]},{},[3]);var Voice=bundle(3);if(typeof define==="function"&&define.amd){define([],function(){return Voice})}else{var Twilio=root.Twilio=root.Twilio||{};Twilio.Connection=Twilio.Connection||Voice.Connection;Twilio.Device=Twilio.Device||Voice.Device;Twilio.PStream=Twilio.PStream||Voice.PStream}})(typeof window!=="undefined"?window:typeof global!=="undefined"?global:this);
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/static/src/js/widget.js b/voip_sip_webrtc_twilio/static/src/js/widget.js
new file mode 100644
index 000000000..7affeb578
--- /dev/null
+++ b/voip_sip_webrtc_twilio/static/src/js/widget.js
@@ -0,0 +1,274 @@
+odoo.define('voip_sip_webrtc_twilio.voip_twilio_call_notification', function (require) {
+"use strict";
+
+var core = require('web.core');
+var framework = require('web.framework');
+var rpc = require('web.rpc');
+var weContext = require('web_editor.context');
+var odoo_session = require('web.session');
+var web_client = require('web.web_client');
+var Widget = require('web.Widget');
+var ajax = require('web.ajax');
+var bus = require('bus.bus').bus;
+var Notification = require('web.notification').Notification;
+var WebClient = require('web.WebClient');
+var SystrayMenu = require('web.SystrayMenu');
+var _t = core._t;
+var qweb = core.qweb;
+var ActionManager = require('web.ActionManager');
+
+var call_conn;
+var myNotif = "";
+var secondsLeft;
+var incoming_ring_interval;
+var mySound = "";
+
+$(function() {
+
+ // Renew the token every 55 seconds
+ var myJWTTimer = setInterval(renewJWT, 55000);
+ renewJWT();
+
+ function renewJWT() {
+ rpc.query({
+ model: 'voip.number',
+ method: 'get_numbers',
+ args: [],
+ context: weContext.get()
+ }).then(function(result){
+
+ for (var i = 0; i < result.length; i++) {
+ var call_route = result[i];
+
+ console.log("Signing in as " + call_route.capability_token_url);
+
+ $.getJSON(call_route.capability_token_url).done(function (data) {
+ console.log('Got a token.');
+ console.log('Token: ' + data.token);
+
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+
+ Twilio.Device.ready(function (device) {
+ console.log('Twilio.Device Ready!');
+ });
+
+ })
+ .fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ }
+
+ });
+ }
+
+});
+
+
+// Bind to end call button
+$(document).on("click", "#voip_end_call", function(){
+ console.log('Hanging up...');
+ twilio_end_call();
+});
+
+function twilio_end_call() {
+ console.log('Call ended.');
+ $("#voip_text").html("Starting Call...");
+ $(".s-voip-manager").css("display","none");
+
+ console.log(twilio_call_sid);
+ if (twilio_call_sid != null) {
+ rpc.query({
+ model: 'voip.call',
+ method: 'add_twilio_call',
+ args: [[voip_call_id], twilio_call_sid],
+ context: weContext.get()
+ }).then(function(result){
+ console.log("Finished Updating Twilio Call");
+ sw_acton_manager.do_action({
+ name: 'Twilio Call Comments',
+ type: 'ir.actions.act_window',
+ res_model: 'voip.call.comment',
+ views: [[false, 'form']],
+ context: {'default_call_id': voip_call_id},
+ target: 'new'
+ });
+ });
+ } else {
+ console.log("Call Failed");
+ }
+
+ Twilio.Device.disconnectAll();
+}
+
+var twilio_call_sid;
+var voip_call_id;
+var sw_acton_manager;
+
+Twilio.Device.connect(function (conn) {
+ console.log('Successfully established call!');
+
+ twilio_call_sid = conn.parameters.CallSid;
+ console.log(twilio_call_sid);
+
+ $(".s-voip-manager").css("display","block");
+
+ var startDate = new Date();
+ var call_interval;
+
+ if (mySound != "") {
+ mySound.pause();
+ mySound.currentTime = 0;
+ }
+
+ call_interval = setInterval(function() {
+ var endDate = new Date();
+ var seconds = (endDate.getTime() - startDate.getTime()) / 1000;
+ $("#voip_text").html( Math.round(seconds) + " seconds");
+ }, 1000);
+});
+
+Twilio.Device.disconnect(function (conn) {
+ twilio_end_call();
+});
+
+Twilio.Device.incoming(function (conn) {
+ console.log('Incoming connection from ' + conn.parameters.From);
+
+ //Set it on a global scale because we we need it when the call it accepted or rejected inside the incoming call dialog
+ call_conn = conn;
+
+ //Poll the server so we can find who the call is from + ringtone
+ rpc.query({
+ model: 'res.users',
+ method: 'get_call_details',
+ args: [[odoo_session.uid], conn],
+ context: weContext.get()
+ }).then(function(result){
+
+ //Open the incoming call dialog
+ var self = this;
+
+ var from_name = result.from_name;
+ var ringtone = result.ringtone;
+ var caller_partner_id = result.caller_partner_id;
+ window.countdown = result.ring_duration;
+
+ var notif_text = from_name + " wants you to join a mobile call";
+
+ window.voip_call_id = result.voip_call_id
+
+ var incomingNotification = new VoipTwilioCallIncomingNotification(window.swnotification_manager, "Incoming Call", notif_text, 0);
+ window.swnotification_manager.display(incomingNotification);
+ mySound = new Audio(ringtone);
+ mySound.loop = true;
+ mySound.play();
+
+ //Display an image of the person who is calling
+ $("#voipcallincomingimage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+ $("#toPartnerImage").attr('src', '/web/image/res.partner/' + caller_partner_id + '/image_medium/image.jpg');
+
+ });
+
+});
+
+Twilio.Device.error(function (error) {
+ console.log('Twilio.Device Error: ' + error.message);
+});
+
+var VoipTwilioCallIncomingNotification = Notification.extend({
+ template: "VoipCallIncomingNotification",
+
+ init: function(parent, title, text, call_id) {
+ this._super(parent, title, text, true);
+
+
+ this.events = _.extend(this.events || {}, {
+ 'click .link2accept': function() {
+
+ call_conn.accept();
+
+ this.destroy(true);
+ },
+
+ 'click .link2reject': function() {
+
+ call_conn.reject();
+
+ this.destroy(true);
+ },
+ });
+ },
+ start: function() {
+ myNotif = this;
+ this._super.apply(this, arguments);
+ secondsLeft = window.countdown;
+ $("#callsecondsincomingleft").html(secondsLeft);
+
+ incoming_ring_interval = setInterval(function() {
+ $("#callsecondsincomingleft").html(secondsLeft);
+ if (secondsLeft == 0) {
+ mySound.pause();
+ mySound.currentTime = 0;
+ clearInterval(incoming_ring_interval);
+ myNotif.destroy(true);
+ }
+
+ secondsLeft--;
+ }, 1000);
+
+ },
+});
+
+WebClient.include({
+
+ show_application: function() {
+
+ window.swnotification_manager = this.notification_manager;
+ //Because this no longer referes to the action manager for the disconnect callback
+ sw_acton_manager = this;
+
+ bus.on('notification', this, function (notifications) {
+ _.each(notifications, (function (notification) {
+
+ if (notification[0][1] === 'voip.twilio.start') {
+ var self = this;
+
+ var from_number = notification[1].from_number;
+ var to_number = notification[1].to_number;
+ var capability_token_url = notification[1].capability_token_url;
+ voip_call_id = notification[1].call_id;
+
+ console.log("Call Type: Twilio");
+
+ //Make the audio call
+ console.log("Twilio audio calling: " + to_number);
+
+ var params = {
+ From: from_number,
+ To: to_number
+ };
+
+ console.log('Calling ' + params.To + '...');
+
+ $.getJSON(capability_token_url).done(function (data) {
+ // Setup Twilio.Device
+ Twilio.Device.setup(data.token, { debug: true });
+ Twilio.Device.connect(params);
+ }).fail(function () {
+ console.log('Could not get a token from server!');
+ });
+
+ }
+
+
+ }).bind(this));
+
+ });
+ return this._super.apply(this, arguments);
+ },
+
+});
+
+});
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/crm_lead_views.xml b/voip_sip_webrtc_twilio/views/crm_lead_views.xml
new file mode 100644
index 000000000..6c8fd7399
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/crm_lead_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Twilio Call Lead Mobile
+
+
+ True
+ code
+ action = record.twilio_mobile_action()
+
+
+
+ crm.lead Twilio
+ crm.lead
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/mail_activity_views.xml b/voip_sip_webrtc_twilio/views/mail_activity_views.xml
new file mode 100644
index 000000000..72a9ad8b9
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/mail_activity_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ mail.activity Twilio
+ mail.activity
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/menus.xml b/voip_sip_webrtc_twilio/views/menus.xml
new file mode 100644
index 000000000..56194274e
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/menus.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/res_partner_views.xml b/voip_sip_webrtc_twilio/views/res_partner_views.xml
new file mode 100644
index 000000000..b8c8150ef
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/res_partner_views.xml
@@ -0,0 +1,25 @@
+
+
+
+
+ Twilio Call Mobile
+
+
+ True
+ code
+ action = record.twilio_mobile_action()
+
+
+
+ res.partner Twilio
+ res.partner
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/res_users_views.xml b/voip_sip_webrtc_twilio/views/res_users_views.xml
new file mode 100644
index 000000000..e4b4dc52f
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/res_users_views.xml
@@ -0,0 +1,16 @@
+
+
+
+
+ res.users Voip TWilio
+ res.users
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_account_action_views.xml b/voip_sip_webrtc_twilio/views/voip_account_action_views.xml
new file mode 100644
index 000000000..5429925cb
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_account_action_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ voip.account.action inherit twilio form view
+ voip.account.action
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_call_comment_views.xml b/voip_sip_webrtc_twilio/views/voip_call_comment_views.xml
new file mode 100644
index 000000000..3542d4740
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_call_comment_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ voip.call.comment form view
+ voip.call.comment
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_call_views.xml b/voip_sip_webrtc_twilio/views/voip_call_views.xml
new file mode 100644
index 000000000..f6ac9f772
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_call_views.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ voip.call tree view inherit twilio
+ voip.call
+
+
+
+
+
+
+
+
+
+
+
+ voip.call form view inherit twilio
+ voip.call
+
+
+
+
+
+
+
+
+
+
+
+
+
+ view.call.view.search
+ voip.call
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_call_wizard_views.xml b/voip_sip_webrtc_twilio/views/voip_call_wizard_views.xml
new file mode 100644
index 000000000..eec79b549
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_call_wizard_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ voip.call.wizard form view
+ voip.call.wizard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_number_views.xml b/voip_sip_webrtc_twilio/views/voip_number_views.xml
new file mode 100644
index 000000000..b09f6a733
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_number_views.xml
@@ -0,0 +1,49 @@
+
+
+
+
+ voip.number view form
+ voip.number
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ voip.number view tree
+ voip.number
+
+
+
+
+
+
+
+
+
+
+ VOIP Number
+ voip.number
+ tree,form
+
+
+ No Voip Numbers
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_sip_webrtc_twilio_templates.xml b/voip_sip_webrtc_twilio/views/voip_sip_webrtc_twilio_templates.xml
new file mode 100644
index 000000000..3e28b8932
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_sip_webrtc_twilio_templates.xml
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Description
+
Source Document
+
Number of Calls
+
Price
+
Disc.(%)
+
Taxes
+
Amount
+
+
+
Description
+
Source Document
+
Quantity
+
Unit Price
+
Disc.(%)
+
Taxes
+
Amount
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Call Log
+
+
Time
Duration
Address
Cost
+
+
+
+
+
+
+ Total Time:
+ Total Cost:
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_twilio_invoice_views.xml b/voip_sip_webrtc_twilio/views/voip_twilio_invoice_views.xml
new file mode 100644
index 000000000..a9cd5f780
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_twilio_invoice_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ voip.twilio.invoice form view
+ voip.twilio.invoice
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/voip_sip_webrtc_twilio/views/voip_twilio_views.xml b/voip_sip_webrtc_twilio/views/voip_twilio_views.xml
new file mode 100644
index 000000000..75eb40cb3
--- /dev/null
+++ b/voip_sip_webrtc_twilio/views/voip_twilio_views.xml
@@ -0,0 +1,54 @@
+
+
+
+
+ voip.twilio form view
+ voip.twilio
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/__init__.py b/website_business_directory/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/website_business_directory/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/website_business_directory/__manifest__.py b/website_business_directory/__manifest__.py
new file mode 100644
index 000000000..25b841cc4
--- /dev/null
+++ b/website_business_directory/__manifest__.py
@@ -0,0 +1,39 @@
+# -*- coding: utf-8 -*-
+{
+ 'name': "Website Business Directory",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "A directory of local companies",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/website_business_directory_templates.xml',
+ 'views/res_partner_views.xml',
+ 'views/res_users_views.xml',
+ 'views/res_partner_directory_department_views.xml',
+ 'views/website_directory_category_views.xml',
+ 'views/website_directory_level_views.xml',
+ 'views/website_directory_billingplan_views.xml',
+ 'views/res_country_state_city_import_views.xml',
+ 'views/res_country_state_city_views.xml',
+ 'views/website_directory_template_views.xml',
+ 'views/menus.xml',
+ 'data/website.menu.csv',
+ 'data/res.groups.csv',
+ 'data/website.directory.level.csv',
+ 'data/res.users.directory.level.xml',
+ 'data/website.page.csv',
+ 'templates/default/website.directory.template.csv',
+ 'templates/default/website.directory.template.page.csv',
+ 'templates/individual/templates.xml',
+ 'templates/individual/website.directory.template.csv',
+ 'templates/individual/website.directory.template.page.csv',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [],
+ 'depends': ['mail','website'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/website_business_directory/controllers/__init__.py b/website_business_directory/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/website_business_directory/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/website_business_directory/controllers/main.py b/website_business_directory/controllers/main.py
new file mode 100644
index 000000000..6e635530b
--- /dev/null
+++ b/website_business_directory/controllers/main.py
@@ -0,0 +1,408 @@
+# -*- coding: utf-8 -*-
+import requests
+import werkzeug
+from datetime import datetime
+import time
+import json
+import math
+import base64
+import urllib
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo.addons.http_routing.models.ir_http import slug
+
+import odoo.http as http
+from odoo.http import request
+
+class WebsiteBusinessDiretoryController(http.Controller):
+
+ @http.route('/directory', type="http", auth="public", website=True)
+ def directory_search(self, **kwargs):
+ featured_listings = request.env['res.partner'].sudo().search([('in_directory','=', True), ('featured_listing','=',True) ])
+ regular_listings = request.env['res.partner'].sudo().search([('in_directory','=', True), ('featured_listing','=',False) ])
+ google_maps_api_key = request.env['ir.config_parameter'].sudo().get_param('google_maps_api_key')
+ directory_categories = request.env['res.partner.directory.category'].sudo().search([('parent_category','=',False)])
+
+ page_directory_search = request.env['ir.model.data'].get_object('website_business_directory', 'page_directory_search')
+ return http.request.render(page_directory_search.view_id.id, {'featured_listings': featured_listings, 'regular_listings': regular_listings, 'google_maps_api_key': google_maps_api_key, 'directory_categories': directory_categories} )
+
+ @http.route('/directory/register', type="http", auth="public", website=True)
+ def directory_register(self, **kwargs):
+ page_directory_register = request.env['ir.model.data'].get_object('website_business_directory','page_directory_register')
+ return http.request.render(page_directory_register.view_id.id, {} )
+
+ @http.route('/directory/register/process', type="http", auth="public", website=True)
+ def directory_register_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ if values['password'] != values['password_again']:
+ return "Password does not match"
+
+ #Create the new user
+ directory_free_account = request.env['ir.model.data'].get_object('website_business_directory','directory_free_account')
+ new_user = request.env['res.users'].sudo().create({'name': values['name'], 'login': values['email'], 'email': values['email'], 'password': values['password'], 'directory_level_id': directory_free_account.id})
+
+ #Add the user to the business directory group
+ directory_group = request.env['ir.model.data'].sudo().get_object('website_business_directory', 'directory_group')
+ directory_group.users = [(4, new_user.id)]
+
+ #Remove 'Contact Creation' permission
+ contact_creation_group = request.env['ir.model.data'].sudo().get_object('base', 'group_partner_manager')
+ contact_creation_group.users = [(3,new_user.id)]
+
+ #Also remove them as an employee
+ human_resources_group = request.env['ir.model.data'].sudo().get_object('base', 'group_user')
+ human_resources_group.users = [(3,new_user.id)]
+
+ #Automatically sign the new user in
+ request.cr.commit() # as authenticate will use its own cursor we need to commit the current transaction
+ request.session.authenticate(request.env.cr.dbname, values['email'], values['password'])
+
+ #Redirect them to thier account page
+ return werkzeug.utils.redirect("/directory/account")
+
+ @http.route('/directory/account', type='http', auth="user", website=True)
+ def directory_account(self, **kwargs):
+ businesses = request.env['res.partner'].sudo().search([('in_directory','=', True), ('business_owner','=', request.env.user.id)])
+ return http.request.render('website_business_directory.directory_account', {'businesses': businesses} )
+
+ @http.route('/directory/account/business/add', type='http', auth="user", website=True)
+ def directory_account_business_add(self, **kwargs):
+ countries = request.env['res.country'].search([])
+ states = request.env['res.country.state'].search([])
+
+ page_directory_account_business_add = request.env['ir.model.data'].get_object('website_business_directory','page_directory_account_business_add')
+ return http.request.render(page_directory_account_business_add.view_id.id, {'countries': countries,'states': states} )
+
+ @http.route('/directory/state/fetch', type='http', auth="public", website=True)
+ def directory_state_fetch(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ return_string = ""
+ return_string += "\n"
+ for state in request.env['res.country.state'].sudo().search([('country_id','=', int(values['country']) )]):
+ return_string += "\n"
+
+ return return_string
+
+ @http.route('/directory/account/business/add/process', type='http', auth="user", website=True)
+ def directory_account_business_add_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ business_logo = base64.encodestring(values['logo'].read() )
+
+ insert_values = {'business_owner': request.env.user.id, 'in_directory': True, 'name': values['name']}
+
+ insert_values['image'] = business_logo
+ if 'email' in values: insert_values['email'] = values['email']
+ if 'street' in values: insert_values['street'] = values['street']
+
+ if 'city' in values:
+ insert_values['city'] = values['city']
+ auto_city = request.env['res.country.state.city'].search([('state_id','=',int(values['state'])), ('name','=ilike',values['city'])])
+ if auto_city:
+ insert_values['city_id'] = auto_city.id
+ insert_values['latitude'] = auto_city.latitude
+ insert_values['longitude'] = auto_city.longitude
+ else:
+ return "City Not Found"
+
+ if 'state' in values: insert_values['state_id'] = values['state']
+ if 'country' in values: insert_values['country_id'] = values['country']
+ if 'zip' in values: insert_values['zip'] = values['zip']
+ if 'directory_description' in values: insert_values['directory_description'] = values['directory_description']
+ if 'website' in values: insert_values['website'] = values['website']
+ if 'allow_restaurant_booking' in values: insert_values['allow_restaurant_booking'] = True
+ if 'facebook_url' in values: insert_values['facebook_url'] = values['facebook_url']
+ if 'twitter_url' in values: insert_values['twitter_url'] = values['twitter_url']
+ if 'youtube_url' in values: insert_values['youtube_url'] = values['youtube_url']
+ if 'instagram_url' in values: insert_values['instagram_url'] = values['instagram_url']
+
+ insert_values['directory_listing_open_hours'] = []
+
+ days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday']
+
+ for day in days:
+ if 'directory_' + day + '_start' in values and 'directory_' + day + '_end' in values:
+ if values['directory_' + day + '_start'] != "" and values['directory_' + day + '_end'] != "":
+ start_time = time.strptime(values['directory_' + day + '_start'] + values['directory_' + day + '_start_period'], "%I:%M%p")
+ start_float = start_time[3] + float(start_time[4]) / 60
+ insert_values['directory_' + day + '_start'] = start_float
+
+ end_time = time.strptime(values['directory_' + day + '_end'] + values['directory_' + day + '_end_period'], "%I:%M%p")
+ end_float = end_time[3] + float(end_time[4]) / 60
+ insert_values['directory_' + day + '_end'] = end_float
+
+ insert_values['directory_listing_open_hours'].append( (0, 0, { 'day': day, 'start_time': start_float, 'end_time': end_float}) )
+
+ new_listing = request.env['res.partner'].sudo().create(insert_values)
+
+ #Redirect them to thier account page
+ return werkzeug.utils.redirect("/directory/account")
+
+ @http.route('/directory/account/business/edit/', type='http', auth="user", website=True)
+ def directory_account_business_edit(self, directory_company, **kwargs):
+ if directory_company.in_directory and directory_company.business_owner.id == request.env.user.id:
+ countries = request.env['res.country'].search([])
+ states = request.env['res.country.state'].search([])
+
+ page_directory_account_business_edit = request.env['ir.model.data'].get_object('website_business_directory', 'page_directory_account_business_edit')
+ return http.request.render(page_directory_account_business_edit.view_id.id, {'directory_company': directory_company, 'countries': countries,'states': states} )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/account/business/edit/process', type='http', auth="user", website=True)
+ def directory_account_business_edit_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ business_logo = base64.encodestring(values['logo'].read() )
+
+ existing_record = request.env['res.partner'].browse( int(values['business_id'] ) )
+
+ if existing_record.in_directory and existing_record.business_owner.id == request.env.user.id:
+ updated_listing = existing_record.sudo().write({'name': values['name'], 'email': values['email'], 'street': values['street'], 'city': values['city'], 'state_id': values['state'], 'country_id': values['country'], 'zip': values['zip'], 'directory_description': values['description'], 'directory_monday_start': values['directory_monday_start'], 'directory_monday_end': values['directory_monday_end'], 'directory_tuesday_start': values['directory_tuesday_start'], 'directory_tuesday_end': values['directory_tuesday_end'], 'directory_wednbesday_start': values['directory_wednesday_start'], 'directory_wednbesday_end': values['directory_wednesday_end'], 'directory_thursday_start': values['directory_thursday_start'], 'directory_thursday_end': values['directory_thursday_end'], 'directory_friday_start': values['directory_friday_start'], 'directory_friday_end': values['directory_friday_end'], 'directory_saturday_start': values['directory_saturday_start'], 'directory_saturday_end': values['directory_saturday_end'], 'directory_sunday_start': values['directory_sunday_start'], 'directory_sunday_end': values['directory_sunday_end'], 'allow_restaurant_booking': values['allow_restaurant_booking'], 'image': business_logo })
+
+ #Redirect them to thier account page
+ return werkzeug.utils.redirect("/directory/account")
+ else:
+ return "Permission Denied"
+
+ @http.route('/directory/account/business/upgrade/', type='http', auth="user", website=True)
+ def directory_account_business_upgrade(self, directory_company, **kwargs):
+ if directory_company.in_directory and directory_company.business_owner.id == request.env.user.id:
+ plan_levels = request.env['website.directory.level'].search([('id','!=', directory_company.listing_level.id)])
+ return http.request.render('website_business_directory.directory_account_business_upgrade', {'directory_company': directory_company, 'plan_levels': plan_levels} )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/account/business/upgrade/process', type='http', auth="user", website=True)
+ def directory_account_business_upgrade_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ existing_record = request.env['res.partner'].browse( int(values['business_id'] ) )
+
+ if existing_record.in_directory and existing_record.business_owner.id == request.env.user.id:
+
+ #paypal_url = "https://api-3t.paypal.com/nvp?"
+ paypal_url = "https://api-3t.sandbox.paypal.com/nvp?"
+
+ #Submit details to paypal
+ return werkzeug.utils.redirect(paypal_url)
+ else:
+ return "Permission Denied"
+
+ @http.route('/directory/review/process', type='http', auth="public", website=True)
+ def directory_review_process(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ directory_company = request.env['res.partner'].sudo().browse( int(values['business_id']) )
+
+ if directory_company.in_directory:
+ if int(values['rating']) >= 1 and int(values['rating']) <= 5:
+ request.env['res.partner.directory.review'].create({'business_id': values['business_id'], 'name': values['name'], 'description': values['description'], 'rating': values['rating'] })
+ return werkzeug.utils.redirect("/directory/company/" + slug(directory_company) )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/company//website', type='http', auth="public", website=True)
+ def directory_company_page_website(self, directory_company, **kwargs):
+ if directory_company.in_directory:
+ #isocountry = request.session.geoip and request.session.geoip.get('country_code') or False
+ request.env['website.directory.stat.website'].sudo().create({'listing_id': directory_company.id, 'ip': request.httprequest.remote_addr})
+
+ return werkzeug.utils.redirect(directory_company.website)
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/company/', type='http', auth="public", website=True)
+ def directory_company_page(self, directory_company, **kwargs):
+ if directory_company.in_directory:
+ return http.request.render('website_business_directory.directory_company_page', {'directory_company': directory_company} )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/company//booking', type='http', auth="public", website=True)
+ def directory_company_booking(self, directory_company, **kwargs):
+ if directory_company.in_directory:
+ return http.request.render('website_business_directory.directory_company_booking', {'directory_company': directory_company} )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/company//menu', type='http', auth="public", website=True)
+ def directory_company_menu(self, directory_company, **kwargs):
+ if directory_company.in_directory:
+ return http.request.render('website_business_directory.directory_company_menu', {'directory_company': directory_company} )
+ else:
+ return "ACCESS DENIED"
+
+ @http.route('/directory/company/booking/process', type='http', auth="public", website=True)
+ def directory_company_booking_process(self, **kwargs):
+ """Insert the booking into the database then notify the restaurant of the booking via thier preferred notification method(email only atm)"""
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ directory_company = request.env['res.partner'].sudo().browse( int(values['business_id']) )
+
+ if directory_company.allow_restaurant_booking:
+ new_booking = request.env['website.directory.booking'].sudo().create({'partner_id': values['business_id'], 'booking_name': values['booking_name'], 'email': values['email'], 'number_of_people': values['number_of_people'], 'booking_datetime': values['booking_datetime'], 'notes': values['notes']})
+
+ #Send email
+ notification_template = request.env['ir.model.data'].sudo().get_object('website_business_directory', 'directory_booking')
+ notification_template.send_mail(new_booking.id, True)
+
+ return werkzeug.utils.redirect("/directory")
+ else:
+ return "BOOKINGS NOT ALLOWED"
+
+ @http.route('/directory/search', type="http", auth="public", website=True)
+ def directory_search_generic_results(self, **kwargs):
+
+ values = {}
+ for field_name, field_value in kwargs.items():
+ values[field_name] = field_value
+
+ search_domain = [('in_directory','=', True)]
+
+ if values['term'] != "":
+ search_string = values['term']
+ search_domain.append('|')
+ search_domain.append( ('name','ilike', search_string) )
+ search_domain.append( ('company_category_ids.name','ilike', search_string) )
+
+ if values['location'] != "":
+ search_location = values['location']
+ #search_state = search_location.split(", ")[1]
+ #search_city = search_location.split(", ")[0]
+ #search_city_id = request.env['res.country.state.city'].search([('state_id.name','=ilike',search_state), ('name','=ilike',search_city)])
+
+ base = r"https://maps.googleapis.com/maps/api/geocode/json?"
+ addP = "address=" + search_location.replace(" ","+")
+ GeoUrl = base + addP
+
+ google_maps_api_key = request.env['ir.config_parameter'].sudo().get_param('google_maps_api_key')
+ if google_maps_api_key:
+ GeoUrl += "&key=" + google_maps_api_key
+
+ response = urllib.urlopen(GeoUrl)
+ jsonRaw = response.read()
+ jsonData = json.loads(jsonRaw)
+
+ longitude = ""
+ latitude = ""
+
+ if jsonData['status'] == 'OK':
+ resu = jsonData['results'][0]
+ longitude = resu['geometry']['location']['lng']
+ latitude = resu['geometry']['location']['lat']
+ else:
+ return "Geocode Failed"
+
+ #In Kilometers
+ distance = 15
+ mylon = float(longitude)
+ mylat = float(latitude)
+ dist = float(distance) * 0.621371
+ lon_min = mylon-dist/abs(math.cos(math.radians(mylat))*69);
+ lon_max = mylon+dist/abs(math.cos(math.radians(mylat))*69);
+ lat_min = mylat-(dist/69);
+ lat_max = mylat+(dist/69);
+
+ #Within distance
+ search_domain.append(('longitude','>=',lon_min))
+ search_domain.append(('longitude','<=',lon_max))
+ search_domain.append(('latitude','<=',lat_min))
+ search_domain.append(('latitude','>=',lat_max))
+
+ featured_listings = request.env['res.partner'].sudo().search( search_domain + [('featured_listing','=',True)] )
+ regular_listings = request.env['res.partner'].sudo().search( search_domain + [('featured_listing','=',False)] )
+
+ google_maps_api_key = request.env['ir.config_parameter'].sudo().get_param('google_maps_api_key')
+ heading_string = str(len(featured_listings) + len(regular_listings)) + " Listings found"
+ return http.request.render('website_business_directory.directory_search_results', {'featured_listings': featured_listings, 'regular_listings': regular_listings, 'google_maps_api_key': google_maps_api_key, 'heading_string': heading_string} )
+
+ @http.route('/directory/search/category/', type="http", auth="public", website=True)
+ def directory_search_category_results(self, category, **kwargs):
+ featured_listings = request.env['res.partner'].sudo().search([('in_directory','=', True), ('company_category_ids','=', category.id), ('featured_listing','=',True) ])
+ regular_listings = request.env['res.partner'].sudo().search([('in_directory','=', True), ('company_category_ids','=', category.id), ('featured_listing','=',False) ])
+ google_maps_api_key = request.env['ir.config_parameter'].sudo().get_param('google_maps_api_key')
+ heading_string = str(len(featured_listings) + len(regular_listings)) + " Listings found in the category " + category.name
+ return http.request.render('website_business_directory.directory_search_results', {'featured_listings': featured_listings, 'regular_listings': regular_listings, 'google_maps_api_key': google_maps_api_key, 'heading_string': heading_string} )
+
+ @http.route('/directory/term-auto-complete', auth="public", website=True, type='http')
+ def directory_term_autocomplete(self, **kw):
+ """Provides an autocomplete list of businesses and types in the directory"""
+ values = {}
+ for field_name, field_value in kw.items():
+ values[field_name] = field_value
+
+ return_string = ""
+
+ my_return = []
+
+ #Get all businesses that match the search term
+ directory_partners = request.env['res.partner'].sudo().search([('in_directory','=',True), ('name','=ilike',"%" + values['term'] + "%")],limit=5)
+
+ for directory_partner in directory_partners:
+ return_item = {"label": directory_partner.name + "Business " + directory_partner.street + "","value": directory_partner.name }
+ my_return.append(return_item)
+
+ #Get all business types that match the search term
+ directory_categories = request.env['res.partner.directory.category'].sudo().search([('name','=ilike',"%" + values['term'] + "%")],limit=5)
+
+ for directory_category in directory_categories:
+ label = directory_category.name + "Category "
+
+ if directory_category.parent_category:
+ label += "" + directory_category.parent_category.name + " -> " + directory_category.name + ""
+ else:
+ label += "" + directory_category.name + ""
+
+ return_item = {"label": label,"value": directory_category.name }
+ my_return.append(return_item)
+
+ return json.JSONEncoder().encode(my_return)
+
+ @http.route('/directory/location-auto-complete', auth="public", website=True, type='http')
+ def directory_location_autocomplete(self, **kw):
+ """Provides an autocomplete list locations in the directory"""
+
+ values = {}
+ for field_name, field_value in kw.items():
+ values[field_name] = field_value
+
+ return_string = ""
+
+ my_return = []
+
+ directory_states = request.env['res.country.state'].sudo().search([('name','=ilike',values['term'] + "%"), ('listing_count','>',0)], limit=5)
+ for state in directory_states:
+ return_item = {"label": state.name + ", " + state.country_id.name,"value": state.name + ", " + state.country_id.name }
+ my_return.append(return_item)
+
+ directory_cities = request.env['res.country.state.city'].sudo().search([('name','=ilike', values['term'] + "%")], limit=5)
+ for city in directory_cities:
+ return_item = {"label": city.name + ", " + city.state_id.name,"value": city.name + ", " + city.state_id.name }
+ my_return.append(return_item)
+
+ return json.JSONEncoder().encode(my_return)
\ No newline at end of file
diff --git a/website_business_directory/data/res.groups.csv b/website_business_directory/data/res.groups.csv
new file mode 100644
index 000000000..08bf31470
--- /dev/null
+++ b/website_business_directory/data/res.groups.csv
@@ -0,0 +1,2 @@
+"id","name","comment"
+"directory_group","Business Directory User","Has registered an account and can create new businesses(res.partner)"
\ No newline at end of file
diff --git a/website_business_directory/data/res.users.directory.level.xml b/website_business_directory/data/res.users.directory.level.xml
new file mode 100644
index 000000000..e98de6574
--- /dev/null
+++ b/website_business_directory/data/res.users.directory.level.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+ Free Account
+ 1
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/data/website.directory.level.csv b/website_business_directory/data/website.directory.level.csv
new file mode 100644
index 000000000..f41afd498
--- /dev/null
+++ b/website_business_directory/data/website.directory.level.csv
@@ -0,0 +1,2 @@
+"id","name"
+"free_listing","Free Listing"
\ No newline at end of file
diff --git a/website_business_directory/data/website.menu.csv b/website_business_directory/data/website.menu.csv
new file mode 100644
index 000000000..9ec822066
--- /dev/null
+++ b/website_business_directory/data/website.menu.csv
@@ -0,0 +1,5 @@
+id,name,url,parent_id/id
+website_directory,Business Directory,/directory,website.main_menu
+website_directory_account,My Account,/directory/account,website_directory
+website_directory_search,Search,/directory,website_directory
+website_directory_register,Register,/directory/register,website_directory
\ No newline at end of file
diff --git a/website_business_directory/data/website.page.csv b/website_business_directory/data/website.page.csv
new file mode 100644
index 000000000..37600dcba
--- /dev/null
+++ b/website_business_directory/data/website.page.csv
@@ -0,0 +1,5 @@
+id,name,url,website_published,view_id/id
+"page_directory_search","Directory Search","/directory",1,"website_business_directory.directory_search"
+"page_directory_register","Directory Register","/directory/register",1,"website_business_directory.directory_register"
+"page_directory_account_business_add","Directory Business Add","/directory/account/business/add",1,"website_business_directory.directory_account_business_add"
+"page_directory_account_business_edit","Directory Business Edit","/directory/account/business/edit",1,"website_business_directory.directory_account_business_edit"
\ No newline at end of file
diff --git a/website_business_directory/doc/changelog.rst b/website_business_directory/doc/changelog.rst
new file mode 100644
index 000000000..41c4dcafc
--- /dev/null
+++ b/website_business_directory/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Port to Odoo 11
\ No newline at end of file
diff --git a/website_business_directory/models/__init__.py b/website_business_directory/models/__init__.py
new file mode 100644
index 000000000..a3aaa99e7
--- /dev/null
+++ b/website_business_directory/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from . import website_directory_level
+from . import res_partner
+from . import res_users
+from . import website_directory_booking
+from . import website_directory_stat
+from . import website_directory_template
+from . import res_country_state
+from . import res_country_state_city
+from . import res_country_state_city_import
\ No newline at end of file
diff --git a/website_business_directory/models/res_country_state.py b/website_business_directory/models/res_country_state.py
new file mode 100644
index 000000000..9cd045900
--- /dev/null
+++ b/website_business_directory/models/res_country_state.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class ResPartnerDirectory(models.Model):
+
+ _inherit = "res.country.state"
+
+ listing_count = fields.Integer(string="Listing Count", compute="_compute_listing_count", store=True)
+ listing_ids = fields.One2many('res.partner','state_id', string="State Listings", domain=[('in_directory','=',True)])
+
+ @api.one
+ @api.depends('listing_ids')
+ def _compute_listing_count(self):
+ self.listing_count = len(self.listing_ids)
\ No newline at end of file
diff --git a/website_business_directory/models/res_country_state_city.py b/website_business_directory/models/res_country_state_city.py
new file mode 100644
index 000000000..c3f6cb2d3
--- /dev/null
+++ b/website_business_directory/models/res_country_state_city.py
@@ -0,0 +1,13 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class ResCountryStateCity(models.Model):
+
+ _name = "res.country.state.city"
+
+ name = fields.Char(string='City Name')
+ state_id = fields.Many2one('res.country.state', string="State")
+ zip = fields.Char(string="Zip")
+ latitude = fields.Char(string="Latitude")
+ longitude = fields.Char(string="Longitude")
\ No newline at end of file
diff --git a/website_business_directory/models/res_country_state_city_import.py b/website_business_directory/models/res_country_state_city_import.py
new file mode 100644
index 000000000..cd7e8dc36
--- /dev/null
+++ b/website_business_directory/models/res_country_state_city_import.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+import io
+from io import StringIO
+import zipfile
+import csv
+import requests
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo.exceptions import Warning
+from odoo import api, fields, models
+
+class ResCountryStateCityImport(models.Model):
+
+ _name = "res.country.state.city.import"
+
+ country_id = fields.Many2one('res.country', required=True, string="Country")
+
+ def geonames_import(self):
+ geonames_url = "http://download.geonames.org/export/zip/" + self.country_id.code + ".zip"
+
+ geonames_request = requests.get(geonames_url)
+ if geonames_request.status_code != requests.codes.ok:
+ raise Warning("Failed to download file from geonames")
+
+ geonames_zip = zipfile.ZipFile(io.BytesIO(geonames_request.content))
+
+ csv_file = geonames_zip.open(self.country_id.code + ".txt")
+ csv_data = csv_file.read().decode("utf-8")
+ reader = csv.reader(csv_data.splitlines(), delimiter='\t')
+
+ for row in reader:
+ zip = row[1]
+ city_name = row[2]
+ state_name = row[3]
+ state_code = row[4]
+ latitude = row[9]
+ longitude = row[10]
+
+ #Create the state if it doesn't exist
+ state_search = self.env['res.country.state'].search([('code','=',state_code), ('country_id','=', self.country_id.id)])
+ if len(state_search) == 0:
+ state = self.env['res.country.state'].create({'name': state_name, 'code':state_code, 'country_id': self.country_id.id})
+ else:
+ state = state_search[0]
+
+ city_search_count = self.env['res.country.state.city'].search_count([('name','=',city_name), ('state_id','=', state.id)])
+ if city_search_count == 0:
+ self.env['res.country.state.city'].create({'name': city_name, 'state_id': state.id, 'zip': zip, 'latitude': latitude, 'longitude': longitude})
\ No newline at end of file
diff --git a/website_business_directory/models/res_partner.py b/website_business_directory/models/res_partner.py
new file mode 100644
index 000000000..0fca5db48
--- /dev/null
+++ b/website_business_directory/models/res_partner.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class ResPartnerDirectory(models.Model):
+
+ _inherit = "res.partner"
+
+ in_directory = fields.Boolean(string="In Directory")
+ company_category_ids = fields.Many2many('res.partner.directory.category', string="Directory Categories")
+ directory_description = fields.Text(string="Directory Description", translate=True)
+ business_owner = fields.Many2one('res.users', string="Business Owner")
+ city_id = fields.Many2one('res.country.state.city', string="City")
+ latitude = fields.Char(String="Latitude")
+ longitude = fields.Char(string="Longitude")
+ directory_monday_start = fields.Float(string="Monday Start Time")
+ directory_monday_end = fields.Float(string="Monday End Time")
+ directory_tuesday_start = fields.Float(string="Tuesday Start Time")
+ directory_tuesday_end = fields.Float(string="Tuesday End Time")
+ directory_wednesday_start = fields.Float(string="Wednesday Start Time")
+ directory_wednesday_end = fields.Float(string="Wednesday End Time")
+ directory_thursday_start = fields.Float(string="Thursday Start Time")
+ directory_thursday_end = fields.Float(string="Thursday End Time")
+ directory_thursday_start = fields.Float(string="Saturday Start Time")
+ directory_thursday_end = fields.Float(string="Saturday End Time")
+ directory_friday_start = fields.Float(string="Friday Start Time")
+ directory_friday_end = fields.Float(string="Friday End Time")
+ directory_saturday_start = fields.Float(string="Saturday Start Time")
+ directory_saturday_end = fields.Float(string="Saturday End Time")
+ directory_sunday_start = fields.Float(string="Sunday Start Time")
+ directory_sunday_end = fields.Float(string="Sunday End Time")
+ directory_listing_open_hours = fields.One2many('website.directory.timeslot', 'business_id', string="Open Times")
+ allow_restaurant_booking = fields.Boolean(string="Allow Restaurant Booking")
+ display_online_menu = fields.Boolean(string="Display Online Menu")
+ menu = fields.One2many('res.partner.directory.department', 'restaurant_id', string="Menu")
+ directory_review_ids = fields.One2many('res.partner.directory.review', 'business_id', string=" Reviews")
+ featured_listing = fields.Boolean(string="Featured Listing")
+ listing_level = fields.Many2one('website.directory.level', string="Listing Level")
+ facebook_url = fields.Char(string="Facebook URL")
+ twitter_url = fields.Char(string="Twitter URL")
+ youtube_url = fields.Char(string="Youtube URL")
+ instagram_url = fields.Char(string="Instagram URL")
+
+class WebsiteDirectoryTimeslot(models.Model):
+
+ _name = "website.directory.timeslot"
+
+ business_id = fields.Many2one('res.partner', string="Business")
+ day = fields.Selection([('monday','Monday'), ('tuesday','Tuesday'), ('wednesday','Wednesday'), ('thursday','Thursday'), ('friday','Friday'), ('saturday','Saturday'), ('sunday','Sunday')], string="Day")
+ start_time = fields.Float(string="Start Time")
+ end_time = fields.Float(string="End Time")
+
+class WebsiteDirectoryBillingPlan(models.Model):
+
+ _name = "website.directory.billingplan"
+
+ name = fields.Char(string="Plan Name", required=True)
+ frequency = fields.Selection([('MONTH','Monthly')], required=True, default="MONTH", string="Monthly")
+ amount = fields.Float(string="Amount", required=True)
+
+class WebsiteDirectoryReview(models.Model):
+
+ _name = "res.partner.directory.review"
+
+ business_id = fields.Many2one('res.partner', string="Business")
+ name = fields.Char(string="Name")
+ description = fields.Text(string="Description")
+ rating = fields.Selection([('1','1 Star'), ('2','2 Star'), ('3','3 Star'), ('4','4 Star'), ('5','5 Star')], string="Rating")
+
+class WebsiteDirectoryDepartment(models.Model):
+
+ _name = "res.partner.directory.department"
+
+ restaurant_id = fields.Many2one('res.partner', string="Restaurant")
+ name = fields.Char(string="Name")
+ description = fields.Text(string="Description")
+ menu_item_ids = fields.One2many('res.partner.directory.department.menuitem', 'department_id', string="Menu Items")
+
+class WebsiteDirectoryDepartmentMenuItem(models.Model):
+
+ _name = "res.partner.directory.department.menuitem"
+
+ department_id = fields.Many2one('res.partner.directory.department', string="Department")
+ name = fields.Char(string="Name")
+ description = fields.Text(string="Description")
+ single_price = fields.Float(string="Price")
+
+class WebsiteDirectoryCategory(models.Model):
+
+ _name = "res.partner.directory.category"
+
+ parent_category = fields.Many2one('res.partner.directory.category', string="Parent Category")
+ children_category_ids = fields.One2many('res.partner.directory.category', 'parent_category', string="Child Categories")
+ name = fields.Char(string="Category Name")
\ No newline at end of file
diff --git a/website_business_directory/models/res_users.py b/website_business_directory/models/res_users.py
new file mode 100644
index 000000000..33987e798
--- /dev/null
+++ b/website_business_directory/models/res_users.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class ResUsersDirectory(models.Model):
+
+ _inherit = "res.users"
+
+ directory_level_id = fields.Many2one('res.users.directory.level', string="Directory Level")
+
+class ResUsersLevelDirectory(models.Model):
+
+ _name = "res.users.directory.level"
+
+ name = fields.Char(string="Name")
+ free_listing_limit = fields.Integer(string="Free Listing Limit")
\ No newline at end of file
diff --git a/website_business_directory/models/website_directory_booking.py b/website_business_directory/models/website_directory_booking.py
new file mode 100644
index 000000000..4fc4221e7
--- /dev/null
+++ b/website_business_directory/models/website_directory_booking.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class WebsiteDirectoryBooking(models.Model):
+
+ _name = "website.directory.booking"
+
+ partner_id = fields.Many2one('res.partner', string="Business")
+ booking_name = fields.Char(string="Booking Name")
+ email = fields.Char(string="Email")
+ number_of_people = fields.Char(string="Number of People")
+ booking_datetime = fields.Datetime(string="Booking Date Time")
+ notes = fields.Text(string="Notes")
\ No newline at end of file
diff --git a/website_business_directory/models/website_directory_level.py b/website_business_directory/models/website_directory_level.py
new file mode 100644
index 000000000..a44454f74
--- /dev/null
+++ b/website_business_directory/models/website_directory_level.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+class WebsiteDirectoryLevel(models.Model):
+
+ _name = "website.directory.level"
+
+ name = fields.Char(string="Name")
+ billing_plan = fields.Many2one('website.directory.billingplan', string="Billing Plan")
+ category_limit = fields.Integer(string="Category Limit")
\ No newline at end of file
diff --git a/website_business_directory/models/website_directory_stat.py b/website_business_directory/models/website_directory_stat.py
new file mode 100644
index 000000000..f6817e8fd
--- /dev/null
+++ b/website_business_directory/models/website_directory_stat.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo import api, fields, models
+
+class WebsiteDirectoryStat(models.Model):
+
+ _name = "website.directory.stat"
+
+ listing_id = fields.Many2one('res.partner', string="Listing")
+ ref = fields.Char(string="Referer URL")
+ ip = fields.Char(string="IP Address")
+ location = fields.Char(string="Geo Location")
+
+class WebsiteDirectoryStatWebsite(models.Model):
+
+ _name = "website.directory.stat.website"
+
+ listing_id = fields.Many2one('res.partner', string="Listing")
+ ip = fields.Char(string="IP Address")
+ location = fields.Char(string="Geo Location")
\ No newline at end of file
diff --git a/website_business_directory/models/website_directory_template.py b/website_business_directory/models/website_directory_template.py
new file mode 100644
index 000000000..eca60d46a
--- /dev/null
+++ b/website_business_directory/models/website_directory_template.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+
+import logging
+_logger = logging.getLogger(__name__)
+
+from odoo import api, fields, models
+
+class WebsiteDirectoryTemplate(models.Model):
+
+ _name = "website.directory.template"
+
+ name = fields.Char(string="Name")
+ website_active = fields.Boolean(string="Active")
+ description = fields.Text(string="Description")
+ page_ids = fields.One2many('website.directory.template.page', 'template_id', string="Pages")
+
+ def set_active(self):
+ # Find the default template and revert all pages back to the default
+ default_template = self.env['ir.model.data'].get_object('website_business_directory','website_directory_template_default')
+
+ for page in default_template.page_ids:
+ page.page_id.view_id = page.view_id.id
+
+ # Now change the views of only the pages in the template with any non included pages remaining the default
+ for page in self.page_ids:
+ page.page_id.view_id = page.view_id.id
+
+ # Deactivate the previously active template (can be multiple active if glitched)
+ for active_template in self.env['website.directory.template'].search([('website_active','=',True)]):
+ active_template.website_active = False
+
+ self.website_active = True
+
+class WebsiteDirectoryTemplatePage(models.Model):
+
+ _name = "website.directory.template.page"
+
+ template_id = fields.Many2one('website.directory.template', string="Template")
+ page_id = fields.Many2one('website.page', string="Page")
+ view_id = fields.Many2one('ir.ui.view', string="View")
\ No newline at end of file
diff --git a/website_business_directory/security/ir.model.access.csv b/website_business_directory/security/ir.model.access.csv
new file mode 100644
index 000000000..e9ee48217
--- /dev/null
+++ b/website_business_directory/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink"
+"access_res_partner_directory","directory access res.partner","base.model_res_partner","",1,0,0,0
+"access_res_partner_directory_category","access res.partner.directory.category","model_res_partner_directory_category","",1,0,0,0
+"access_res_partner_directory_review","access res.partner.directory.review","model_res_partner_directory_review","",1,0,1,0
+"access_res_country_state_city","access res.country.state.city","model_res_country_state_city","",1,0,0,0
\ No newline at end of file
diff --git a/website_business_directory/static/description/1.jpg b/website_business_directory/static/description/1.jpg
new file mode 100644
index 000000000..c9d6df8e4
Binary files /dev/null and b/website_business_directory/static/description/1.jpg differ
diff --git a/website_business_directory/static/description/2.jpg b/website_business_directory/static/description/2.jpg
new file mode 100644
index 000000000..7284296b2
Binary files /dev/null and b/website_business_directory/static/description/2.jpg differ
diff --git a/website_business_directory/static/description/3.jpg b/website_business_directory/static/description/3.jpg
new file mode 100644
index 000000000..6616eb220
Binary files /dev/null and b/website_business_directory/static/description/3.jpg differ
diff --git a/website_business_directory/static/description/4.jpg b/website_business_directory/static/description/4.jpg
new file mode 100644
index 000000000..74615cefd
Binary files /dev/null and b/website_business_directory/static/description/4.jpg differ
diff --git a/website_business_directory/static/description/5.jpg b/website_business_directory/static/description/5.jpg
new file mode 100644
index 000000000..bfa137400
Binary files /dev/null and b/website_business_directory/static/description/5.jpg differ
diff --git a/website_business_directory/static/description/6.jpg b/website_business_directory/static/description/6.jpg
new file mode 100644
index 000000000..8840f1b5e
Binary files /dev/null and b/website_business_directory/static/description/6.jpg differ
diff --git a/website_business_directory/static/description/7.jpg b/website_business_directory/static/description/7.jpg
new file mode 100644
index 000000000..f704880ff
Binary files /dev/null and b/website_business_directory/static/description/7.jpg differ
diff --git a/website_business_directory/static/description/icon.png b/website_business_directory/static/description/icon.png
new file mode 100644
index 000000000..2a435e94f
Binary files /dev/null and b/website_business_directory/static/description/icon.png differ
diff --git a/website_business_directory/static/description/index.html b/website_business_directory/static/description/index.html
new file mode 100644
index 000000000..d4251c2a1
--- /dev/null
+++ b/website_business_directory/static/description/index.html
@@ -0,0 +1,39 @@
+
+
Description
+A directory of local companies
+
+
+
Business Search
+
+Search for businesses in your local area or a business type
+
+
+
Search Results
+
+View the results of a search
+
+
+
Restaurant Booking
+
+Enable bookings for your business and customers can fill in a form anmd the details will auitomatically be emailed to you
+
+
+
Business Reviews
+
+Customers can write reviews for companies given insight into which companies are popular in a category
+
+
+
Create Account
+
+Create an account so you can manage all your businesses
+
+
+
Manage Listings
+
+Create new business listings and update the details of your existing businesses
+
+
+
Template System
+
+Provide altered versions of each webpage to customise the system to your listing niche (businesses, inviduals, cars, etc)
+
\ No newline at end of file
diff --git a/website_business_directory/templates/default/website.directory.template.csv b/website_business_directory/templates/default/website.directory.template.csv
new file mode 100644
index 000000000..2dd12f5a1
--- /dev/null
+++ b/website_business_directory/templates/default/website.directory.template.csv
@@ -0,0 +1,2 @@
+id,name,website_active,description
+website_directory_template_default,"Default Template",1,"The default template which is geared towards business listings"
\ No newline at end of file
diff --git a/website_business_directory/templates/default/website.directory.template.page.csv b/website_business_directory/templates/default/website.directory.template.page.csv
new file mode 100644
index 000000000..c86245486
--- /dev/null
+++ b/website_business_directory/templates/default/website.directory.template.page.csv
@@ -0,0 +1,5 @@
+id,template_id/id,page_id/id,view_id/id
+"website_directory_template_default_page_directory_search","website_directory_template_default","page_directory_search","directory_search"
+"website_directory_template_default_page_directory_register","website_directory_template_default","page_directory_register","directory_register"
+"website_directory_template_default_page_directory_account_business_add","website_directory_template_default","page_directory_account_business_add","directory_account_business_add"
+"website_directory_template_default_page_directory_account_business_edit","website_directory_template_default","page_directory_account_business_edit","directory_account_business_edit"
\ No newline at end of file
diff --git a/website_business_directory/templates/individual/templates.xml b/website_business_directory/templates/individual/templates.xml
new file mode 100644
index 000000000..01cb3b8c0
--- /dev/null
+++ b/website_business_directory/templates/individual/templates.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
Enter Personal Info
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website_business_directory/templates/individual/website.directory.template.csv b/website_business_directory/templates/individual/website.directory.template.csv
new file mode 100644
index 000000000..342158603
--- /dev/null
+++ b/website_business_directory/templates/individual/website.directory.template.csv
@@ -0,0 +1,2 @@
+id,name,description
+website_directory_template_individual,"Individual Template","Hides street address and uses the term individual instead of company"
\ No newline at end of file
diff --git a/website_business_directory/templates/individual/website.directory.template.page.csv b/website_business_directory/templates/individual/website.directory.template.page.csv
new file mode 100644
index 000000000..d3f6ad77b
--- /dev/null
+++ b/website_business_directory/templates/individual/website.directory.template.page.csv
@@ -0,0 +1,2 @@
+id,template_id/id,page_id/id,view_id/id
+"website_directory_template_individual_page_directory_account_individual_add","website_directory_template_individual","page_directory_account_business_add","directory_account_individual_add"
\ No newline at end of file
diff --git a/website_business_directory/views/menus.xml b/website_business_directory/views/menus.xml
new file mode 100644
index 000000000..f667d89f1
--- /dev/null
+++ b/website_business_directory/views/menus.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/res_country_state_city_import_views.xml b/website_business_directory/views/res_country_state_city_import_views.xml
new file mode 100644
index 000000000..d35b3e240
--- /dev/null
+++ b/website_business_directory/views/res_country_state_city_import_views.xml
@@ -0,0 +1,29 @@
+
+
+
+
+
+ res.country.state.city.import.view.form
+ res.country.state.city.import
+
+
+
+
+
+
+
+
+
+
+
+ GeoNames Import Wizard
+ res.country.state.city.import
+ form
+ form
+ new
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/res_country_state_city_views.xml b/website_business_directory/views/res_country_state_city_views.xml
new file mode 100644
index 000000000..5cac3bea4
--- /dev/null
+++ b/website_business_directory/views/res_country_state_city_views.xml
@@ -0,0 +1,42 @@
+
+
+
+
+
+ res.country.state.city.view.form
+ res.country.state.city
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ res.country.state.city.view.tree
+ res.country.state.city
+
+
+
+
+
+
+
+
+
+
+
+ Locations
+ res.country.state.city
+ form
+ tree,form
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/res_partner_directory_department_views.xml b/website_business_directory/views/res_partner_directory_department_views.xml
new file mode 100644
index 000000000..7b67e244b
--- /dev/null
+++ b/website_business_directory/views/res_partner_directory_department_views.xml
@@ -0,0 +1,33 @@
+
+
+
+
+ res.partner.directory.department.view.form
+ res.partner.directory.department
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Business Directory Listings
+ res.partner
+ form
+ tree,form
+ [('in_directory','=',True)]
+ {'default_in_directory':1}
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/res_partner_views.xml b/website_business_directory/views/res_partner_views.xml
new file mode 100644
index 000000000..55d434323
--- /dev/null
+++ b/website_business_directory/views/res_partner_views.xml
@@ -0,0 +1,68 @@
+
+
+
+
+ res.partner Business Directory
+ res.partner
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/res_users_views.xml b/website_business_directory/views/res_users_views.xml
new file mode 100644
index 000000000..9ef84baf1
--- /dev/null
+++ b/website_business_directory/views/res_users_views.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ res.users Business Directory
+ res.users
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_business_directory/views/website_business_directory_templates.xml b/website_business_directory/views/website_business_directory_templates.xml
new file mode 100644
index 000000000..83271aac4
--- /dev/null
+++ b/website_business_directory/views/website_business_directory_templates.xml
@@ -0,0 +1,770 @@
+
+
+
+
+ Directory Restaurant Booking
+
+ ${object.email}
+ ${object.partner_id.email}
+ New online booking
+
+ Dear ${object.partner_id.name},
+
A new online booking has been made, here are the details,
"
+
+ if help_pages:
+ return return_html
+ else:
+ # Return blank so we can hide the suggestion div
+ return ""
\ No newline at end of file
diff --git a/website_support/data/ir.cron.xml b/website_support/data/ir.cron.xml
new file mode 100644
index 000000000..93b3de1db
--- /dev/null
+++ b/website_support/data/ir.cron.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+ Update SLA Timer
+
+ code
+ model.update_sla_timer()
+ 1
+ minutes
+ -1
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/ir.module.category.csv b/website_support/data/ir.module.category.csv
new file mode 100644
index 000000000..a5fb96008
--- /dev/null
+++ b/website_support/data/ir.module.category.csv
@@ -0,0 +1,2 @@
+"id","name"
+"support_application","Website Support"
\ No newline at end of file
diff --git a/website_support/data/res.groups.xml b/website_support/data/res.groups.xml
new file mode 100644
index 000000000..79b43e764
--- /dev/null
+++ b/website_support/data/res.groups.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+ Support Client
+
+ Created an account through the website, has no portal (/web) access
+
+
+
+ Support Staff
+
+
+ Has the ability the view and answer support tickets but not configure
+
+
+
+ Support Manager
+
+
+ Can configure support settings
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website.menu.csv b/website_support/data/website.menu.csv
new file mode 100644
index 000000000..822226554
--- /dev/null
+++ b/website_support/data/website.menu.csv
@@ -0,0 +1,2 @@
+"id","name","url","parent_id/id"
+"website_support_ticket","Support","/support/help","website.main_menu"
diff --git a/website_support/data/website.support.department.role.csv b/website_support/data/website.support.department.role.csv
new file mode 100644
index 000000000..5643cfad8
--- /dev/null
+++ b/website_support/data/website.support.department.role.csv
@@ -0,0 +1,2 @@
+"id","name","view_department_tickets"
+"website_support_department_manager","Manager","True"
\ No newline at end of file
diff --git a/website_support/data/website.support.settings.xml b/website_support/data/website.support.settings.xml
new file mode 100644
index 000000000..1ca105092
--- /dev/null
+++ b/website_support/data/website.support.settings.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website.support.ticket.approval.xml b/website_support/data/website.support.ticket.approval.xml
new file mode 100644
index 000000000..6e9dc1257
--- /dev/null
+++ b/website_support/data/website.support.ticket.approval.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+ No Approval Required
+
+
+
+ Awaiting Approval
+
+
+
+ Approval Accepted
+
+
+
+ Approval Rejected
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website.support.ticket.categories.xml b/website_support/data/website.support.ticket.categories.xml
new file mode 100644
index 000000000..4f8b56f9a
--- /dev/null
+++ b/website_support/data/website.support.ticket.categories.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+ Technical Support
+
+
+
+ Billing Issues
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website.support.ticket.priority.xml b/website_support/data/website.support.ticket.priority.xml
new file mode 100644
index 000000000..4a78f85c9
--- /dev/null
+++ b/website_support/data/website.support.ticket.priority.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ Low
+ #000000
+
+
+
+ Normal
+ #000000
+
+
+
+ Moderately
+ #FFFF00
+
+
+
+ High
+ #FFA500
+
+
+
+ Urgent
+ #FF0000
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website.support.ticket.states.xml b/website_support/data/website.support.ticket.states.xml
new file mode 100644
index 000000000..d07bbec42
--- /dev/null
+++ b/website_support/data/website.support.ticket.states.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+ Open
+
+ True
+
+
+
+ Staff Replied
+
+
+
+ Customer Replied
+ True
+
+
+
+ Awaiting Approval
+
+
+
+ Approval Accepted
+ True
+
+
+
+ Approval Rejected
+ True
+
+
+
+ Customer Closed
+
+
+
+
+ Staff Closed
+
+
+
+
\ No newline at end of file
diff --git a/website_support/data/website_support_sequence.xml b/website_support/data/website_support_sequence.xml
new file mode 100644
index 000000000..7cf170aaf
--- /dev/null
+++ b/website_support/data/website_support_sequence.xml
@@ -0,0 +1,14 @@
+
+
+
+
+ website.support.ticket
+ website.support.ticket
+
+ no_gap
+ 0
+ 1
+ 1
+
+
+
diff --git a/website_support/doc/changelog.rst b/website_support/doc/changelog.rst
new file mode 100644
index 000000000..f5685faea
--- /dev/null
+++ b/website_support/doc/changelog.rst
@@ -0,0 +1,307 @@
+v1.6.9
+======
+* Fix custom fields many2one with no filter producing an error
+
+v1.6.8
+======
+* Fix comment attachments in portal reply
+
+v1.6.7
+======
+* Fix help pages 404
+
+v1.6.6
+======
+* Ability to set a default body and cc depending on ticket contact, which auto fills the ticket reply wizard
+
+v1.6.5
+======
+* Fixed bug with department manager not being able to view individual tickets
+
+v1.6.4
+======
+* Add create support ticket button to form view
+
+v1.6.3
+======
+* Prevent direct help page access
+
+v1.6.2
+======
+* Clears sub category on category change to prevent saving with sub category of old parent
+
+v1.6.1
+======
+* Change close template to allow blank closing comment
+
+v1.6.0
+======
+* Add merge ticket button
+* Add close lock feature to prevent reopening tickets (auto lock upon ticket merge)
+
+v1.5.16
+=======
+* Add many2one type to custom fields
+
+v1.5.15
+=======
+* Add setting to auto create contact
+
+v1.5.14
+=======
+* Add ability to add attachments on staff reply and staff ticket close
+
+v1.5.13
+=======
+* Adjust permissions so employees can view contacts without access to support ticket data.
+
+v1.5.12
+=======
+* Add ability for clients to add attachments in there replies from the website interface
+
+v1.5.11
+=======
+* Suggest help pages that are similiar to ticket subject (reduces tickets submitted that already have a help article)
+
+v1.5.10
+=======
+* Changes courtesy of lucode (https://github.com/SythilTech/Odoo/pull/60)
+* Adding some UX related logic to Support Ticket view.
+* Updating french / german language
+* Adding new client form view. Old view is still default one.
+* New client form view. state readonly in view.
+
+v1.4.9
+======
+* Change close email template to support html and remove from form view as it is displayed in chatter anyway.
+
+v1.4.8
+======
+* Fix spelling mistake sanatize=False to sanitize=False, which should allow things like youtube videos and other stuff to be placed in help pages like it was designed to do...
+* Add help page attachments
+
+v1.4.7
+======
+* Fix upgrade issue for people before version 1.1.0 related to approval
+
+v1.4.6
+======
+* Fix permission issues with support clients assigned as department managers not being able to view website reporting interface
+
+v1.4.5
+======
+* Fix approval mail permissions
+
+v1.4.4
+======
+* Close ticket email templates
+* Fix Fix help page search
+* Fix double close comment email bug
+
+v1.4.3
+======
+* Fix permission issue with survey and change requests being inaccessible for public users
+
+v1.4.2
+======
+* Fix ticket submit issue with public users with existing emails submitting tickets (introduced in v1.4.0)
+
+v1.4.1
+======
+* Fix issue with survey link appearing as _survey_url_ in chatter
+
+v1.4.0
+======
+* SLA overhaul to support multiple conditions e.g. Priority = Urgent AND Category = Technical Support
+* Fix Issue with Support Managers access being lost on module update (will apply to future versions)
+* Ability to add image to help groups (optional)
+
+v1.3.12
+=======
+* Department manager access to department contact tickets fix
+* Automatically add category follower to ticket followers
+
+v1.3.11
+=======
+* Ability to assign a customer to a dedicated support staff member
+
+v1.3.10
+=======
+* Help page unpublish / republish
+
+v1.3.9
+======
+* Fix signed in users not being able to access help groups / pages
+
+v1.3.8
+======
+* Fix help group page using old field
+
+v1.3.7
+======
+* Add customer close button
+* Limit help pages to 5 per help group with a more link
+* Administrator now defaults to Support Manager to help reduce install confusion
+
+v1.3.6
+======
+* User accounts created through the create account link are now added to the portal group instead of the public group to resolve login issues
+
+v1.3.5
+======
+* Fix business hours field missing resource module dependacy
+
+v1.3.4
+======
+* Ability to limit which user groups can select a category
+
+v1.3.3
+======
+* Add close date to customer website portal
+
+v1.3.2
+======
+* Assigned user filter for internal users (employees) only
+
+v1.3.1
+======
+* Remove dependency on CRM module
+
+v1.3.0
+======
+* (BACK COMPATABLITY BREAK) Remove old Sales Manager permissions
+* Group and permission overhaul (Support Client, Support Staff, Support Manager)
+* Update documentation to reflect menu changes and permission overhaul
+
+v1.2.14
+=======
+* Adding sequence for ticket number, deleting ticket number display
+* Migrate fake ticket number system to sequence system
+* Spanish tranlation
+* Timezone in website view
+* Various view improvements
+
+v1.2.13
+=======
+* Optional priority field on website
+
+v1.2.12
+=======
+* Website filter state for tickets
+* Hide SLA resume and pause buttons if no SLA is assigned to the ticket
+* Choose which states get classified as unattended
+
+v1.2.11
+=======
+* Unlinked page to list help pages by support group
+
+v1.2.10
+=======
+* Fix SLA business hours timer and add support for holidays via the hr_public_holidays module
+
+v1.2.9
+======
+* Permission for SLA Alerts
+
+v1.2.8
+======
+* SLA alert emails
+
+v1.2.7
+======
+* reCAPTCHA implementation since the honey pot is not bullet proof
+
+v1.2.6
+======
+* SLA tickets now have a timer that counts down, you can select between always count and business hours only + plus/resume timer
+
+v1.2.5
+======
+* Ability to assign SLA to contact and ultimately to their tickets
+
+v1.2.4
+======
+* Information only SLA
+
+v1.2.3
+======
+* Planned date now in default wrapper email template, formatted and localised
+* Default wrapper email template now uses fake/display ticket_number not id
+
+v1.2.2
+======
+* Portal access key is generated when ticket is manually created or through email / website
+
+v1.2.1
+======
+* Permission fix for approval system
+
+v1.2.0
+======
+* Ability to tag support tickets
+
+v1.1.1
+======
+* Support ticket now defaultly searches by subject rather then partner...
+
+v1.1.0
+======
+* Port approval system over from version 10
+* Add approvals to portal
+* Email notifacation on approval / rejection
+* Default approval compose email is now a email tempalte rather then hard coded.
+
+v1.0.12
+=======
+* Changing subcategory now automatically adds th extra fields
+
+v1.0.11
+=======
+* Extra field type and label is required
+
+v1.0.10
+=======
+* Show extra fields incase someone wants to manuall add the data
+* Add new channel field which tracks the source of the ticket (website / email)
+
+v1.0.9
+======
+* Remove kanban "+" and create since it isn't really compatable
+
+v1.0.8
+======
+* Fix subcategory change not disappearing
+* States no longer readonly
+* Move Kanban view over from Odoo 10
+
+v1.0.7
+======
+* Fix subcategories
+
+v1.0.6
+======
+* Fix multiple ticket delete issue
+
+v1.0.5
+======
+* Change default email wrapper to user
+
+v1.0.4
+======
+* Remove obsolete support@ reply wrapper
+
+v1.0.3
+======
+* Fix website ticket attachment issue
+
+v1.0.2
+======
+* Fix settings screen and move menu
+
+v1.0.1
+======
+* Forward fix custom field mismatch
+
+v1.0
+====
+* Port to version 11
\ No newline at end of file
diff --git a/website_support/i18n/de.po b/website_support/i18n/de.po
new file mode 100644
index 000000000..15b6e72b8
--- /dev/null
+++ b/website_support/i18n/de.po
@@ -0,0 +1,2466 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * website_support
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 11.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2019-01-25 09:45+0000\n"
+"PO-Revision-Date: 2019-01-25 14:01+0100\n"
+"Last-Translator: Lucas Huber \n"
+"Language-Team: \n"
+"Language: de\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: \n"
+"X-Generator: Poedit 2.2.1\n"
+
+#. module: website_support
+#: model:mail.template,body_html:website_support.support_ticket_reply_wrapper
+msgid ""
+"\n"
+" ${object.body|safe}\n"
+" \n"
+" % if object.ticket_id.portal_access_key :\n"
+" View Ticket Online:here \n"
+" % endif\n"
+" Ticket Number: ${object.ticket_id.ticket_number or "
+"object.ticket_id.id} \n"
+" Ticket Category: ${object.ticket_id.category.name or "
+"''} \n"
+" % if object.ticket_id.planned_time_format :\n"
+" Planned Date: ${object.ticket_id."
+"planned_time_format} \n"
+" % endif\n"
+" Ticket Description: \n"
+" ${object.ticket_id.description|safe}\n"
+" \n"
+" "
+msgstr ""
+"\n"
+" ${object.body|safe}\n"
+" \n"
+" % if object.ticket_id.portal_access_key :\n"
+" Gehe zu Ticket Online:here \n"
+" % endif\n"
+" Ticket Nummer: ${object.ticket_id.id} \n"
+" Ticket Kategorie: ${object.ticket_id.category.name or "
+"''}\n"
+" \n"
+" % if object.ticket_id.planned_time_format :\n"
+" Geplanntes Datum: ${object.ticket_id."
+"planned_time_format} \n"
+" % endif\n"
+"\n"
+" Ticket Beschreibung: \n"
+" ${object.ticket_id.description|safe}\n"
+" \n"
+" "
+
+#. module: website_support
+#: model:mail.template,body_html:website_support.support_ticket_approval
+msgid ""
+"\n"
+" % if object.person_name :\n"
+"
Dear ${object.person_name},
\n"
+" \n"
+" % endif\n"
+"\n"
+"
Approval is required before we can proceed with this "
+"support request, please click the link below to accept
Ihre Zustimmung ist erforderlich ist, bevor wir mit "
+"dieser Supportanfrage fortfahren können, klicken Sie bitte auf den Link "
+"unten, um zu akzeptieren
"
+ self.ticket_id.message_post(body=message, subject="Ticket Closed by Staff")
+
+ email_wrapper = self.env['ir.model.data'].get_object('website_support', 'support_ticket_close_wrapper')
+
+ values = email_wrapper.generate_email([self.id])[self.id]
+ values['model'] = "website.support.ticket"
+ values['res_id'] = self.ticket_id.id
+
+ for attachment in self.attachment_ids:
+ values['attachment_ids'].append((4, attachment.id))
+
+ send_mail = self.env['mail.mail'].create(values)
+ send_mail.send()
+
+ self.ticket_id.close_comment = self.message
+ self.ticket_id.closed_by_id = self.env.user.id
+ self.ticket_id.state = closed_state.id
+
+ self.ticket_id.sla_active = False
+
+ #Auto send out survey
+ setting_auto_send_survey = self.env['ir.default'].get('website.support.settings', 'auto_send_survey')
+ if setting_auto_send_survey:
+ self.ticket_id.send_survey()
+
+class WebsiteSupportTicketCompose(models.Model):
+
+ _name = "website.support.ticket.compose"
+
+ ticket_id = fields.Many2one('website.support.ticket', string='Ticket ID')
+ partner_id = fields.Many2one('res.partner', string="Partner", readonly="True")
+ email = fields.Char(string="Email", readonly="True")
+ email_cc = fields.Char(string="Cc")
+ subject = fields.Char(string="Subject", readonly="True")
+ body = fields.Text(string="Message Body")
+ template_id = fields.Many2one('mail.template', string="Mail Template", domain="[('model_id','=','website.support.ticket'), ('built_in','=',False)]")
+ approval = fields.Boolean(string="Approval")
+ planned_time = fields.Datetime(string="Planned Time")
+ attachment_ids = fields.Many2many('ir.attachment', 'sms_compose_attachment_rel', 'sms_compose_id', 'attachment_id', 'Attachments')
+
+ @api.onchange('template_id')
+ def _onchange_template_id(self):
+ if self.template_id:
+ values = self.env['mail.compose.message'].generate_email_for_composer(self.template_id.id, [self.ticket_id.id])[self.ticket_id.id]
+ self.body = values['body']
+
+ @api.one
+ def send_reply(self):
+
+ #Change the approval state before we send the mail
+ if self.approval:
+ #Change the ticket state to awaiting approval
+ awaiting_approval_state = self.env['ir.model.data'].get_object('website_support','website_ticket_state_awaiting_approval')
+ self.ticket_id.state = awaiting_approval_state.id
+
+ #One support request per ticket...
+ self.ticket_id.planned_time = self.planned_time
+ self.ticket_id.approval_message = self.body
+ self.ticket_id.sla_active = False
+
+ #Send email
+ values = {}
+
+ setting_staff_reply_email_template_id = self.env['ir.default'].get('website.support.settings', 'staff_reply_email_template_id')
+
+ if setting_staff_reply_email_template_id:
+ email_wrapper = self.env['mail.template'].browse(setting_staff_reply_email_template_id)
+
+ values = email_wrapper.generate_email([self.id])[self.id]
+ values['model'] = "website.support.ticket"
+ values['res_id'] = self.ticket_id.id
+ values['reply_to'] = email_wrapper.reply_to
+
+ if self.email_cc:
+ values['email_cc'] = self.email_cc
+
+ for attachment in self.attachment_ids:
+ values['attachment_ids'].append((4, attachment.id))
+
+ send_mail = self.env['mail.mail'].create(values)
+ send_mail.send()
+
+ #Add to the message history to keep the data clean from the rest HTML
+ self.env['website.support.ticket.message'].create({'ticket_id': self.ticket_id.id, 'by': 'staff', 'content':self.body.replace("
","").replace("
","")})
+
+ #Post in message history
+ #self.ticket_id.message_post(body=self.body, subject=self.subject, message_type='comment', subtype='mt_comment')
+
+ if self.approval:
+ #Also change the approval
+ awaiting_approval = self.env['ir.model.data'].get_object('website_support','awaiting_approval')
+ self.ticket_id.approval_id = awaiting_approval.id
+ else:
+ #Change the ticket state to staff replied
+ staff_replied = self.env['ir.model.data'].get_object('website_support','website_ticket_state_staff_replied')
+ self.ticket_id.state = staff_replied.id
\ No newline at end of file
diff --git a/website_support/security/ir.model.access.csv b/website_support/security/ir.model.access.csv
new file mode 100644
index 000000000..c147dce85
--- /dev/null
+++ b/website_support/security/ir.model.access.csv
@@ -0,0 +1,41 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_website_support_ticket,access website.support.ticket,model_website_support_ticket,support_staff,1,1,1,0
+employee_access_website_support_ticket,employee access website.support.ticket,model_website_support_ticket,base.group_user,1,0,0,0
+access_website_support_ticket_compose,access website.support.ticket.compose,model_website_support_ticket_compose,support_staff,1,1,1,0
+access_website_support_ticket_states,support staff access website.support.ticket.states,model_website_support_ticket_states,support_staff,1,0,0,0
+access_website_support_ticket_states_maanger,support manager access website.support.ticket.states,model_website_support_ticket_states,support_manager,1,1,1,1
+access_website_support_ticket_message,access website.support.ticket.message,model_website_support_ticket_message,support_staff,1,0,1,0
+access_mail_followers_support,public access.mail.followers.support,mail.model_mail_followers,base.group_public,0,0,1,0
+access_website_support_ticket_categories,support staff access website.support.ticket.categories,model_website_support_ticket_categories,support_staff,1,0,0,0
+access_website_support_ticket_categories_manager,support manager access website.support.ticket.categories,model_website_support_ticket_categories,support_manager,1,1,1,1
+access_website_support_ticket_subcategory,support staff access website.support.ticket.subcategory,model_website_support_ticket_subcategory,support_staff,1,0,0,0
+access_website_support_ticket_subcategory_manager,support manager access website.support.ticket.subcategory,model_website_support_ticket_subcategory,support_manager,1,1,1,1
+access_website_support_help_groups,public access website.support.help.groups,model_website_support_help_groups,,1,0,0,0
+access_website_support_help_groups_manager,support manager access website.support.help.groups,model_website_support_help_groups,support_manager,1,1,1,1
+access_website_support_help_page,public access website.support.help.page,model_website_support_help_page,,1,0,0,0
+access_website_support_help_page_manager,support manager access website.support.help.page,model_website_support_help_page,support_manager,1,1,1,1
+access_website_support_help_page_feedback,public access website.support.help.page.feedback,model_website_support_help_page_feedback,base.group_public,1,0,0,0
+access_website_support_help_page_feedback_manager,support manager access website.support.help.page.feedback,model_website_support_help_page_feedback,support_manager,1,1,1,1
+access_website_support_ticket_priority,support staff access website.support.ticket.priority,model_website_support_ticket_priority,support_staff,1,0,0,0
+access_website_support_ticket_priority_manager,support manager access website.support.ticket.priority,model_website_support_ticket_priority,support_manager,1,1,1,1
+access_website_support_ticket_tag,support staff access website.support.ticket.tag,model_website_support_ticket_tag,support_staff,1,0,0,0
+access_website_support_ticket_tag_manager,support manager access website.support.ticket.tag,model_website_support_ticket_tag,support_manager,1,1,1,1
+access_website_support_ticket_close,support staff access website.support.ticket.close,model_website_support_ticket_close,support_staff,1,1,1,1
+access_website_support_ticket_approval,access website.support.ticket.approval,model_website_support_ticket_approval,support_staff,1,1,1,1
+access_website_support_ticket_merge,access website.support.ticket.merge,model_website_support_ticket_merge,support_staff,1,1,1,1
+access_website_support_department_contact,access website.support.department.contact,model_website_support_department_contact,support_manager,1,1,1,1
+access_website_support_ticket_subcategory_field,support staff access website.support.ticket.subcategory.field,model_website_support_ticket_subcategory_field,support_staff,1,0,0,0
+access_website_support_ticket_subcategory_field_manager,support manager access website.support.ticket.subcategory.field,model_website_support_ticket_subcategory_field,support_manager,1,1,1,1
+access_website_support_ticket_field,public access website.support.ticket.field,model_website_support_ticket_field,base.group_public,1,1,1,0
+access_website_support_ticket_field_staff,support staff access website.support.ticket.field,model_website_support_ticket_field,support_staff,1,1,1,1
+access_website_support_sla,support staff access website.support.sla,model_website_support_sla,support_staff,1,0,0,0
+access_website_support_sla_manager,support manager access website.support.sla,model_website_support_sla,support_manager,1,1,1,1
+access_website_support_sla_response,support staff access website.support.sla.response,model_website_support_sla_response,support_staff,1,0,0,0
+access_website_support_sla_response_manager,support manager access website.support.sla.response,model_website_support_sla_response,support_manager,1,1,1,1
+access_website_support_sla_alert,support staff access website.support.sla.alert,model_website_support_sla_alert,support_staff,1,0,0,0
+access_website_support_sla_alert_maanger,support manager access website.support.sla.alert,model_website_support_sla_alert,support_manager,1,1,1,1
+access_ir_rule,support manager access ir.rule,base.model_ir_rule,support_manager,1,0,0,0
+access_ir_config_parameter,support manager access ir.config_parameter,base.model_ir_config_parameter,support_manager,1,0,0,0
+access_res_users,support manager access res.users,base.model_res_users,support_manager,1,0,1,0
+access_website_support_sla_rule,support manager access website.support.sla.rule,model_website_support_sla_rule,support_manager,1,1,1,1
+access_website_support_sla_rule_condition,support manager access website.support.sla.rule.condition,model_website_support_sla_rule_condition,support_manager,1,1,1,1
\ No newline at end of file
diff --git a/website_support/static/description/1.jpg b/website_support/static/description/1.jpg
new file mode 100644
index 000000000..0b883147a
Binary files /dev/null and b/website_support/static/description/1.jpg differ
diff --git a/website_support/static/description/2.jpg b/website_support/static/description/2.jpg
new file mode 100644
index 000000000..fffe2ba47
Binary files /dev/null and b/website_support/static/description/2.jpg differ
diff --git a/website_support/static/description/3.jpg b/website_support/static/description/3.jpg
new file mode 100644
index 000000000..97c8f0d22
Binary files /dev/null and b/website_support/static/description/3.jpg differ
diff --git a/website_support/static/description/4.jpg b/website_support/static/description/4.jpg
new file mode 100644
index 000000000..57d985c35
Binary files /dev/null and b/website_support/static/description/4.jpg differ
diff --git a/website_support/static/description/5.jpg b/website_support/static/description/5.jpg
new file mode 100644
index 000000000..34736c070
Binary files /dev/null and b/website_support/static/description/5.jpg differ
diff --git a/website_support/static/description/6.jpg b/website_support/static/description/6.jpg
new file mode 100644
index 000000000..f34c637f0
Binary files /dev/null and b/website_support/static/description/6.jpg differ
diff --git a/website_support/static/description/7.jpg b/website_support/static/description/7.jpg
new file mode 100644
index 000000000..0e62d6655
Binary files /dev/null and b/website_support/static/description/7.jpg differ
diff --git a/website_support/static/description/icon.png b/website_support/static/description/icon.png
new file mode 100644
index 000000000..eee8c3877
Binary files /dev/null and b/website_support/static/description/icon.png differ
diff --git a/website_support/static/description/index.html b/website_support/static/description/index.html
new file mode 100644
index 000000000..063436278
--- /dev/null
+++ b/website_support/static/description/index.html
@@ -0,0 +1,179 @@
+
+
Description
+A helpdesk / support ticket system for your website
+
+
+
+
Configure Support Ticket System
+
+
+
+
+
+
+
+Create custom categories and assigns users to each category and they will instantly be notified when a new support ticket arrives for thier department.
+
+Instructions
+
+
Assign yourself to the 'Support Manager' group
+
Go to Customer Support->Configuration->Categories and select user that should respond to that type of ticket
+
Alternatvily go to the user and select the support ticket group they should respond too
+
+
+
+
+
+
+
+
+
Create Help Groups / Pages
+
+
+Reduce the amount of support tickets your staff have to answer by providing help pages for your customers to read
+
+Instructions
+
+
Assign yourself to the 'Support Manager' group
+
Go to the Support menu on your website
+
Click on the content then "New Help Group" and type in a name
+
Click on the content then "New Help Page" and select a help group, after which you can rename the help page and drag and drop snippets to create a fancy help page
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Submit Support Ticket Online
+
+
+
+
+
+
+
+Both registered and public user can submit tickets via your website.
+
+Instructions
+
+
Go to Support menu on your website and click on "Submit a ticket"
+
Fill in the form and someone should get back to you soon.
+
+
+
+
+
+
+
+
+
View Support Tickets Online
+
+
+User that were logged in at the time of submitting a ticket can view a history of all thier support tickets.
+
+Instructions
+
+
Go to Support menu on your website and click on "My Tickets"
+
You can click on an individual ticket if you want view more information
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Respond to support tickets online
+
+
+
+
+
+
+
+User can view a history of thier ticket online and add a comment.
+
+Instructions
+
+
Go to Support menu on your website and click on "My Tickets"
+
You can click on an individual ticket to view it
+
Type a comment and wait for a response
+
+
+
+
+
+
+
+
+
Email Integration
+
+
+Email conversations are tracked for each ticket so customers can reply directly via email without ever having to go to the website or login.
+Emails are automatically sent when a ticket category is changed or the ticket is closed by staff
+When the customer replies via email the ticket state is automatically set to 'Customer Replied' allowing you to easily see at a glance which tickets need attending to
+Operators can reply online via Customer Support->Support Tickets
+
+Per Operator Instructions
+
+
Assign the operator to the 'Support Staff' group
+
Setup outgoing email for all your support ticket operators
+
Go to Customer Support->Support Tickets and open one
+
Hit the reply button in the header
+
Type a message and the user will get sent an email alongside information about the ticket
+
Email replies will appear in the chatter as well as the online ticket view
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Create Support Ticket via Email
+
+
+
+
+
+
+
+Automatically create support ticket when an email is fetched by Odoo.
+
+Instructions
+1. Go to Settings->Technicial->Email->Incoming Mail Servers and create a new record e.g. support@ or helpdesk@
+2. Also setup an outgoing mail server for your support email address
+3. Lastly go to Customer Support->Configuration->Settings and setup a default category for tickets created via email
+
+ ]]>
+
+
+
+
+ Support Ticket User Change
+
+ ]]>
+ A Support Ticket has been assigned to you
+
+
+ Dear _user_name_,
+
A support ticket has been assigned to you here are the details
+
+ Ticket Number: ${object.ticket_number or object.id}
+ Ticket Category:
+ % if object.category.name :
+ ${object.category.name}
+ % endif
+
+ Ticket Description:
+ ${object.description|safe}
+
+ ]]>
+
+
+
+
+ Support Ticket Category Change
+
+ ]]>
+ ${object.email|safe}
+ Your support ticket has been updated
+
+
+ Dear ${object.person_name},
+
Your support ticket has been updated and is now in the category '${object.category.name}'
+
+ Ticket Number: ${object.ticket_number or object.id}
+ Ticket Category:
+ % if object.category.name :
+ ${object.category.name}
+ % endif
+
+ Ticket Description:
+ ${object.description|safe}
+
+ ]]>
+
+
+
+
+ Support Ticket Close Wrapper
+
+ ]]>
+ ${object.ticket_id.email|safe}
+ Your support ticket has been closed
+
+
+ Dear ${object.ticket_id.person_name},
+
Your support ticket has been closed by our staff
+ % if object.message:
+
Here is the final comment
+
${object.message|safe or ''}
+ % endif
+
+ Ticket Number: ${object.ticket_id.ticket_number or object.ticket_id.id}
+ Ticket Category:
+ % if object.ticket_id.category.name :
+ ${object.ticket_id.category.name}
+ % endif
+
+ Ticket Description:
+ ${object.ticket_id.description|safe}
+
+ ]]>
+
+
+
+
+ Support Ticket Closed
+
+ ]]>
+ ${object.email|safe}
+ Your support ticket has been closed
+
+
+ Dear ${object.person_name},
+
Your support ticket has been closed by our staff
+ % if object.close_comment:
+
Here is the final comment
+
${object.close_comment|safe or ''}
+ % endif
+
+ Ticket Number: ${object.ticket_number or object.id}
+ Ticket Category:
+ % if object.category.name :
+ ${object.category.name}
+ % endif
+
+ Ticket Description:
+ ${object.description|safe}
+
+ ]]>
+
+
+
+
+ Support Ticket New
+
+ ]]>
+ ${object.email|safe}
+ We have received your support ticket '${object.subject}' and will get back to you shortly
+
+
+ Dear ${object.person_name},
+ % endif
+
Thank you for submitting a support ticket to us, we will get back to your shortly
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support_analytic_timesheets/views/website_support_ticket_views.xml b/website_support_analytic_timesheets/views/website_support_ticket_views.xml
new file mode 100644
index 000000000..015443423
--- /dev/null
+++ b/website_support_analytic_timesheets/views/website_support_ticket_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+ website.support.ticket.view.form.inherit.timesheets
+ website.support.ticket
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/website_support_billing/__init__.py b/website_support_billing/__init__.py
new file mode 100644
index 000000000..5305644df
--- /dev/null
+++ b/website_support_billing/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import models
\ No newline at end of file
diff --git a/website_support_billing/__manifest__.py b/website_support_billing/__manifest__.py
new file mode 100644
index 000000000..4dfc447a1
--- /dev/null
+++ b/website_support_billing/__manifest__.py
@@ -0,0 +1,18 @@
+{
+ 'name': "Website Help Desk / Support Ticket - Billing",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary':'Generate invoices based on amount of time spent on ticket / tasks',
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/res_partner_views.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'demo': [],
+ 'depends': ['website_support','website_support_analytic_timesheets', 'account_invoicing'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/website_support_billing/doc/changelog.rst b/website_support_billing/doc/changelog.rst
new file mode 100644
index 000000000..f160755b1
--- /dev/null
+++ b/website_support_billing/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0.0
+======
+* Initial Release
\ No newline at end of file
diff --git a/website_support_billing/models/__init__.py b/website_support_billing/models/__init__.py
new file mode 100644
index 000000000..cd5f4d832
--- /dev/null
+++ b/website_support_billing/models/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import res_partner
\ No newline at end of file
diff --git a/website_support_billing/models/res_partner.py b/website_support_billing/models/res_partner.py
new file mode 100644
index 000000000..ff999e6ba
--- /dev/null
+++ b/website_support_billing/models/res_partner.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+import logging
+_logger = logging.getLogger(__name__)
+import datetime
+
+from openerp import api, fields, models, tools
+
+class ResPartnerSupportBilling(models.Model):
+
+ _inherit = "res.partner"
+
+ @api.multi
+ def support_billing_action(self):
+ self.ensure_one()
+
+ return {
+ 'name': 'Support Billing Invoice Wizard',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'res.partner.support.billing.wizard',
+ 'type': 'ir.actions.act_window',
+ 'target': 'new',
+ 'context': {'default_partner_id': self.id}
+ }
+
+class ResPartnerSupportBillingWizard(models.Model):
+
+ _name = "res.partner.support.billing.wizard"
+
+ partner_id = fields.Many2one('res.partner', string="Partner")
+ start_date = fields.Date(string="Start Date", required="True")
+ end_date = fields.Date(string="End Date", required="True")
+ per_hour_charge = fields.Float(string="Per Hour Charge")
+
+ @api.multi
+ def generate_invoice(self):
+ self.ensure_one()
+
+ new_invoice = self.env['account.invoice'].create({
+ 'partner_id': self.partner_id.id,
+ 'account_id': self.partner_id.property_account_receivable_id.id,
+ 'fiscal_position_id': self.partner_id.property_account_position_id.id,
+ })
+
+ if self.partner_id.company_type == "person":
+
+ #Get all the invoice lines for just a single individual
+ self.partner_ticket_task_charge(new_invoice, self.partner_id)
+
+ elif self.partner_id.company_type == "company":
+
+ #Loop through all contacts and get the invoice lines
+ for company_contact in self.partner_id.child_ids:
+ self.partner_ticket_task_charge(new_invoice, company_contact)
+
+ new_invoice.compute_taxes()
+
+ return {
+ 'name': 'Support Billing Invoice',
+ 'view_type': 'form',
+ 'view_mode': 'form',
+ 'res_model': 'account.invoice',
+ 'type': 'ir.actions.act_window',
+ 'res_id': new_invoice.id
+ }
+
+ def partner_ticket_task_charge(self, new_invoice, partner):
+
+ partner_tasks = self.env['project.task'].search([('partner_id','=', partner.id), ('create_date','>=',self.start_date), ('create_date','<=',self.end_date)])
+ partner_tickets = self.env['website.support.ticket'].search([('partner_id','=', partner.id), ('create_date','>=',self.start_date), ('create_date','<=',self.end_date)])
+
+ for support_ticket in partner_tickets:
+ total_hours = 0
+
+ for timesheet_line in support_ticket.analytic_timesheet_ids:
+ total_hours += timesheet_line.unit_amount
+
+ total_charge = total_hours * self.per_hour_charge
+
+ line_values = {
+ 'name': support_ticket.subject,
+ 'price_unit': total_charge,
+ 'invoice_id': new_invoice.id,
+ 'account_id': new_invoice.journal_id.default_credit_account_id.id
+ }
+
+ new_invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
+
+ for task in partner_tasks:
+ total_hours = 0
+
+ for timesheet_line in task.timesheet_ids:
+ total_hours += timesheet_line.unit_amount
+
+ total_charge = total_hours * self.per_hour_charge
+
+ line_values = {
+ 'name': task.name,
+ 'price_unit': total_charge,
+ 'invoice_id': new_invoice.id,
+ 'account_id': new_invoice.journal_id.default_credit_account_id.id
+ }
+
+ new_invoice.write({'invoice_line_ids': [(0, 0, line_values)]})
\ No newline at end of file
diff --git a/website_support_billing/security/ir.model.access.csv b/website_support_billing/security/ir.model.access.csv
new file mode 100644
index 000000000..362b55cbd
--- /dev/null
+++ b/website_support_billing/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_res_partner_support_billing_wizard,access res.partner.support.billing.wizard,model_res_partner_support_billing_wizard,,1,1,1,0
\ No newline at end of file
diff --git a/website_support_billing/static/description/1.jpg b/website_support_billing/static/description/1.jpg
new file mode 100644
index 000000000..9fb80ee1c
Binary files /dev/null and b/website_support_billing/static/description/1.jpg differ
diff --git a/website_support_billing/static/description/icon.png b/website_support_billing/static/description/icon.png
new file mode 100644
index 000000000..eee8c3877
Binary files /dev/null and b/website_support_billing/static/description/icon.png differ
diff --git a/website_support_billing/static/description/index.html b/website_support_billing/static/description/index.html
new file mode 100644
index 000000000..135ac2887
--- /dev/null
+++ b/website_support_billing/static/description/index.html
@@ -0,0 +1,6 @@
+
+
Description
+
Send invoices
+Generate invoices based on amount of time spent on ticket / tasks
+
+
\ No newline at end of file
diff --git a/website_support_billing/views/res_partner_views.xml b/website_support_billing/views/res_partner_views.xml
new file mode 100644
index 000000000..3392d7160
--- /dev/null
+++ b/website_support_billing/views/res_partner_views.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ res.partner.support.billing.wizard.form.view
+ res.partner.support.billing.wizard
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Generate Timesheet Invoice
+
+
+ True
+ code
+ action = record.support_billing_action()
+
+
+
+
\ No newline at end of file
diff --git a/website_support_gamification/__init__.py b/website_support_gamification/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/website_support_gamification/__openerp__.py b/website_support_gamification/__openerp__.py
new file mode 100644
index 000000000..f13673a31
--- /dev/null
+++ b/website_support_gamification/__openerp__.py
@@ -0,0 +1,17 @@
+{
+ 'name': "Website Help Desk / Support Ticket - Gamification",
+ 'version': "1.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Adds badges to support tickets",
+ 'description': "Adds badges to support tickets",
+ 'license':'LGPL-3',
+ 'data': [
+ 'data/gamification_data.xml',
+ ],
+ 'demo': [],
+ 'depends': ['website_support_analytic_timesheets', 'gamification'],
+ 'images':[
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/website_support_gamification/data/gamification_data.xml b/website_support_gamification/data/gamification_data.xml
new file mode 100644
index 000000000..7c5628f88
--- /dev/null
+++ b/website_support_gamification/data/gamification_data.xml
@@ -0,0 +1,422 @@
+
+
+
+
+
+
+
+ Ticket Completed
+
+ count
+
+ [('closed_by_id',"=",user.id)]
+
+
+
+ Time on Support Ticket
+
+ sum
+
+
+ [('user_id',"=",user.id), ('support_ticket_id','!=', False)]
+
+
+
+ Positive Feedback
+
+ count
+
+ [('user_id',"=",user.id), ('support_rating','>=', 4)]
+
+
+
+ Perfect Feedback
+ 5 star rating
+ count
+
+
+ [('user_id',"=",user.id), ('support_rating','=', 5)]
+
+
+
+ Non Perfect Feedback
+ 1-4 star rating
+ count
+ lower
+
+
+ [('user_id',"=",user.id), ('support_rating','>', 0), ('support_rating','<', 5)]
+
+
+
+ Ticket completed in less than 15 minutes of submission
+
+ count
+
+ [('user_id',"=",user.id), ('close_time','!=', False), ('time_to_close','<', 900)]
+
+
+
+ Ticket completed in less than 10 minutes of submission
+
+ count
+
+ [('user_id',"=",user.id), ('close_time','!=', False), ('time_to_close','<', 600)]
+
+
+
+ Ticket completed in less than 5 minutes of submission
+
+ count
+
+ [('user_id',"=",user.id), ('close_time','!=', False), ('time_to_close','<', 300)]
+
+
+
+ Tickets completed in a day
+
+ count
+
+
+ [('closed_by_id',"=",user.id)]
+
+
+
+ Tickets completed in a week
+
+ count
+
+
+ [('closed_by_id',"=",user.id)]
+
+
+
+
+ First Ticket Completed
+ First Ticket Completed
+ nobody
+
+
+
+ First Ticket Completed
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ First Positive Feedback
+ First Positive Feedback
+ nobody
+
+
+ First Positive Feedback
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ Spent 1 hour on support tickets
+ Spent 1 hour on support tickets
+ nobody
+
+
+ Spend 1 hour on support tickets
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ Ticket completed in less than 15 minutes of submission
+ Ticket completed in less than 15 minutes of submission
+ nobody
+
+
+ Ticket completed in less than 15 minutes of submission
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ Ticket completed in less than 10 minutes of submission
+ Ticket completed in less than 10 minutes of submission
+ nobody
+
+
+ Ticket completed in less than 10 minutes of submission
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ Ticket completed in less than 5 minutes of submission
+ Ticket completed in less than 5 minutes of submission
+ nobody
+
+
+ Ticket completed in less than 5 minutes of submission
+
+ inprogress
+
+
+
+
+ 1
+
+
+
+
+
+ Seven tickets completed in a day
+ Seven tickets completed in a day
+ nobody
+
+
+ Seven tickets completed in a day
+
+ inprogress
+ daily
+
+
+
+
+ 7
+
+
+
+
+
+ Eight tickets completed in a day
+ Eight tickets completed in a day
+ nobody
+
+
+ Eight tickets completed in a day
+
+ inprogress
+ daily
+
+
+
+
+ 8
+
+
+
+
+
+ Nine tickets completed in a day
+ Nine tickets completed in a day
+ nobody
+
+
+ Nine tickets completed in a day
+
+ inprogress
+ daily
+
+
+
+
+ 9
+
+
+
+
+
+ Ten tickets completed in a day
+ Ten tickets completed in a day
+ nobody
+
+
+ Ten tickets completed in a day
+
+ inprogress
+ daily
+
+
+
+
+ 10
+
+
+
+
+
+ More than 10 tickets completed in a week
+ More than 10 tickets completed in a week
+ nobody
+
+
+ More than 10 tickets completed in a week
+
+ inprogress
+ weekly
+
+
+
+
+ 10
+
+
+
+
+
+ More than 25 tickets completed in a week
+ More than 25 tickets completed in a week
+ nobody
+
+
+ More than 25 tickets completed in a week
+
+ inprogress
+ weekly
+
+
+
+
+ 25
+
+
+
+
+
+ More than 30 tickets completed in a week
+ More than 30 tickets completed in a week
+ nobody
+
+
+ More than 30 tickets completed in a week
+
+ inprogress
+ weekly
+
+
+
+
+ 30
+
+
+
+
+
+ More than 35 tickets completed in a week
+ More than 35 tickets completed in a week
+ nobody
+
+
+ More than 35 tickets completed in a week
+
+ inprogress
+ weekly
+
+
+
+
+ 35
+
+
+
+
+
+ More than 40 tickets completed in a week
+ More than 40 tickets completed in a week
+ nobody
+
+
+ More than 40 tickets completed in a week
+
+ inprogress
+ weekly
+
+
+
+
+ 40
+
+
+
+
+
+ Nothing less than 5 star feedback for a week
+ Nothing less than 5 star feedback for a week
+ nobody
+
+
+ Nothing less than 5 star feedback for a week
+
+ inprogress
+ weekly
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+
+
+
+ Nothing less than 5 star feedback for a month
+ Nothing less than 5 star feedback for a month
+ nobody
+
+
+ Nothing less than 5 star feedback for a month
+
+ inprogress
+ monthly
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+
+
+
diff --git a/website_support_gamification/doc/changelog.rst b/website_support_gamification/doc/changelog.rst
new file mode 100644
index 000000000..f428dba03
--- /dev/null
+++ b/website_support_gamification/doc/changelog.rst
@@ -0,0 +1,3 @@
+v1.0
+====
+* Port to version 11
\ No newline at end of file
diff --git a/website_support_gamification/static/description/icon.png b/website_support_gamification/static/description/icon.png
new file mode 100644
index 000000000..eee8c3877
Binary files /dev/null and b/website_support_gamification/static/description/icon.png differ
diff --git a/website_support_gamification/static/description/index.html b/website_support_gamification/static/description/index.html
new file mode 100644
index 000000000..3a27c3df5
--- /dev/null
+++ b/website_support_gamification/static/description/index.html
@@ -0,0 +1,24 @@
+
+
Description
+Adds badges to support tickets
+
+Includes the following per user demo challenges
+
+
First ticket Completed
+
First positive feedback
+
Ticket completed in less than 15 minutes of submission
+
Ticket Completed in less than 10 minutes of submission
+
Ticket Completed in less than 5 minutes of submission
+
Seven tickets completed in a day
+
Eight tickets completed in a day
+
Nine tickets completed in a day
+
Ten tickets completed in a day
+
More than 10 tickets completed in a week
+
More than 25 tickets completed in a week
+
More than 30 tickets completed in a week
+
More than 35 tickets completed in a week
+
More than 40 tickets completed in a week
+
Nothing less than 5 star feedback for a week
+
Nothing less than 5 star feedback for a month
+
+
\ No newline at end of file
diff --git a/website_videos/__init__.py b/website_videos/__init__.py
new file mode 100644
index 000000000..e89927ca6
--- /dev/null
+++ b/website_videos/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+
+from . import models
+from . import controllers
\ No newline at end of file
diff --git a/website_videos/__manifest__.py b/website_videos/__manifest__.py
new file mode 100644
index 000000000..e69c824fb
--- /dev/null
+++ b/website_videos/__manifest__.py
@@ -0,0 +1,20 @@
+{
+ 'name': "Videos",
+ 'version': "1.0.0",
+ 'author': "Sythil Tech",
+ 'category': "Tools",
+ 'summary': "Allows users to upload videos to your website",
+ 'license':'LGPL-3',
+ 'data': [
+ 'views/video_video_views.xml',
+ 'views/website_videos_templates.xml',
+ 'views/menus.xml',
+ 'data/website.menu.csv'
+ ],
+ 'demo': [],
+ 'depends': ['website'],
+ 'images':[
+ 'static/description/1.jpg',
+ ],
+ 'installable': True,
+}
\ No newline at end of file
diff --git a/website_videos/controllers/__init__.py b/website_videos/controllers/__init__.py
new file mode 100644
index 000000000..6920e2020
--- /dev/null
+++ b/website_videos/controllers/__init__.py
@@ -0,0 +1,3 @@
+# -*- coding: utf-8 -*-
+
+from . import main
\ No newline at end of file
diff --git a/website_videos/controllers/main.py b/website_videos/controllers/main.py
new file mode 100644
index 000000000..f8193630d
--- /dev/null
+++ b/website_videos/controllers/main.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+
+import base64
+import werkzeug
+import logging
+_logger = logging.getLogger(__name__)
+
+import odoo.http as http
+from odoo.addons.http_routing.models.ir_http import slug
+from odoo.http import request
+
+class WebsiteVideosController(http.Controller):
+
+ @http.route('/videos', type="http", auth="public", website=True)
+ def videos(self):
+ """Home Page"""
+ return request.render('website_videos.home_page', {})
+
+ @http.route('/videos/upload', type="http", auth="user", website=True)
+ def videos_upload(self):
+ """Video Upload"""
+ return request.render('website_videos.upload', {})
+
+ @http.route('/videos/upload/process', type="http", auth="user")
+ def videos_upload_process(self, **kwargs):
+ create_dict = {}
+ create_dict['name'] = kwargs['name']
+ create_dict['uploader_id'] = request.env.user.id
+ for c_file in request.httprequest.files.getlist('file'):
+ create_dict['data'] = base64.b64encode( c_file.read() )
+ video = request.env['video.video'].create(create_dict)
+ return werkzeug.utils.redirect("/videos/video/" + slug(video))
+
+ @http.route('/videos/stream/