pengyuthon wechat
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import models
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "VoIP",
|
||||||
|
"summary": """Make and receive phone calls from within Odoo.""",
|
||||||
|
"description": """Adds a softphone and helpers to make phone calls directly from within your Odoo database.""",
|
||||||
|
"category": "Productivity/VOIP",
|
||||||
|
"sequence": 280,
|
||||||
|
"version": "2.0",
|
||||||
|
"depends": ["base", "mail", "phone_validation", "web", "web_mobile"],
|
||||||
|
"data": [
|
||||||
|
"security/ir.model.access.csv",
|
||||||
|
"security/voip_security.xml",
|
||||||
|
"views/res_config_settings_views.xml",
|
||||||
|
"views/res_partner_views.xml",
|
||||||
|
"views/res_users_views.xml",
|
||||||
|
"views/res_users_settings_views.xml",
|
||||||
|
"views/voip_call_views.xml",
|
||||||
|
],
|
||||||
|
"application": True,
|
||||||
|
"license": "OEEL-1",
|
||||||
|
"assets": {
|
||||||
|
"voip.assets_sip": [
|
||||||
|
"voip/static/lib/sip.js"
|
||||||
|
],
|
||||||
|
"web.assets_backend": [
|
||||||
|
"voip/static/src/**/*",
|
||||||
|
("remove", "voip/static/src/**/*.dark.scss"),
|
||||||
|
],
|
||||||
|
"web.assets_web_dark": [
|
||||||
|
"voip/static/src/**/*.dark.scss",
|
||||||
|
],
|
||||||
|
"web.tests_assets": [
|
||||||
|
"voip/static/tests/helpers/**/*.js",
|
||||||
|
],
|
||||||
|
"web.qunit_suite_tests": [
|
||||||
|
"voip/static/tests/**/*.js",
|
||||||
|
("remove", "voip/static/tests/helpers/**/*"),
|
||||||
|
("remove", "voip/static/tests/**/*.mobile.js"),
|
||||||
|
],
|
||||||
|
"web.qunit_mobile_suite_tests": [
|
||||||
|
"voip/static/tests/**/*.mobile.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- disable voip
|
||||||
|
INSERT INTO ir_config_parameter (key, value)
|
||||||
|
VALUES ('voip.mode', 'demo')
|
||||||
|
ON CONFLICT (key) DO
|
||||||
|
UPDATE SET value = 'demo';
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
=================
|
||||||
|
Odoo VOIP
|
||||||
|
=================
|
||||||
|
|
||||||
|
Installing Asterisk server
|
||||||
|
==========================
|
||||||
|
|
||||||
|
Before installing Asterisk you need to install the following dependencies:
|
||||||
|
|
||||||
|
* wget
|
||||||
|
* gcc
|
||||||
|
* gcc-c++
|
||||||
|
* ncurses-devel
|
||||||
|
* libxml2-devel
|
||||||
|
* sqlite-devel
|
||||||
|
* libsrtp-devel
|
||||||
|
* libuuid-devel
|
||||||
|
* openssl-devel
|
||||||
|
|
||||||
|
If libsrtp is not available in the repo install it from source.
|
||||||
|
|
||||||
|
* cd /usr/local/src/
|
||||||
|
* wget http://srtp.sourceforge.net/srtp-1.4.2.tgz
|
||||||
|
* tar zxvf srtp-1.4.2.tgz
|
||||||
|
* cd /usr/local/src/srtp
|
||||||
|
* ./configure CFLAGS=-fPIC
|
||||||
|
* make && make install
|
||||||
|
|
||||||
|
You also need to install PJSIP, you can download the source `here <http://www.pjsip.org/download.htm>`_.
|
||||||
|
Once the source directory is extracted:
|
||||||
|
|
||||||
|
* Change to the pjproject source directory:
|
||||||
|
* # cd pjproject
|
||||||
|
* run :
|
||||||
|
* # ./configure --prefix=/usr --enable-shared --disable-sound --disable-resample --disable-video --disable-opencore-amr CFLAGS='-O2 -DNDEBUG'
|
||||||
|
* Build and install pjproject:
|
||||||
|
* # make dep
|
||||||
|
* # make
|
||||||
|
* # make install
|
||||||
|
* update shared library links:
|
||||||
|
* # ldconfig
|
||||||
|
* Verify that pjproject is installed,:
|
||||||
|
* # ldconfig -p | grep pj
|
||||||
|
* The result should be:
|
||||||
|
libpjsua.so (libc6,x86-64) => /usr/lib/libpjsua.so
|
||||||
|
libpjsip.so (libc6,x86-64) => /usr/lib/libpjsip.so
|
||||||
|
libpjsip-ua.so (libc6,x86-64) => /usr/lib/libpjsip-ua.so
|
||||||
|
libpjsip-simple.so (libc6,x86-64) => /usr/lib/libpjsip-simple.so
|
||||||
|
libpjnath.so (libc6,x86-64) => /usr/lib/libpjnath.so
|
||||||
|
libpjmedia.so (libc6,x86-64) => /usr/lib/libpjmedia.so
|
||||||
|
libpjmedia-videodev.so (libc6,x86-64) => /usr/lib/libpjmedia-videodev.so
|
||||||
|
libpjmedia-codec.so (libc6,x86-64) => /usr/lib/libpjmedia-codec.so
|
||||||
|
libpjmedia-audiodev.so (libc6,x86-64) => /usr/lib/libpjmedia-audiodev.so
|
||||||
|
libpjlib-util.so (libc6,x86-64) => /usr/lib/libpjlib-util.so
|
||||||
|
libpj.so (libc6,x86-64) => /usr/lib/libpj.so
|
||||||
|
|
||||||
|
In order to install Asterisk 13.2.0, you can download the source directly `here <http://www.asterisk.org/downloads>`_.
|
||||||
|
|
||||||
|
* Extract Asterisk: tar zxvf asterisk*.
|
||||||
|
* Enter the Asterisk directory: cd /usr/local/src/asterisk*.
|
||||||
|
* Run the Asterisk configure script: ./configure --libdir=/usr/lib64.
|
||||||
|
* Run the Asterisk menuselect tool: make menuselect.
|
||||||
|
* In the menuselect, go to the resources option and ensure that res_srtp is enabled. If there are 3 x’s next to res_srtp, there is a problem with the srtp library and you must reinstall it. Save the configuration (press x). You should also see stars in front of the res_pjsip lines.
|
||||||
|
* Compile and install Asterisk: make && make install.
|
||||||
|
* If you need the sample configs you can run make samples to install the sample configs. If you need to install the Asterisk startup script you can run make config.
|
||||||
|
|
||||||
|
After you need to setup the DTLS certificates.
|
||||||
|
|
||||||
|
* mkdir /etc/asterisk/keys
|
||||||
|
* Enter the Asterisk scripts directory: cd /usr/local/src/asterisk*/contrib/scripts.
|
||||||
|
* Create the DTLS certificates (replace pbx.mycompany.com with your ip address or dns name, replace My Super Company with your company name): ./ast_tls_cert -C pbx.mycompany.com -O "My Super Company" -d /etc/asterisk/keys
|
||||||
|
|
||||||
|
Configure Asterisk server
|
||||||
|
==========================
|
||||||
|
|
||||||
|
For WebRTC, a lot of the settings that are needed MUST be in the peer settings. The global settings do not flow down into the peer settings very well. By default, Asterisk config files are located in /etc/asterisk/. Start by editing http.conf and make sure that the following lines are uncommented::
|
||||||
|
|
||||||
|
;http.conf
|
||||||
|
[general]
|
||||||
|
enabled=yes
|
||||||
|
bindaddr=127.0.0.1 ; Replace this with your IP address
|
||||||
|
bindport=8088 ; Replace this with the port you want to listen on
|
||||||
|
|
||||||
|
Next, edit sip.conf. The WebRTC peer requires encryption, avpf, and icesupport to be enabled. In most cases, directmedia should be disabled. Also under the WebRTC client, the transport needs to be listed as ‘ws’ to allow websocket connections. All of these config lines should be under the peer itself; setting these config lines globally might not work::
|
||||||
|
|
||||||
|
;sip.conf
|
||||||
|
[general]
|
||||||
|
realm=127.0.0.1 ; Replace this with your IP address
|
||||||
|
udpbindaddr=127.0.0.1 ; Replace this with your IP address
|
||||||
|
transport=udp
|
||||||
|
|
||||||
|
[1060] ; This will be WebRTC client
|
||||||
|
type=friend
|
||||||
|
username=1060 ; The Auth user for SIP.js
|
||||||
|
host=dynamic ; Allows any host to register
|
||||||
|
secret=password ; The SIP Password for SIP.js
|
||||||
|
encryption=yes ; Tell Asterisk to use encryption for this peer
|
||||||
|
avpf=yes ; Tell Asterisk to use AVPF for this peer
|
||||||
|
icesupport=yes ; Tell Asterisk to use ICE for this peer
|
||||||
|
context=default ; Tell Asterisk which context to use when this peer is dialing
|
||||||
|
directmedia=no ; Asterisk will relay media for this peer
|
||||||
|
transport=udp,ws ; Asterisk will allow this peer to register on UDP or WebSockets
|
||||||
|
force_avp=yes ; Force Asterisk to use avp. Introduced in Asterisk 11.11
|
||||||
|
dtlsenable=yes ; Tell Asterisk to enable DTLS for this peer
|
||||||
|
dtlsverify=no ; Tell Asterisk to not verify your DTLS certs
|
||||||
|
dtlscertfile=/etc/asterisk/keys/asterisk.pem ; Tell Asterisk where your DTLS cert file is
|
||||||
|
dtlsprivatekey=/etc/asterisk/keys/asterisk.pem ; Tell Asterisk where your DTLS private key is
|
||||||
|
dtlssetup=actpass ; Tell Asterisk to use actpass SDP parameter when setting up DTLS
|
||||||
|
|
||||||
|
In the sip.conf and rtp.conf files you also need to add or uncomment the lines:
|
||||||
|
|
||||||
|
* icesupport = yes
|
||||||
|
* stunaddr = stun.l.google.com:19302
|
||||||
|
|
||||||
|
Lastly, set up extensions.conf::
|
||||||
|
|
||||||
|
;extensions.conf
|
||||||
|
[default]
|
||||||
|
exten => 1060,1,Dial(SIP/1060) ; Dialing 1060 will call the SIP client registered to 1060
|
||||||
|
|
||||||
|
Configure Odoo VOIP
|
||||||
|
==========================
|
||||||
|
|
||||||
|
In Odoo, the configuration should be done in the user's preferences.
|
||||||
|
|
||||||
|
The VoIP username / Extension number is the number you configured previously in the sip.conf file. In our example, 1060.
|
||||||
|
The VoIP secret is the secret you chose in the sip.conf file.
|
||||||
|
The external device number is not a required field but it is used if you want to transfer your call from Odoo to an external phone also configured in the sip.conf file.
|
||||||
|
|
||||||
|
The configuration needs also to be done in the sale settings under the title "PBX Configuration". You need to put the IP you define in the http.conf file and the WebSocket should be: ws://127.0.0.1:8088/ws. The part "127.0.0.1" needs to be the same as the IP defined previously and the "8088" is the port you defined in the http.conf file.
|
||||||
+1139
File diff suppressed because it is too large
Load Diff
+1135
File diff suppressed because it is too large
Load Diff
+1169
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1121
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1168
File diff suppressed because it is too large
Load Diff
+1143
File diff suppressed because it is too large
Load Diff
+1121
File diff suppressed because it is too large
Load Diff
+1190
File diff suppressed because it is too large
Load Diff
+1141
File diff suppressed because it is too large
Load Diff
+1181
File diff suppressed because it is too large
Load Diff
+1181
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1170
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1122
File diff suppressed because it is too large
Load Diff
+1180
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1181
File diff suppressed because it is too large
Load Diff
+1137
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1139
File diff suppressed because it is too large
Load Diff
+1130
File diff suppressed because it is too large
Load Diff
+1147
File diff suppressed because it is too large
Load Diff
+1122
File diff suppressed because it is too large
Load Diff
+1111
File diff suppressed because it is too large
Load Diff
+1173
File diff suppressed because it is too large
Load Diff
+1116
File diff suppressed because it is too large
Load Diff
+1183
File diff suppressed because it is too large
Load Diff
+1136
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1141
File diff suppressed because it is too large
Load Diff
+1148
File diff suppressed because it is too large
Load Diff
+1135
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1128
File diff suppressed because it is too large
Load Diff
+1120
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1137
File diff suppressed because it is too large
Load Diff
+1150
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1120
File diff suppressed because it is too large
Load Diff
+1140
File diff suppressed because it is too large
Load Diff
+1187
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1142
File diff suppressed because it is too large
Load Diff
+1117
File diff suppressed because it is too large
Load Diff
+1179
File diff suppressed because it is too large
Load Diff
+1126
File diff suppressed because it is too large
Load Diff
+1184
File diff suppressed because it is too large
Load Diff
+1116
File diff suppressed because it is too large
Load Diff
+1125
File diff suppressed because it is too large
Load Diff
+1138
File diff suppressed because it is too large
Load Diff
+1143
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1127
File diff suppressed because it is too large
Load Diff
+1139
File diff suppressed because it is too large
Load Diff
+1165
File diff suppressed because it is too large
Load Diff
+1129
File diff suppressed because it is too large
Load Diff
+1172
File diff suppressed because it is too large
Load Diff
+1170
File diff suppressed because it is too large
Load Diff
+1111
File diff suppressed because it is too large
Load Diff
+1137
File diff suppressed because it is too large
Load Diff
+1137
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from . import voip_queue_mixin # keep this before res_partner import
|
||||||
|
from . import mail_activity
|
||||||
|
from . import res_config_settings
|
||||||
|
from . import res_partner
|
||||||
|
from . import res_users
|
||||||
|
from . import res_users_settings
|
||||||
|
from . import voip_call
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from odoo import api, fields, models, tools
|
||||||
|
|
||||||
|
|
||||||
|
class MailActivity(models.Model):
|
||||||
|
_inherit = "mail.activity"
|
||||||
|
|
||||||
|
phone = fields.Char("Phone", compute="_compute_phone_numbers", readonly=False, store=True)
|
||||||
|
mobile = fields.Char("Mobile", compute="_compute_phone_numbers", readonly=False, store=True)
|
||||||
|
|
||||||
|
@api.depends("res_model", "res_id", "activity_type_id")
|
||||||
|
def _compute_phone_numbers(self):
|
||||||
|
call_activities = self.filtered(
|
||||||
|
lambda activity: activity.id
|
||||||
|
and activity.res_model
|
||||||
|
and activity.res_id
|
||||||
|
and activity.activity_category == "phonecall"
|
||||||
|
)
|
||||||
|
(self - call_activities).phone = False
|
||||||
|
(self - call_activities).mobile = False
|
||||||
|
phone_numbers_by_activity = call_activities._get_phone_numbers_by_activity()
|
||||||
|
for activity in call_activities:
|
||||||
|
activity.mobile = phone_numbers_by_activity[activity]["mobile"]
|
||||||
|
activity.phone = phone_numbers_by_activity[activity]["phone"]
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
activities = super().create(vals_list)
|
||||||
|
call_activities = activities.filtered(
|
||||||
|
lambda activity: (activity.phone or activity.mobile) and activity.activity_category == "phonecall"
|
||||||
|
)
|
||||||
|
notifications = [[user.partner_id, "refresh_call_activities", {}] for user in call_activities.user_id]
|
||||||
|
self.env["bus.bus"]._sendmany(notifications)
|
||||||
|
return activities
|
||||||
|
|
||||||
|
def write(self, values):
|
||||||
|
if "date_deadline" in values and self.user_id:
|
||||||
|
self.env["bus.bus"]._sendmany(
|
||||||
|
[[partner, "refresh_call_activities", {}] for partner in self.user_id.partner_id]
|
||||||
|
)
|
||||||
|
return super().write(values)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_today_call_activities(self):
|
||||||
|
"""Retrieve the list of activities that:
|
||||||
|
* have the type “phonecall”
|
||||||
|
* have a phone number
|
||||||
|
* are overdue
|
||||||
|
* are assigned to the current user
|
||||||
|
* are in the current company
|
||||||
|
|
||||||
|
The resulting list is intended for display in the “Next Activities” tab.
|
||||||
|
"""
|
||||||
|
overdue_call_activities_of_current_user = self.search(
|
||||||
|
[
|
||||||
|
("activity_type_id.category", "=", "phonecall"),
|
||||||
|
("user_id", "=", self.env.uid),
|
||||||
|
("date_deadline", "<=", fields.Date.today()),
|
||||||
|
"|",
|
||||||
|
("mobile", "!=", False),
|
||||||
|
("phone", "!=", False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# ----- Tackling multi-company shenanigans 👺 -----
|
||||||
|
record_ids_by_model_name = defaultdict(set)
|
||||||
|
for activity in overdue_call_activities_of_current_user:
|
||||||
|
record_ids_by_model_name[activity.res_model].add(activity.res_id)
|
||||||
|
allowed_record_ids_by_model_name = defaultdict(list)
|
||||||
|
for model_name, ids in record_ids_by_model_name.items():
|
||||||
|
# calling search will filter out records that are irrelevant to the current company
|
||||||
|
allowed_record_ids_by_model_name[model_name] = self.env[model_name].search([("id", "in", list(ids))]).ids
|
||||||
|
return overdue_call_activities_of_current_user.filtered(
|
||||||
|
lambda activity: activity.res_id in allowed_record_ids_by_model_name[activity.res_model]
|
||||||
|
)._format_call_activities()
|
||||||
|
|
||||||
|
def _action_done(self, feedback=False, attachment_ids=None):
|
||||||
|
"""Extends _action_done to notify the user assigned to a phonecall
|
||||||
|
activity that it has been marked as done. This is useful to trigger the
|
||||||
|
refresh of the “Next Activities” tab.
|
||||||
|
"""
|
||||||
|
to_notify = self.filtered(
|
||||||
|
lambda activity: activity.activity_type_id.category == "phonecall"
|
||||||
|
).user_id.partner_id
|
||||||
|
self.env["bus.bus"]._sendmany([[partner, "refresh_call_activities", {}] for partner in to_notify])
|
||||||
|
return super()._action_done(feedback=feedback, attachment_ids=attachment_ids)
|
||||||
|
|
||||||
|
def _format_call_activities(self):
|
||||||
|
"""Serializes call activities for transmission to/use by the client side."""
|
||||||
|
formatted_activities = []
|
||||||
|
call_activities = self.filtered(lambda activity: activity.activity_type_id.category == "phonecall")
|
||||||
|
for model_name, activities in call_activities.grouped("res_model").items():
|
||||||
|
model = self.env[model_name]
|
||||||
|
records = model.browse(activities.mapped("res_id"))
|
||||||
|
partners_by_records = records._mail_get_partners(introspect_fields=True)
|
||||||
|
partners = self.env["res.partner"].browse([p[:1].id for p in partners_by_records.values() if p])
|
||||||
|
formatted_partners_by_record = partners.mail_partner_format()
|
||||||
|
for activity in activities:
|
||||||
|
activity_data = {
|
||||||
|
**activity.read(["id", "res_name", "phone", "mobile", "res_id", "res_model", "state", "date_deadline", "mail_template_ids"])[0],
|
||||||
|
"activity_category": activity.activity_type_id.category,
|
||||||
|
"modelName": activity.sudo().res_model_id.display_name,
|
||||||
|
"user_id": activity._read_format(["user_id"])[0]["user_id"],
|
||||||
|
}
|
||||||
|
partner = partners_by_records.get(activity.res_id)[:1]
|
||||||
|
if partner:
|
||||||
|
activity_data["partner"] = formatted_partners_by_record[partner]
|
||||||
|
formatted_activities.append(activity_data)
|
||||||
|
return formatted_activities
|
||||||
|
|
||||||
|
def _get_phone_numbers_by_activity(self):
|
||||||
|
"""Batch compute the phone numbers associated with the activities.
|
||||||
|
|
||||||
|
:return dict: for each activity, a sub-dict containing:
|
||||||
|
* phone: phone number (obtained from the activity itself or from the related partner);
|
||||||
|
* mobile: mobile number (obtained from the activity itself or from the related partner).
|
||||||
|
"""
|
||||||
|
phone_numbers_by_activity = {}
|
||||||
|
data_by_model = self._classify_by_model()
|
||||||
|
for model, data in data_by_model.items():
|
||||||
|
records = self.env[model].browse(data["record_ids"])
|
||||||
|
for record, activity in zip(records, data["activities"]):
|
||||||
|
mobile = record.mobile if "mobile" in record else False
|
||||||
|
phone = record.phone if "phone" in record else False
|
||||||
|
if not mobile and not phone:
|
||||||
|
recipient = next(
|
||||||
|
iter(record._mail_get_partners(introspect_fields=True)[record.id]),
|
||||||
|
self.env["res.partner"],
|
||||||
|
)
|
||||||
|
mobile = recipient.mobile
|
||||||
|
phone = recipient.phone
|
||||||
|
phone_numbers_by_activity[activity] = {"mobile": mobile, "phone": phone}
|
||||||
|
return phone_numbers_by_activity
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResConfigSettings(models.TransientModel):
|
||||||
|
_inherit = "res.config.settings"
|
||||||
|
|
||||||
|
wsServer = fields.Char(
|
||||||
|
"WebSocket", help="The URL of your WebSocket", default="ws://localhost", config_parameter="voip.wsServer"
|
||||||
|
)
|
||||||
|
pbx_ip = fields.Char(
|
||||||
|
"PBX Server IP", help="The IP address of your PBX Server", default="localhost", config_parameter="voip.pbx_ip"
|
||||||
|
)
|
||||||
|
mode = fields.Selection(
|
||||||
|
[
|
||||||
|
("demo", "Demo"),
|
||||||
|
("prod", "Production"),
|
||||||
|
],
|
||||||
|
string="VoIP Environment",
|
||||||
|
default="demo",
|
||||||
|
config_parameter="voip.mode",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.osv import expression
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(models.Model):
|
||||||
|
_name = "res.partner"
|
||||||
|
_inherit = ["res.partner", "voip.queue.mixin"]
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_contacts(self, offset, limit, search_terms):
|
||||||
|
domain = ["|", ("phone", "!=", False), ("mobile", "!=", False)]
|
||||||
|
if search_terms:
|
||||||
|
search_fields = ["complete_name", "phone", "mobile", "email"]
|
||||||
|
search_domain = expression.OR([[(field, "ilike", search_terms)] for field in search_fields])
|
||||||
|
domain = expression.AND([domain, search_domain])
|
||||||
|
return self.search(domain, offset=offset, limit=limit)._format_contacts()
|
||||||
|
|
||||||
|
def _format_contacts(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": contact.id,
|
||||||
|
"displayName": contact.display_name,
|
||||||
|
"email": contact.email,
|
||||||
|
"landlineNumber": contact.phone,
|
||||||
|
"mobileNumber": contact.mobile,
|
||||||
|
"name": contact.name,
|
||||||
|
}
|
||||||
|
for contact in self
|
||||||
|
]
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, models, fields
|
||||||
|
|
||||||
|
|
||||||
|
# List of the VoIP-related fields that are configurable by the user using the
|
||||||
|
# `res.users` form view. Useful to override SELF_READABLE/WRITABLE_FIELDS.
|
||||||
|
VOIP_USER_CONFIGURATION_FIELDS = [
|
||||||
|
"external_device_number",
|
||||||
|
"how_to_call_on_mobile",
|
||||||
|
"should_auto_reject_incoming_calls",
|
||||||
|
"should_call_from_another_device",
|
||||||
|
"voip_secret",
|
||||||
|
"voip_username",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = "res.users"
|
||||||
|
|
||||||
|
last_seen_phone_call = fields.Many2one("voip.call")
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# VoIP User Configuration Fields
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# These fields mirror those defined in `res.users.settings`. The reason they
|
||||||
|
# are not directly defined in here is that we want these fields to have
|
||||||
|
# different access rights than the rest of the fields of `res.users`. See
|
||||||
|
# their definition in `res.users.settings` for comprehensive documentation.
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
external_device_number = fields.Char(
|
||||||
|
compute="_compute_external_device_number",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
how_to_call_on_mobile = fields.Selection(
|
||||||
|
[("ask", "Ask"), ("voip", "VoIP"), ("phone", "Device's phone")],
|
||||||
|
compute="_compute_how_to_call_on_mobile",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
should_auto_reject_incoming_calls = fields.Boolean(
|
||||||
|
compute="_compute_should_auto_reject_incoming_calls",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
should_call_from_another_device = fields.Boolean(
|
||||||
|
compute="_compute_should_call_from_another_device",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
voip_secret = fields.Char(
|
||||||
|
compute="_compute_voip_secret",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
voip_username = fields.Char(
|
||||||
|
compute="_compute_voip_username",
|
||||||
|
inverse="_reflect_change_in_res_users_settings",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SELF_READABLE_FIELDS(self):
|
||||||
|
return super().SELF_READABLE_FIELDS + VOIP_USER_CONFIGURATION_FIELDS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def SELF_WRITEABLE_FIELDS(self):
|
||||||
|
return super().SELF_WRITEABLE_FIELDS + VOIP_USER_CONFIGURATION_FIELDS
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.external_device_number")
|
||||||
|
def _compute_external_device_number(self):
|
||||||
|
for user in self:
|
||||||
|
user.external_device_number = user.res_users_settings_id.external_device_number
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.how_to_call_on_mobile")
|
||||||
|
def _compute_how_to_call_on_mobile(self):
|
||||||
|
for user in self:
|
||||||
|
user.how_to_call_on_mobile = user.res_users_settings_id.how_to_call_on_mobile
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.should_auto_reject_incoming_calls")
|
||||||
|
def _compute_should_auto_reject_incoming_calls(self):
|
||||||
|
for user in self:
|
||||||
|
user.should_auto_reject_incoming_calls = user.res_users_settings_id.should_auto_reject_incoming_calls
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.should_call_from_another_device")
|
||||||
|
def _compute_should_call_from_another_device(self):
|
||||||
|
for user in self:
|
||||||
|
user.should_call_from_another_device = user.res_users_settings_id.should_call_from_another_device
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.voip_secret")
|
||||||
|
def _compute_voip_secret(self):
|
||||||
|
for user in self:
|
||||||
|
user.voip_secret = user.res_users_settings_id.voip_secret
|
||||||
|
|
||||||
|
@api.depends("res_users_settings_id.voip_username")
|
||||||
|
def _compute_voip_username(self):
|
||||||
|
for user in self:
|
||||||
|
user.voip_username = user.res_users_settings_id.voip_username
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reset_last_seen_phone_call(self):
|
||||||
|
domain = [("user_id", "=", self.env.user.id)]
|
||||||
|
last_call = self.env["voip.call"].search(domain, order="id desc", limit=1)
|
||||||
|
self.env.user.last_seen_phone_call = last_call.id
|
||||||
|
|
||||||
|
def _init_messaging(self):
|
||||||
|
res = super()._init_messaging()
|
||||||
|
if not self.env.user._is_internal():
|
||||||
|
return res
|
||||||
|
get_param = self.env["ir.config_parameter"].sudo().get_param
|
||||||
|
return {
|
||||||
|
**res,
|
||||||
|
"voipConfig": {
|
||||||
|
"mode": get_param("voip.mode", default="demo"),
|
||||||
|
"missedCalls": self.env["voip.call"]._get_number_of_missed_calls(),
|
||||||
|
"pbxAddress": get_param("voip.pbx_ip", default="localhost"),
|
||||||
|
"webSocketUrl": get_param("voip.wsServer", default="ws://localhost"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _reflect_change_in_res_users_settings(self):
|
||||||
|
"""
|
||||||
|
Updates the values of the VoIP User Configuration Fields in `res_users_settings_ids` to have the same values as
|
||||||
|
their related fields in `res.users`. If there is no `res.users.settings` record for the user, then the record is
|
||||||
|
created.
|
||||||
|
|
||||||
|
This method is intended to be used as an inverse for VoIP Configuration Fields.
|
||||||
|
"""
|
||||||
|
for user in self:
|
||||||
|
settings = self.env["res.users.settings"]._find_or_create_for_user(user)
|
||||||
|
settings.update(
|
||||||
|
{
|
||||||
|
"external_device_number": user.external_device_number,
|
||||||
|
"how_to_call_on_mobile": user.how_to_call_on_mobile or settings.how_to_call_on_mobile,
|
||||||
|
"should_auto_reject_incoming_calls": user.should_auto_reject_incoming_calls,
|
||||||
|
"should_call_from_another_device": user.should_call_from_another_device,
|
||||||
|
"voip_secret": user.voip_secret,
|
||||||
|
"voip_username": user.voip_username,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import fields, models
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsersSettings(models.Model):
|
||||||
|
_inherit = "res.users.settings"
|
||||||
|
|
||||||
|
# Credentials for authentication to the PBX server
|
||||||
|
voip_username = fields.Char(
|
||||||
|
"VoIP username / Extension number",
|
||||||
|
help="The username (typically the extension number) that will be used to register with the PBX server.",
|
||||||
|
)
|
||||||
|
voip_secret = fields.Char("VoIP secret", help="The password that will be used to register with the PBX server.")
|
||||||
|
|
||||||
|
should_call_from_another_device = fields.Boolean(
|
||||||
|
"Call from another device",
|
||||||
|
help="""If enabled, placing a call in Odoo will transfer the call to the "External device number". Use this option to place the call in Odoo but handle it from another device - e.g. your desk phone.""",
|
||||||
|
)
|
||||||
|
external_device_number = fields.Char(
|
||||||
|
"External device number",
|
||||||
|
help="""If the "Call from another device" option is enabled, calls placed in Odoo will be transfered to this phone number.""",
|
||||||
|
)
|
||||||
|
|
||||||
|
should_auto_reject_incoming_calls = fields.Boolean(
|
||||||
|
"Reject incoming calls",
|
||||||
|
help="If enabled, incoming calls will be automatically declined in Odoo.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mobile stuff
|
||||||
|
how_to_call_on_mobile = fields.Selection(
|
||||||
|
[("ask", "Ask"), ("voip", "VoIP"), ("phone", "Device's phone")],
|
||||||
|
default="ask",
|
||||||
|
string="How to place calls on mobile",
|
||||||
|
help="""Choose the method to be used to place a call when using the mobile application:
|
||||||
|
• VoIP: Always use the Odoo softphone
|
||||||
|
• Device's phone: Always use the device's phone
|
||||||
|
• Ask: Always ask whether the softphone or the device's phone must be used
|
||||||
|
""",
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.osv import expression
|
||||||
|
|
||||||
|
|
||||||
|
class VoipCall(models.Model):
|
||||||
|
_name = "voip.call"
|
||||||
|
_description = """A phone call handled using the VoIP application"""
|
||||||
|
|
||||||
|
phone_number = fields.Char(required=True, readonly=True)
|
||||||
|
direction = fields.Selection(
|
||||||
|
[
|
||||||
|
("incoming", "Incoming"),
|
||||||
|
("outgoing", "Outgoing"),
|
||||||
|
],
|
||||||
|
default="outgoing",
|
||||||
|
readonly=True,
|
||||||
|
)
|
||||||
|
state = fields.Selection(
|
||||||
|
[
|
||||||
|
("aborted", "Aborted"),
|
||||||
|
("calling", "Calling"),
|
||||||
|
("missed", "Missed"),
|
||||||
|
("ongoing", "Ongoing"),
|
||||||
|
("rejected", "Rejected"),
|
||||||
|
("terminated", "Terminated"),
|
||||||
|
],
|
||||||
|
default="calling",
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
end_date = fields.Datetime()
|
||||||
|
start_date = fields.Datetime()
|
||||||
|
# Since activities are deleted from the database once marked as done, the
|
||||||
|
# activity name is saved here in order to be preserved.
|
||||||
|
activity_name = fields.Char(help="The name of the activity related to this phone call, if any.")
|
||||||
|
partner_id = fields.Many2one("res.partner", "Contact", index=True)
|
||||||
|
user_id = fields.Many2one("res.users", "Responsible", default=lambda self: self.env.uid, index=True)
|
||||||
|
|
||||||
|
@api.depends("state", "partner_id.name")
|
||||||
|
def _compute_display_name(self):
|
||||||
|
def get_name(call):
|
||||||
|
if call.activity_name:
|
||||||
|
return call.activity_name
|
||||||
|
if call.state == "aborted":
|
||||||
|
return _("Aborted call to %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
if call.state == "missed":
|
||||||
|
return _("Missed call from %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
if call.state == "rejected":
|
||||||
|
if call.direction == "incoming":
|
||||||
|
return _("Rejected call from %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
return _("Rejected call to %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
if call.partner_id:
|
||||||
|
if call.direction == "incoming":
|
||||||
|
return _("Call from %(correspondent)s", correspondent=call.partner_id.name)
|
||||||
|
return _("Call to %(correspondent)s", correspondent=call.partner_id.name)
|
||||||
|
if call.direction == "incoming":
|
||||||
|
return _("Call from %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
return _("Call to %(phone_number)s", phone_number=call.phone_number)
|
||||||
|
|
||||||
|
for call in self:
|
||||||
|
call.display_name = get_name(call)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def create_and_format(self, res_id: int = None, res_model: str = None, **kwargs) -> list:
|
||||||
|
"""Creates a call from the provided values and returns it formatted for
|
||||||
|
use in JavaScript. If a record is provided via its id and model,
|
||||||
|
introspects it for a recipient.
|
||||||
|
"""
|
||||||
|
if res_id and res_model:
|
||||||
|
related_record = self.env[res_model].browse(res_id)
|
||||||
|
kwargs["partner_id"] = next(
|
||||||
|
iter(related_record._mail_get_partners(introspect_fields=True)[related_record.id]),
|
||||||
|
self.env["res.partner"],
|
||||||
|
).id
|
||||||
|
return self.create(kwargs)._format_calls()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_recent_phone_calls(self, search_terms: str = None, offset: int = 0, limit: int = None) -> list:
|
||||||
|
domain = [("user_id", "=", self.env.uid)]
|
||||||
|
if search_terms:
|
||||||
|
search_fields = ["phone_number", "partner_id.name", "activity_name"]
|
||||||
|
search_domain = expression.OR([[(field, "ilike", search_terms)] for field in search_fields])
|
||||||
|
domain += search_domain
|
||||||
|
return self.search(domain, offset=offset, limit=limit, order="create_date DESC")._format_calls()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _get_number_of_missed_calls(self) -> int:
|
||||||
|
domain = [("user_id", "=", self.env.uid), ("state", "=", "missed")]
|
||||||
|
last_seen_phone_call = self.env.user.last_seen_phone_call
|
||||||
|
if last_seen_phone_call:
|
||||||
|
domain += [("id", ">", last_seen_phone_call.id)]
|
||||||
|
return self.search_count(domain)
|
||||||
|
|
||||||
|
def abort_call(self) -> list:
|
||||||
|
self.state = "aborted"
|
||||||
|
return self._format_calls()
|
||||||
|
|
||||||
|
def start_call(self) -> list:
|
||||||
|
self.start_date = fields.Datetime.now()
|
||||||
|
self.state = "ongoing"
|
||||||
|
return self._format_calls()
|
||||||
|
|
||||||
|
def end_call(self, activity_name: str = None) -> list:
|
||||||
|
self.end_date = fields.Datetime.now()
|
||||||
|
self.state = "terminated"
|
||||||
|
if activity_name:
|
||||||
|
self.activity_name = activity_name
|
||||||
|
return self._format_calls()
|
||||||
|
|
||||||
|
def reject_call(self) -> list:
|
||||||
|
self.state = "rejected"
|
||||||
|
return self._format_calls()
|
||||||
|
|
||||||
|
def miss_call(self) -> list:
|
||||||
|
self.state = "missed"
|
||||||
|
return self._format_calls()
|
||||||
|
|
||||||
|
def get_contact_info(self):
|
||||||
|
self.ensure_one()
|
||||||
|
number = self.phone_number
|
||||||
|
# Internal extensions could theoretically be one or two digits long.
|
||||||
|
# phone_mobile_search doesn't handle numbers that short: do a regular
|
||||||
|
# search for the exact match:
|
||||||
|
if len(number) < 3:
|
||||||
|
domain = ["|", ("phone", "=", number), ("mobile", "=", number)]
|
||||||
|
# 00 and + both denote an international prefix. phone_mobile_search will
|
||||||
|
# match both indifferently.
|
||||||
|
elif number.startswith(("+", "00")):
|
||||||
|
domain = [("phone_mobile_search", "=", number)]
|
||||||
|
# USA: Calls between different area codes are usually prefixed with 1.
|
||||||
|
# Conveniently, the country code for the USA also happens to be 1, so we
|
||||||
|
# just need to add the + symbol to format it like an international call
|
||||||
|
# and match what's supposed to be stored in the database.
|
||||||
|
elif number.startswith("1"):
|
||||||
|
domain = [("phone_mobile_search", "=", f"+{number}")]
|
||||||
|
# 0 is the national prefix recommended by the ITU-T. This means that a
|
||||||
|
# number starting with 0 will identify a national call in most
|
||||||
|
# countries. We don't know the country code, so we can't convert the
|
||||||
|
# number to the international format: Replace the prefix with a
|
||||||
|
# wildcard.
|
||||||
|
elif number.startswith("0"):
|
||||||
|
domain = [("phone_mobile_search", "=like", f"%{number[1:]}")]
|
||||||
|
# The phone number doesn't match any expected format. Could it be some
|
||||||
|
# kind of internal extension? Try looking for an exact match:
|
||||||
|
else:
|
||||||
|
domain = [("phone_mobile_search", "=", number)]
|
||||||
|
partner = self.env["res.partner"].search(domain, limit=1)
|
||||||
|
if not partner:
|
||||||
|
return False
|
||||||
|
self.partner_id = partner
|
||||||
|
return self.partner_id._format_contacts()[0]
|
||||||
|
|
||||||
|
def _format_calls(self) -> list:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": call.id,
|
||||||
|
"creationDate": call.create_date,
|
||||||
|
"direction": call.direction,
|
||||||
|
"displayName": call.display_name,
|
||||||
|
"endDate": call.end_date,
|
||||||
|
"partner": call.partner_id._format_contacts()[0] if call.partner_id else False,
|
||||||
|
"phoneNumber": call.phone_number,
|
||||||
|
"startDate": call.start_date,
|
||||||
|
"state": call.state,
|
||||||
|
}
|
||||||
|
for call in self
|
||||||
|
]
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import api, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
from odoo.tools.translate import _
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VoipQueueMixin(models.AbstractModel):
|
||||||
|
_name = "voip.queue.mixin"
|
||||||
|
_description = "VOIP Queue support"
|
||||||
|
|
||||||
|
has_call_in_queue = fields.Boolean("Is in the Call Queue", compute="_compute_has_call_in_queue")
|
||||||
|
|
||||||
|
def _compute_has_call_in_queue(self):
|
||||||
|
activity_count_by_res_id = dict(
|
||||||
|
self.env["mail.activity"]._read_group(
|
||||||
|
[
|
||||||
|
("res_id", "in", self.ids),
|
||||||
|
("res_model", "=", self._name),
|
||||||
|
("user_id", "=", self.env.uid),
|
||||||
|
("activity_type_id.category", "=", "phonecall"),
|
||||||
|
("date_deadline", "<=", fields.Date.today()),
|
||||||
|
"|",
|
||||||
|
("phone", "!=", False),
|
||||||
|
("mobile", "!=", False),
|
||||||
|
],
|
||||||
|
["res_id"],
|
||||||
|
["__count"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for record in self:
|
||||||
|
record.has_call_in_queue = activity_count_by_res_id.get(record.id, 0) > 0
|
||||||
|
|
||||||
|
def create_call_activity(self):
|
||||||
|
if not self:
|
||||||
|
return self.env["mail.activity"]
|
||||||
|
# Ensure that a phonecall activity type exists beforehand, otherwise
|
||||||
|
# create one. This is important because we rely on this type to retrieve
|
||||||
|
# the activities to be displayed in the Next Activities tab.
|
||||||
|
phonecall_activity_type_id = self.env["ir.model.data"]._xmlid_to_res_id(
|
||||||
|
"mail.mail_activity_data_call", raise_if_not_found=False
|
||||||
|
)
|
||||||
|
if not phonecall_activity_type_id:
|
||||||
|
phonecall_activity_type_id = (
|
||||||
|
self.env["mail.activity.type"]
|
||||||
|
.search(
|
||||||
|
[
|
||||||
|
"|",
|
||||||
|
("res_model", "=", False),
|
||||||
|
("res_model", "=", self._name),
|
||||||
|
("category", "=", "phonecall"),
|
||||||
|
],
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
.id
|
||||||
|
)
|
||||||
|
if not phonecall_activity_type_id:
|
||||||
|
phonecall_activity_type_id = (
|
||||||
|
self.env["mail.activity.type"]
|
||||||
|
.sudo()
|
||||||
|
.create(
|
||||||
|
{
|
||||||
|
"category": "phonecall",
|
||||||
|
"delay_count": 2,
|
||||||
|
"icon": "fa-phone",
|
||||||
|
"name": _("Call"),
|
||||||
|
"sequence": 999,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.id
|
||||||
|
)
|
||||||
|
date_deadline = fields.Date.today(self)
|
||||||
|
res_model_id = self.env["ir.model"]._get_id(self._name)
|
||||||
|
activities = self.env["mail.activity"].create(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"activity_type_id": phonecall_activity_type_id,
|
||||||
|
"date_deadline": date_deadline,
|
||||||
|
"res_id": record.id,
|
||||||
|
"res_model_id": res_model_id,
|
||||||
|
"user_id": self.env.uid,
|
||||||
|
}
|
||||||
|
for record in self
|
||||||
|
]
|
||||||
|
)
|
||||||
|
failed_activities = activities.filtered(lambda activity: not activity.mobile and not activity.phone)
|
||||||
|
if failed_activities:
|
||||||
|
failed_records = self.browse(failed_activities.mapped("res_id"))
|
||||||
|
raise UserError(
|
||||||
|
_(
|
||||||
|
"Some documents cannot be added to the call queue as they do not have a phone number set: %(record_names)s",
|
||||||
|
record_names=_(", ").join(failed_records.mapped("display_name")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return activities
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def delete_call_activity(self, res_id):
|
||||||
|
related_activities = self.env["mail.activity"].search(
|
||||||
|
[
|
||||||
|
("res_id", "=", res_id),
|
||||||
|
("res_model", "=", self._name),
|
||||||
|
("user_id", "=", self.env.uid),
|
||||||
|
("activity_type_id.category", "=", "phonecall"),
|
||||||
|
("date_deadline", "<=", fields.Date.today()),
|
||||||
|
"|",
|
||||||
|
("phone", "!=", False),
|
||||||
|
("mobile", "!=", False),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.env["bus.bus"]._sendmany(
|
||||||
|
[
|
||||||
|
[self.env.user.partner_id, "delete_call_activity", {"id": activity.id}]
|
||||||
|
for activity in related_activities
|
||||||
|
]
|
||||||
|
)
|
||||||
|
related_activities.unlink()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_voip_call_admin,voip_call_manager,model_voip_call,base.group_system,1,1,1,1
|
||||||
|
access_voip_call_user,voip_call_user,model_voip_call,base.group_user,1,1,1,0
|
||||||
|
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo noupdate="1">
|
||||||
|
|
||||||
|
<record id="voip_call_rule_user" model="ir.rule">
|
||||||
|
<field name="name">voip.call: users can access their own entries</field>
|
||||||
|
<field name="model_id" ref="model_voip_call"/>
|
||||||
|
<field name="groups" eval="[Command.link(ref('base.group_user'))]"/>
|
||||||
|
<field name="domain_force">[("user_id", "=", user.id)]</field>
|
||||||
|
<field name="perm_read" eval="True"/>
|
||||||
|
<field name="perm_write" eval="True"/>
|
||||||
|
<field name="perm_create" eval="True"/>
|
||||||
|
<field name="perm_unlink" eval="False"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M50 27a2 2 0 1 1-4 0v-4a2 2 0 1 1 4 0v4Z" fill="#1A6F66"/><path d="M43 30a2 2 0 1 1-4 0V20a2 2 0 1 1 4 0v10Z" fill="#1AD3BB"/><path d="M36 37.5a2.5 2.5 0 0 1-5 0v-25a2.5 2.5 0 0 1 5 0v25Z" fill="#03AF89"/><path d="M28 33a3 3 0 1 1-6 0V17a3 3 0 1 1 6 0v16Z" fill="#1AD3BB"/><path d="M19 37.5a2.5 2.5 0 0 1-5 0v-25a2.5 2.5 0 0 1 5 0v25Z" fill="#03AF89"/><path d="M11 30a2 2 0 1 1-4 0V20a2 2 0 1 1 4 0v10Z" fill="#1AD3BB"/><path d="M4 27a2 2 0 1 1-4 0v-4a2 2 0 1 1 4 0v4Z" fill="#1A6F66"/></svg>
|
||||||
|
After Width: | Height: | Size: 585 B |
+20562
File diff suppressed because it is too large
Load Diff
+5
@@ -0,0 +1,5 @@
|
|||||||
|
declare module "models" {
|
||||||
|
interface Activity {
|
||||||
|
partner: Persona,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Record } from "@mail/core/common/record";
|
||||||
|
import { Activity } from "@mail/core/web/activity_model";
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Activity, {
|
||||||
|
/** @override */
|
||||||
|
_insert(data) {
|
||||||
|
const activity = super._insert(...arguments);
|
||||||
|
if (Object.hasOwn(data, "partner")) {
|
||||||
|
activity.partner = data.partner;
|
||||||
|
}
|
||||||
|
return activity;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
patch(Activity.prototype, {
|
||||||
|
/** @override */
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
this.partner = Record.one("Persona");
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Activity } from "@mail/core/web/activity";
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Activity.prototype, {
|
||||||
|
/**
|
||||||
|
* @param {MouseEvent} ev
|
||||||
|
* @param {string} phoneNumber
|
||||||
|
*/
|
||||||
|
onClickPhoneNumber(ev, phoneNumber) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.env.services["voip.user_agent"].makeCall({
|
||||||
|
activity: this.env.services["mail.store"].Activity.get(this.props.data.id),
|
||||||
|
phone_number: phoneNumber,
|
||||||
|
res_id: this.props.data.res_id,
|
||||||
|
res_model: this.props.data.res_model,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
|
||||||
|
<t t-name="voip.Activity" t-inherit="mail.Activity" t-inherit-mode="extension">
|
||||||
|
<xpath expr="//*[hasclass('o-mail-Activity-info')]" position="after">
|
||||||
|
<t t-if="activity.phone">
|
||||||
|
<div class="o-mail-Activity-voip-landline-number">
|
||||||
|
<t t-if="activity.mobile">
|
||||||
|
Phone:
|
||||||
|
</t>
|
||||||
|
<a href="#" t-on-click="(ev) => this.onClickPhoneNumber(ev, activity.phone)">
|
||||||
|
<t t-esc="activity.phone"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-if="activity.mobile">
|
||||||
|
<div class="o-mail-Activity-voip-mobile-number">
|
||||||
|
<t t-if="activity.phone">
|
||||||
|
Mobile:
|
||||||
|
</t>
|
||||||
|
<a href="#" t-on-click="(ev) => this.onClickPhoneNumber(ev, activity.mobile)">
|
||||||
|
<t t-esc="activity.mobile"/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</xpath>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Record } from "@mail/core/common/record";
|
||||||
|
import { assignDefined } from "@mail/utils/common/misc";
|
||||||
|
|
||||||
|
import { deserializeDateTime } from "@web/core/l10n/dates";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export class Call extends Record {
|
||||||
|
static id = "id";
|
||||||
|
/** @type {Object.<number, Call>} */
|
||||||
|
static records = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} data
|
||||||
|
* @returns {Call}
|
||||||
|
*/
|
||||||
|
static insert(data) {
|
||||||
|
const call = this.get(data) ?? this.new(data);
|
||||||
|
if (data.partner) {
|
||||||
|
data.partner = this.store.Persona.insert({ ...data.partner, type: "partner" });
|
||||||
|
}
|
||||||
|
if (data.creationDate) {
|
||||||
|
data.creationDate = deserializeDateTime(data.creationDate);
|
||||||
|
}
|
||||||
|
if (data.startDate) {
|
||||||
|
data.startDate = deserializeDateTime(data.startDate);
|
||||||
|
}
|
||||||
|
if (data.endDate) {
|
||||||
|
data.endDate = deserializeDateTime(data.endDate);
|
||||||
|
}
|
||||||
|
assignDefined(call, data);
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
activity;
|
||||||
|
/** @type {luxon.DateTime} */
|
||||||
|
creationDate;
|
||||||
|
/** @type {"incoming"|"outgoing"} */
|
||||||
|
direction;
|
||||||
|
/** @type {string} */
|
||||||
|
displayName;
|
||||||
|
/** @type {luxon.DateTime} */
|
||||||
|
endDate;
|
||||||
|
/** @type {import("@mail/core/persona_model").Persona | undefined} */
|
||||||
|
partner;
|
||||||
|
/** @type {string} */
|
||||||
|
phoneNumber;
|
||||||
|
/** @type {luxon.DateTime} */
|
||||||
|
startDate;
|
||||||
|
/** @type {"aborted"|"calling"|"missed"|"ongoing"|"rejected"|"terminated"} */
|
||||||
|
state;
|
||||||
|
/** @type {{ interval: number, time: number }} */
|
||||||
|
timer;
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
get callDate() {
|
||||||
|
if (this.state === "terminated") {
|
||||||
|
return this.startDate.toLocaleString(luxon.DateTime.DATETIME_SHORT);
|
||||||
|
}
|
||||||
|
return this.creationDate.toLocaleString(luxon.DateTime.DATETIME_SHORT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} */
|
||||||
|
get duration() {
|
||||||
|
if (!this.startDate || !this.endDate) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (this.endDate - this.startDate) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
get durationString() {
|
||||||
|
if (!this.duration) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(this.duration / 60);
|
||||||
|
const seconds = this.duration % 60;
|
||||||
|
if (minutes === 0) {
|
||||||
|
switch (seconds) {
|
||||||
|
case 0:
|
||||||
|
return _t("less than a second");
|
||||||
|
case 1:
|
||||||
|
return _t("1 second");
|
||||||
|
case 2:
|
||||||
|
return _t("2 seconds");
|
||||||
|
default:
|
||||||
|
return _t("%(seconds)s seconds", { seconds });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (seconds === 0) {
|
||||||
|
switch (minutes) {
|
||||||
|
case 1:
|
||||||
|
return _t("1 minute");
|
||||||
|
case 2:
|
||||||
|
return _t("2 minutes");
|
||||||
|
default:
|
||||||
|
return _t("%(minutes)s minutes", { minutes });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _t("%(minutes)s min %(seconds)s sec", { minutes, seconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
get isInProgress() {
|
||||||
|
switch (this.state) {
|
||||||
|
case "calling":
|
||||||
|
case "ongoing":
|
||||||
|
// In case the power goes out in the middle of a call (for
|
||||||
|
// example), the call may be stuck in the “calling” or “ongoing”
|
||||||
|
// state, meaning we can't rely on the state alone, hence the
|
||||||
|
// need to also check for the session.
|
||||||
|
return Boolean(this._store.env.services["voip.user_agent"].session);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Call.register();
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
export class CallService {
|
||||||
|
missedCalls = 0;
|
||||||
|
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.orm = services.orm;
|
||||||
|
this.store = services["mail.store"];
|
||||||
|
this.activityService = services["mail.activity"];
|
||||||
|
}
|
||||||
|
|
||||||
|
async abort(call) {
|
||||||
|
const [data] = await this.orm.call("voip.call", "abort_call", [[call.id]]);
|
||||||
|
this.store.Call.insert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data) {
|
||||||
|
const { activity, partner } = data;
|
||||||
|
delete data.activity;
|
||||||
|
delete data.partner;
|
||||||
|
data.partner_id = partner?.id;
|
||||||
|
const [res] = await this.orm.call("voip.call", "create_and_format", [], data);
|
||||||
|
const call = this.store.Call.insert(res);
|
||||||
|
if (activity) {
|
||||||
|
call.activity = activity;
|
||||||
|
}
|
||||||
|
if (!call.partner) {
|
||||||
|
this.orm.call("voip.call", "get_contact_info", [[call.id]]).then((partnerData) => {
|
||||||
|
if (partnerData) {
|
||||||
|
call.partner = partnerData;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
}
|
||||||
|
|
||||||
|
async end(call, { activityDone = true } = {}) {
|
||||||
|
let data;
|
||||||
|
if (call.activity && activityDone) {
|
||||||
|
[data] = await this.orm.call("voip.call", "end_call", [[call.id]], {
|
||||||
|
activity_name: call.activity.res_name,
|
||||||
|
});
|
||||||
|
await this.activityService.markAsDone(call.activity);
|
||||||
|
this.activityService.delete(call.activity);
|
||||||
|
call.activity = null;
|
||||||
|
} else {
|
||||||
|
[data] = await this.orm.call("voip.call", "end_call", [[call.id]]);
|
||||||
|
}
|
||||||
|
this.store.Call.insert(data);
|
||||||
|
if (call.timer) {
|
||||||
|
clearInterval(call.timer.interval);
|
||||||
|
call.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async miss(call) {
|
||||||
|
const [data] = await this.orm.call("voip.call", "miss_call", [[call.id]]);
|
||||||
|
this.store.Call.insert(data);
|
||||||
|
this.missedCalls++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(call) {
|
||||||
|
const [data] = await this.orm.call("voip.call", "reject_call", [[call.id]]);
|
||||||
|
this.store.Call.insert(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(call) {
|
||||||
|
const [data] = await this.orm.call("voip.call", "start_call", [[call.id]]);
|
||||||
|
this.store.Call.insert(data);
|
||||||
|
call.timer = {};
|
||||||
|
const computeDuration = () => {
|
||||||
|
call.timer.time = Math.floor((luxon.DateTime.now() - call.startDate) / 1000);
|
||||||
|
};
|
||||||
|
computeDuration();
|
||||||
|
call.timer.interval = browser.setInterval(computeDuration, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const callService = {
|
||||||
|
dependencies: ["mail.activity", "mail.persona", "mail.store", "orm"],
|
||||||
|
start(env, services) {
|
||||||
|
return new CallService(env, services);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("voip.call", callService);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { url } from "@web/core/utils/urls";
|
||||||
|
|
||||||
|
export class Correspondence {
|
||||||
|
activity;
|
||||||
|
call;
|
||||||
|
_partner;
|
||||||
|
|
||||||
|
constructor({ activity, partner, call }) {
|
||||||
|
if (!activity && !partner && !call) {
|
||||||
|
throw TypeError(
|
||||||
|
"Cannot create correspondence: missing required data. A correspondence must refer to an activity, a partner or a phone call."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.activity = activity;
|
||||||
|
this._partner = partner;
|
||||||
|
this.call = call;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
get avatarUrl() {
|
||||||
|
if (this.partner) {
|
||||||
|
return url("/web/image", {
|
||||||
|
model: "res.partner",
|
||||||
|
id: this.partner.id,
|
||||||
|
field: "avatar_128",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return "/base/static/img/avatar_grey.png";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {import("@mail/core/common/persona_model").Persona | undefined} */
|
||||||
|
get partner() {
|
||||||
|
return this.call?.partner ?? this.activity?.partner ?? this._partner;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
get phoneNumber() {
|
||||||
|
if (this.call) {
|
||||||
|
return this.call.phoneNumber;
|
||||||
|
}
|
||||||
|
if (this.activity) {
|
||||||
|
return this.activity.mobile || this.activity.phone;
|
||||||
|
}
|
||||||
|
if (this.partner) {
|
||||||
|
return this.partner.mobileNumber || this.partner.landlineNumber;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Messaging } from "@mail/core/common/messaging_service";
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
patch(Messaging.prototype, {
|
||||||
|
initMessagingCallback({ voipConfig }) {
|
||||||
|
super.initMessagingCallback(...arguments);
|
||||||
|
this.store.voipConfig = voipConfig;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
/* global SIP */
|
||||||
|
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export class Registerer {
|
||||||
|
/**
|
||||||
|
* When sending a REGISTER request, an “expires” parameter with the value of
|
||||||
|
* this field is added to the Contact header. It is used to indicate how
|
||||||
|
* long we would like the registration to remain valid.
|
||||||
|
* Note however that the definitive value is decided by the server based on
|
||||||
|
* its own policy and may therefore differ.
|
||||||
|
*
|
||||||
|
* The library automatically renews the registration for the same duration
|
||||||
|
* shortly before it expires.
|
||||||
|
*
|
||||||
|
* The value is expressed in seconds.
|
||||||
|
*/
|
||||||
|
static EXPIRATION_INTERVAL = 3600;
|
||||||
|
/**
|
||||||
|
* Possible values:
|
||||||
|
* - SIP.RegistererState.Initial
|
||||||
|
* - SIP.RegistererState.Registered
|
||||||
|
* - SIP.RegistererState.Unregistered
|
||||||
|
* - SIP.RegistererState.Terminated
|
||||||
|
*/
|
||||||
|
state;
|
||||||
|
/**
|
||||||
|
* An instance of the Registerer class from the SIP.js library. It shouldn't
|
||||||
|
* be used outside of this class; only this class is responsible for
|
||||||
|
* interfacing with this object.
|
||||||
|
*/
|
||||||
|
__sipJsRegisterer;
|
||||||
|
|
||||||
|
constructor(voip, sipJsUserAgent) {
|
||||||
|
this.voip = voip;
|
||||||
|
this.__sipJsRegisterer = new SIP.Registerer(sipJsUserAgent, {
|
||||||
|
expires: Registerer.EXPIRATION_INTERVAL,
|
||||||
|
});
|
||||||
|
this.__sipJsRegisterer.stateChange.addListener((state) => this._onStateChanged(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the REGISTER request to the Registrar.
|
||||||
|
*/
|
||||||
|
register() {
|
||||||
|
this.__sipJsRegisterer.register({
|
||||||
|
requestDelegate: {
|
||||||
|
onReject: (response) => this._onRegistrationRejected(response),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a response with status code 4xx, 5xx, or 6xx to
|
||||||
|
* the REGISTER request.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingResponse} response The server final response to the
|
||||||
|
* REGISTER request.
|
||||||
|
*/
|
||||||
|
_onRegistrationRejected(response) {
|
||||||
|
const errorMessage = _t("Registration rejected: %(statusCode)s %(reasonPhrase)s.", {
|
||||||
|
statusCode: response.message.statusCode,
|
||||||
|
reasonPhrase: response.message.reasonPhrase,
|
||||||
|
});
|
||||||
|
const help = (() => {
|
||||||
|
switch (response.message.statusCode) {
|
||||||
|
case 401: // Unauthorized
|
||||||
|
return _t(
|
||||||
|
"The server failed to authenticate you. Please have an administrator verify that you are reaching the right server (PBX server IP in the General Settings) and that the credentials in your user preferences are correct."
|
||||||
|
);
|
||||||
|
case 503: // Service Unavailable
|
||||||
|
return _t(
|
||||||
|
"The error may come from the transport layer. Please have an administrator verify the websocket server URL in the General Settings. If the problem persists, this is probably an issue with the server."
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _t(
|
||||||
|
"Please try again later. If the problem persists, you may want to ask an administrator to check the configuration."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
this.voip.triggerError(`${errorMessage}\n\n${help}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onStateChanged(newState) {
|
||||||
|
this.state = newState;
|
||||||
|
if (newState === SIP.RegistererState.Registered) {
|
||||||
|
this.voip.resolveError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
/* global SIP */
|
||||||
|
|
||||||
|
import { reactive } from "@odoo/owl";
|
||||||
|
|
||||||
|
import { Registerer } from "@voip/core/registerer";
|
||||||
|
import { cleanPhoneNumber } from "@voip/utils/utils";
|
||||||
|
|
||||||
|
import { getBundle, loadBundle } from "@web/core/assets";
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Session
|
||||||
|
* @property {"trying"|"ringing"|"ok"} [inviteState]
|
||||||
|
* @property {boolean} isMute
|
||||||
|
* @property {import("@voip/core/call_model").Call} call
|
||||||
|
* @property {window.sip.Session} [sipSession]
|
||||||
|
* @property {string} [transferTarget]
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UserAgent {
|
||||||
|
attemptingToReconnect = false;
|
||||||
|
/**
|
||||||
|
* The id of the setTimeout used in demo mode to simulate the waiting time
|
||||||
|
* before the call is picked up.
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
demoTimeout;
|
||||||
|
preferredInputDevice;
|
||||||
|
registerer;
|
||||||
|
/**
|
||||||
|
* The Audio element used to play the audio stream received from the remote
|
||||||
|
* call party.
|
||||||
|
*
|
||||||
|
* @type {HTMLAudioElement}
|
||||||
|
*/
|
||||||
|
remoteAudio = new window.Audio();
|
||||||
|
/**
|
||||||
|
* @type {Session}
|
||||||
|
*/
|
||||||
|
session;
|
||||||
|
transferTarget;
|
||||||
|
voip;
|
||||||
|
__sipJsUserAgent;
|
||||||
|
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
this.callService = services["voip.call"];
|
||||||
|
this.notificationService = services.notification;
|
||||||
|
this.ringtoneService = services["voip.ringtone"];
|
||||||
|
this.voip = services.voip;
|
||||||
|
this.softphone = this.voip.softphone;
|
||||||
|
this.voip.isReady.then(() => this.init());
|
||||||
|
return reactive(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Object} */
|
||||||
|
get mediaConstraints() {
|
||||||
|
const constraints = { audio: true, video: false };
|
||||||
|
if (this.preferredInputDevice) {
|
||||||
|
constraints.audio = { deviceId: { exact: this.preferredInputDevice } };
|
||||||
|
}
|
||||||
|
return constraints;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the function that will be used by the SIP.js library to create
|
||||||
|
* the media source that will serve as the local media stream (i.e. the
|
||||||
|
* recording of the user's microphone).
|
||||||
|
*
|
||||||
|
* @returns {SIP.MediaStreamFactory}
|
||||||
|
*/
|
||||||
|
get mediaStreamFactory() {
|
||||||
|
return (constraints, sessionDescriptionHandler) => {
|
||||||
|
const mediaRequest = browser.navigator.mediaDevices.getUserMedia(constraints);
|
||||||
|
mediaRequest.then(
|
||||||
|
(stream) => this._onGetUserMediaSuccess(stream),
|
||||||
|
(error) => this._onGetUserMediaFailure(error)
|
||||||
|
);
|
||||||
|
return mediaRequest;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the handlers to be called by the SIP.js library when receiving
|
||||||
|
* SIP requests (BYE, INFO, ACK, REFER…).
|
||||||
|
*
|
||||||
|
* @returns {SIP.SessionDelegate}
|
||||||
|
*/
|
||||||
|
get sessionDelegate() {
|
||||||
|
return { onBye: (bye) => this._onBye(bye) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Object} */
|
||||||
|
get sipJsUserAgentConfig() {
|
||||||
|
const isDebug = odoo.debug !== "";
|
||||||
|
return {
|
||||||
|
authorizationPassword: this.voip.settings.voip_secret,
|
||||||
|
authorizationUsername: this.voip.authorizationUsername,
|
||||||
|
delegate: {
|
||||||
|
onDisconnect: (error) => this._onTransportDisconnected(error),
|
||||||
|
onInvite: (inviteSession) => this._onIncomingInvitation(inviteSession),
|
||||||
|
},
|
||||||
|
hackIpInContact: true,
|
||||||
|
logBuiltinEnabled: isDebug,
|
||||||
|
logLevel: isDebug ? "debug" : "error",
|
||||||
|
sessionDescriptionHandlerFactory: SIP.Web.defaultSessionDescriptionHandlerFactory(
|
||||||
|
this.mediaStreamFactory
|
||||||
|
),
|
||||||
|
sessionDescriptionHandlerFactoryOptions: { iceGatheringTimeout: 1000 },
|
||||||
|
transportOptions: {
|
||||||
|
server: this.voip.webSocketUrl,
|
||||||
|
traceSip: isDebug,
|
||||||
|
},
|
||||||
|
uri: SIP.UserAgent.makeURI(
|
||||||
|
`sip:${this.voip.settings.voip_username}@${this.voip.pbxAddress}`
|
||||||
|
),
|
||||||
|
userAgentString: `Odoo ${odoo.info.server_version} SIP.js/${window.SIP.version}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async acceptIncomingCall() {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
this.session.sipSession.accept({
|
||||||
|
sessionDescriptionHandlerOptions: {
|
||||||
|
constraints: this.mediaConstraints,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.voip.triggerError(_t("Please accept the use of the microphone."));
|
||||||
|
}
|
||||||
|
|
||||||
|
async attemptReconnection(attemptCount = 0) {
|
||||||
|
if (attemptCount > 5) {
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t("The WebSocket connection was lost and couldn't be reestablished.")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.attemptingToReconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.attemptingToReconnect = true;
|
||||||
|
try {
|
||||||
|
await this.__sipJsUserAgent.reconnect();
|
||||||
|
this.registerer.register();
|
||||||
|
this.voip.resolveError();
|
||||||
|
} catch {
|
||||||
|
setTimeout(
|
||||||
|
() => this.attemptReconnection(attemptCount + 1),
|
||||||
|
2 ** attemptCount * 1000 + Math.random() * 500
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.attemptingToReconnect = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} phoneNumber
|
||||||
|
* @returns {SIP.URI}
|
||||||
|
*/
|
||||||
|
getUri(phoneNumber) {
|
||||||
|
const sanitizedNumber = cleanPhoneNumber(phoneNumber);
|
||||||
|
return SIP.UserAgent.makeURI(`sip:${sanitizedNumber}@${this.voip.pbxAddress}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async hangup({ activityDone = true } = {}) {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
browser.clearTimeout(this.demoTimeout);
|
||||||
|
if (this.session.sipSession) {
|
||||||
|
this._cleanUpRemoteAudio();
|
||||||
|
switch (this.session.sipSession.state) {
|
||||||
|
case SIP.SessionState.Establishing:
|
||||||
|
this.session.sipSession.cancel();
|
||||||
|
break;
|
||||||
|
case SIP.SessionState.Established:
|
||||||
|
this.session.sipSession.bye();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (this.session.call.state) {
|
||||||
|
case "calling":
|
||||||
|
await this.callService.abort(this.session.call);
|
||||||
|
break;
|
||||||
|
case "ongoing":
|
||||||
|
await this.callService.end(this.session.call, { activityDone });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.session = null;
|
||||||
|
if (this.softphone.isInAutoCallMode) {
|
||||||
|
this.softphone.selectNextActivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (this.voip.mode !== "prod") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.voip.hasRtcSupport) {
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t(
|
||||||
|
"Your browser does not support some of the features required for VoIP to work. Please try updating your browser or using a different one."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.voip.isServerConfigured) {
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t("PBX or Websocket address is missing. Please check your settings.")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.voip.areCredentialsSet) {
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t("Your login details are not set correctly. Please contact your administrator.")
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadBundle(await getBundle("voip.assets_sip"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t("Failed to load the SIP.js library:\n\n%(error)s", {
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.__sipJsUserAgent = new SIP.UserAgent(this.sipJsUserAgentConfig);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t("An error occurred during the instantiation of the User Agent:\n\n%(error)s", {
|
||||||
|
error: error.message,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.voip.triggerError(_t("Connecting…"));
|
||||||
|
try {
|
||||||
|
await this.__sipJsUserAgent.start();
|
||||||
|
} catch {
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t(
|
||||||
|
"The user agent could not be started. The websocket server URL may be incorrect. Please have an administrator check the websocket server URL in the General Settings."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.registerer = new Registerer(this.voip, this.__sipJsUserAgent);
|
||||||
|
this.registerer.register();
|
||||||
|
}
|
||||||
|
|
||||||
|
invite(phoneNumber) {
|
||||||
|
let calleeUri;
|
||||||
|
if (this.voip.willCallFromAnotherDevice) {
|
||||||
|
calleeUri = this.getUri(this.voip.settings.external_device_number);
|
||||||
|
this.session.transferTarget = phoneNumber;
|
||||||
|
} else {
|
||||||
|
calleeUri = this.getUri(phoneNumber);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const inviter = new SIP.Inviter(this.__sipJsUserAgent, calleeUri);
|
||||||
|
inviter.delegate = this.sessionDelegate;
|
||||||
|
inviter.stateChange.addListener((state) => this._onSessionStateChange(state));
|
||||||
|
this.session.sipSession = inviter;
|
||||||
|
this.session.sipSession.invite({
|
||||||
|
requestDelegate: {
|
||||||
|
onAccept: (response) => this._onOutgoingInvitationAccepted(response),
|
||||||
|
onProgress: (response) => this._onOutgoingInvitationProgress(response),
|
||||||
|
onReject: (response) => this._onOutgoingInvitationRejected(response),
|
||||||
|
},
|
||||||
|
sessionDescriptionHandlerOptions: {
|
||||||
|
constraints: this.mediaConstraints,
|
||||||
|
},
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.name === "NotAllowedError") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t(
|
||||||
|
"An error occurred trying to invite the following number: %(phoneNumber)s\n\nError: %(error)s",
|
||||||
|
{ phoneNumber, error: error.message }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Object} data */
|
||||||
|
async makeCall(data) {
|
||||||
|
if (!(await this.voip.willCallUsingVoip())) {
|
||||||
|
window.location.assign(`tel:${data.phone_number}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const call = await this.callService.create(data);
|
||||||
|
this.softphone.show();
|
||||||
|
this.softphone.closeNumpad();
|
||||||
|
this.notificationService.add(
|
||||||
|
_t("Calling %(phone number)s", { "phone number": call.phoneNumber })
|
||||||
|
);
|
||||||
|
this.softphone.selectCorrespondence({ call });
|
||||||
|
this.session = {
|
||||||
|
inviteState: "trying",
|
||||||
|
isMute: false,
|
||||||
|
call,
|
||||||
|
};
|
||||||
|
this.ringtoneService.ringback.play();
|
||||||
|
if (this.voip.mode === "prod") {
|
||||||
|
this.invite(call.phoneNumber);
|
||||||
|
} else {
|
||||||
|
this.demoTimeout = browser.setTimeout(() => {
|
||||||
|
this._onOutgoingInvitationAccepted();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectIncomingCall() {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
this.session.sipSession.reject({ statusCode: 603 /* Decline */ });
|
||||||
|
await this.callService.reject(this.session.call);
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} deviceId */
|
||||||
|
async switchInputStream(deviceId) {
|
||||||
|
if (!this.session.sipSession?.sessionDescriptionHandler.peerConnection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.preferredInputDevice = deviceId;
|
||||||
|
const stream = await browser.navigator.mediaDevices.getUserMedia(this.mediaConstraints);
|
||||||
|
for (const sender of this.session.sipSession.sessionDescriptionHandler.peerConnection.getSenders()) {
|
||||||
|
if (sender.track) {
|
||||||
|
await sender.replaceTrack(stream.getAudioTracks()[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers the call to the given number.
|
||||||
|
*
|
||||||
|
* @param {string} number
|
||||||
|
*/
|
||||||
|
transfer(number) {
|
||||||
|
if (this.voip.mode === "demo") {
|
||||||
|
this.hangup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transferTarget = this.getUri(number);
|
||||||
|
this.session.sipSession.refer(transferTarget, {
|
||||||
|
requestDelegate: {
|
||||||
|
onAccept: (response) => this._onReferAccepted(response),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSenderTracks() {
|
||||||
|
if (!this.session?.sipSession?.sessionDescriptionHandler) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { peerConnection } = this.session.sipSession.sessionDescriptionHandler;
|
||||||
|
for (const { track } of peerConnection.getSenders()) {
|
||||||
|
if (track) {
|
||||||
|
track.enabled = !this.session.isMute;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanUpRemoteAudio() {
|
||||||
|
this.remoteAudio.srcObject = null;
|
||||||
|
this.remoteAudio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a BYE request. Useful to detect when the callee
|
||||||
|
* of an outgoing call hangs up.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingByeRequest} bye
|
||||||
|
*/
|
||||||
|
async _onBye({ incomingByeRequest: bye }) {
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.callService.end(this.session.call);
|
||||||
|
this.session = null;
|
||||||
|
this._cleanUpRemoteAudio();
|
||||||
|
if (this.softphone.isInAutoCallMode) {
|
||||||
|
this.softphone.selectNextActivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DOMException} error */
|
||||||
|
_onGetUserMediaFailure(error) {
|
||||||
|
console.error(error);
|
||||||
|
const errorMessage = (() => {
|
||||||
|
switch (error.name) {
|
||||||
|
case "NotAllowedError":
|
||||||
|
return _t(
|
||||||
|
"Cannot access audio recording device. If you have denied access to your microphone, please allow it and try again. Otherwise, make sure that this website is running over HTTPS and that your browser is not set to deny access to media devices."
|
||||||
|
);
|
||||||
|
case "NotFoundError":
|
||||||
|
return _t(
|
||||||
|
"No audio recording device available. The application requires a microphone in order to be used."
|
||||||
|
);
|
||||||
|
case "NotReadableError":
|
||||||
|
return _t(
|
||||||
|
"A hardware error has occurred while trying to access the audio recording device. Please ensure that your drivers are up to date and try again."
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return _t(
|
||||||
|
"An error occured involving the audio recording device (%(errorName)s):\n%(errorMessage)s",
|
||||||
|
{ errorMessage: error.message, errorName: error.name }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
this.voip.triggerError(errorMessage, { isNonBlocking: true });
|
||||||
|
if (this.session.call.direction === "outgoing") {
|
||||||
|
this.hangup();
|
||||||
|
} else {
|
||||||
|
this.rejectIncomingCall();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MediaStream} stream */
|
||||||
|
_onGetUserMediaSuccess(stream) {
|
||||||
|
this.voip.resolveError();
|
||||||
|
switch (this.session.call.direction) {
|
||||||
|
case "outgoing":
|
||||||
|
this.ringtoneService.dial.play();
|
||||||
|
break;
|
||||||
|
case "incoming":
|
||||||
|
this.callService.start(this.session.call);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Object} inviteSession */
|
||||||
|
async _onIncomingInvitation(inviteSession) {
|
||||||
|
if (this.session) {
|
||||||
|
inviteSession.reject({ statusCode: 486 /* Busy Here */ });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.voip.settings.should_auto_reject_incoming_calls) {
|
||||||
|
inviteSession.reject({ statusCode: 488 /* Not Acceptable Here */ });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const phoneNumber = inviteSession.remoteIdentity.uri.user;
|
||||||
|
const call = await this.callService.create({
|
||||||
|
direction: "incoming",
|
||||||
|
phone_number: phoneNumber,
|
||||||
|
state: "calling",
|
||||||
|
});
|
||||||
|
this.softphone.selectCorrespondence({ call });
|
||||||
|
inviteSession.delegate = this.sessionDelegate;
|
||||||
|
inviteSession.incomingInviteRequest.delegate = {
|
||||||
|
onCancel: (message) => this._onIncomingInvitationCanceled(message),
|
||||||
|
};
|
||||||
|
inviteSession.stateChange.addListener((state) => this._onSessionStateChange(state));
|
||||||
|
this.session = {
|
||||||
|
call,
|
||||||
|
isMute: false,
|
||||||
|
sipSession: inviteSession,
|
||||||
|
};
|
||||||
|
this.softphone.show();
|
||||||
|
this.ringtoneService.incoming.play();
|
||||||
|
// TODO send notification
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving CANCEL request.
|
||||||
|
* Useful to handle missed phone calls.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingRequestMessage} message
|
||||||
|
*/
|
||||||
|
_onIncomingInvitationCanceled(message) {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
this.session.sipSession.reject({ statusCode: 487 /* Request Terminated */ });
|
||||||
|
this.callService.miss(this.session.call);
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a 2xx final response to the INVITE request.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingResponse} response
|
||||||
|
* @param {function} response.ack
|
||||||
|
* @param {SIP.IncomingResponseMessage} response.message
|
||||||
|
* @param {SIP.SessionDialog} response.session
|
||||||
|
*/
|
||||||
|
_onOutgoingInvitationAccepted(response) {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
this.session.inviteState = "ok";
|
||||||
|
if (this.voip.willCallFromAnotherDevice) {
|
||||||
|
this.transfer(this.session.transferTarget);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.callService.start(this.session.call);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a 1xx provisional response to the INVITE request
|
||||||
|
* (excepted code 100 responses).
|
||||||
|
*
|
||||||
|
* NOTE: Relying on provisional responses to implement behaviors seems like
|
||||||
|
* a bad idea, as they may or may not be sent depending on the SIP server
|
||||||
|
* implementation.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingResponse} response
|
||||||
|
* @param {SIP.IncomingResponseMessage} response.message
|
||||||
|
* @param {function} response.prack
|
||||||
|
* @param {SIP.SessionDialog} response.session
|
||||||
|
*/
|
||||||
|
_onOutgoingInvitationProgress(response) {
|
||||||
|
const { statusCode } = response.message;
|
||||||
|
if (statusCode === 183 /* Session Progress */ || statusCode === 180 /* Ringing */) {
|
||||||
|
this.ringtoneService.ringback.play();
|
||||||
|
this.session.inviteState = "ringing";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a 4xx, 5xx, or 6xx final response to the
|
||||||
|
* INVITE request.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingResponse} response
|
||||||
|
* @param {SIP.IncomingResponseMessage} response.message
|
||||||
|
* @param {number} response.message.statusCode
|
||||||
|
* @param {string} response.message.reasonPhrase
|
||||||
|
*/
|
||||||
|
_onOutgoingInvitationRejected(response) {
|
||||||
|
this.ringtoneService.stopPlaying();
|
||||||
|
if (response.message.statusCode === 487) { // Request Terminated
|
||||||
|
// invitation has been cancelled by the user, the session has
|
||||||
|
// already been terminated
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const errorMessage = (() => {
|
||||||
|
switch (response.message.statusCode) {
|
||||||
|
case 404: // Not Found
|
||||||
|
case 488: // Not Acceptable Here
|
||||||
|
case 603: // Decline
|
||||||
|
return _t(
|
||||||
|
"The number is incorrect, the user credentials could be wrong or the connection cannot be made. Please check your configuration.\n(Reason received: %(reasonPhrase)s)",
|
||||||
|
{ reasonPhrase: response.message.reasonPhrase }
|
||||||
|
);
|
||||||
|
case 486: // Busy Here
|
||||||
|
case 600: // Busy Everywhere
|
||||||
|
return _t("The person you try to contact is currently unavailable.");
|
||||||
|
default:
|
||||||
|
return _t("Call rejected (reason: “%(reasonPhrase)s”)", {
|
||||||
|
reasonPhrase: response.message.reasonPhrase,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
this.voip.triggerError(errorMessage, { isNonBlocking: true });
|
||||||
|
this.callService.reject(this.session.call);
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when receiving a response with status code 2xx to the REFER
|
||||||
|
* request.
|
||||||
|
*
|
||||||
|
* @param {SIP.IncomingResponse} response The server final response to the
|
||||||
|
* REFER request.
|
||||||
|
*/
|
||||||
|
_onReferAccepted(response) {
|
||||||
|
this.session.sipSession.bye();
|
||||||
|
this._cleanUpRemoteAudio();
|
||||||
|
this.callService.end(this.session.call);
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {SIP.SessionState} newState */
|
||||||
|
_onSessionStateChange(newState) {
|
||||||
|
switch (newState) {
|
||||||
|
case SIP.SessionState.Initial:
|
||||||
|
break;
|
||||||
|
case SIP.SessionState.Establishing:
|
||||||
|
break;
|
||||||
|
case SIP.SessionState.Established:
|
||||||
|
this._setUpRemoteAudio();
|
||||||
|
this.session.sipSession.sessionDescriptionHandler.remoteMediaStream.onaddtrack = (
|
||||||
|
mediaStreamTrackEvent
|
||||||
|
) => this._setUpRemoteAudio();
|
||||||
|
break;
|
||||||
|
case SIP.SessionState.Terminating:
|
||||||
|
break;
|
||||||
|
case SIP.SessionState.Terminated:
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown session state: "${newState}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered when the transport transitions from connected state.
|
||||||
|
*
|
||||||
|
* @param {Error} error
|
||||||
|
*/
|
||||||
|
_onTransportDisconnected(error) {
|
||||||
|
if (!error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error(error);
|
||||||
|
this.voip.triggerError(
|
||||||
|
_t(
|
||||||
|
"The websocket connection to the server has been lost. Attempting to reestablish the connection…"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
this.attemptReconnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setUpRemoteAudio() {
|
||||||
|
const remoteStream = new MediaStream();
|
||||||
|
for (const receiver of this.session.sipSession.sessionDescriptionHandler.peerConnection.getReceivers()) {
|
||||||
|
if (receiver.track) {
|
||||||
|
remoteStream.addTrack(receiver.track);
|
||||||
|
// According to the SIP.js documentation, this is needed by Safari to work.
|
||||||
|
this.remoteAudio.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.remoteAudio.srcObject = remoteStream;
|
||||||
|
this.remoteAudio.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userAgentService = {
|
||||||
|
dependencies: ["notification", "voip", "voip.call", "voip.ringtone"],
|
||||||
|
start(env, services) {
|
||||||
|
return new UserAgent(env, services);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("voip.user_agent", userAgentService);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { UserSettings } from "@mail/core/common/user_settings_service";
|
||||||
|
|
||||||
|
import { patch } from "@web/core/utils/patch";
|
||||||
|
|
||||||
|
const VOIP_CONFIG_KEYS = [
|
||||||
|
"external_device_number",
|
||||||
|
"how_to_call_on_mobile",
|
||||||
|
"should_auto_reject_incoming_calls",
|
||||||
|
"should_call_from_another_device",
|
||||||
|
"voip_secret",
|
||||||
|
"voip_username",
|
||||||
|
];
|
||||||
|
|
||||||
|
patch(UserSettings.prototype, {
|
||||||
|
/** @override */
|
||||||
|
updateFromCommands(settings) {
|
||||||
|
super.updateFromCommands(settings);
|
||||||
|
for (const key of VOIP_CONFIG_KEYS) {
|
||||||
|
if (key in settings) {
|
||||||
|
this[key] = settings[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { EventBus, markup, reactive } from "@odoo/owl";
|
||||||
|
|
||||||
|
import { CallMethodSelectionDialog } from "@voip/mobile/call_method_selection_dialog";
|
||||||
|
import { SoftphoneContainer } from "@voip/softphone/softphone_container";
|
||||||
|
import { Softphone } from "@voip/softphone/softphone_model";
|
||||||
|
import { cleanPhoneNumber } from "@voip/utils/utils";
|
||||||
|
import { VoipSystrayItem } from "@voip/web/voip_systray_item";
|
||||||
|
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { isMobileOS } from "@web/core/browser/feature_detection";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { Deferred } from "@web/core/utils/concurrency";
|
||||||
|
import { escape } from "@web/core/utils/strings";
|
||||||
|
|
||||||
|
export class Voip {
|
||||||
|
bus = new EventBus();
|
||||||
|
error;
|
||||||
|
isReady = new Deferred();
|
||||||
|
/**
|
||||||
|
* Either “demo” or “prod”. In demo mode, phone calls are simulated in the
|
||||||
|
* interface but no RTC sessions are actually established.
|
||||||
|
*
|
||||||
|
* @type {"demo"|"prod"}
|
||||||
|
*/
|
||||||
|
mode;
|
||||||
|
/**
|
||||||
|
* The address of the PBX server. Used as the hostname in SIP URIs.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
pbxAddress;
|
||||||
|
/** @type {Softphone} */
|
||||||
|
softphone;
|
||||||
|
/**
|
||||||
|
* The WebSocket URL of the signaling server that will be used to
|
||||||
|
* communicate SIP messages between Odoo and the PBX server.
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
*/
|
||||||
|
webSocketUrl;
|
||||||
|
|
||||||
|
constructor(env, services) {
|
||||||
|
this.env = env;
|
||||||
|
/** @type {import("@mail/core/user_settings_service").UserSettings} */
|
||||||
|
this.settings = services["mail.user_settings"];
|
||||||
|
/** @type {import("@mail/core/store_service").Store} */
|
||||||
|
this.store = services["mail.store"];
|
||||||
|
/** @type {import("@mail/core/messaging_service").Messaging} */
|
||||||
|
this.messaging = services["mail.messaging"];
|
||||||
|
/** @type {import("@mail/activity/activity_service").ActivityService} */
|
||||||
|
this.activityService = services["mail.activity"];
|
||||||
|
this.callService = services["voip.call"];
|
||||||
|
this.dialog = services.dialog;
|
||||||
|
this.orm = services.orm;
|
||||||
|
this.busService = services.bus_service;
|
||||||
|
this.softphone = new Softphone(this.store, this);
|
||||||
|
// VoIP config is retrieved by init_messaging RPC to minimize the number
|
||||||
|
// of requests at start-up. This is why we need to wait until
|
||||||
|
// mail.messaging is ready to update the VoIP config.
|
||||||
|
this.messaging.isReady.then(() => {
|
||||||
|
this.callService.missedCalls = this.store.voipConfig?.missedCalls ?? 0;
|
||||||
|
delete this.store.voipConfig?.missedCalls;
|
||||||
|
Object.assign(this, this.store.voipConfig);
|
||||||
|
delete this.store.voipConfig;
|
||||||
|
this.isReady.resolve();
|
||||||
|
});
|
||||||
|
this.busService.addEventListener("notification", this._onBusNotifications.bind(this));
|
||||||
|
document.body.addEventListener("beforeunload", this._onBeforeUnload.bind(this));
|
||||||
|
return reactive(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if `voip_secret` and `voip_username` settings are defined for
|
||||||
|
* the current user.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get areCredentialsSet() {
|
||||||
|
return Boolean(this.settings.voip_username && this.settings.voip_secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With some providers, the authorization username (the one used to register
|
||||||
|
* with the PBX server) differs from the username. This getter is intended
|
||||||
|
* to provide a way to override the authorization username.
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
get authorizationUsername() {
|
||||||
|
return this.settings.voip_username || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
get calls() {
|
||||||
|
return this.store.Call.records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
get canCall() {
|
||||||
|
return (
|
||||||
|
this.mode === "demo" ||
|
||||||
|
(this.hasRtcSupport && this.isServerConfigured && this.areCredentialsSet)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
get hasPendingRequest() {
|
||||||
|
return Boolean(this._activityRpc || this._contactRpc || this._recentCallsRpc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
get hasRtcSupport() {
|
||||||
|
return Boolean(
|
||||||
|
window.RTCPeerConnection && window.MediaStream && browser.navigator.mediaDevices
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if `pbxAddress` and `webSocketUrl` have been provided.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get isServerConfigured() {
|
||||||
|
return Boolean(this.pbxAddress && this.webSocketUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
get isValidTransferNumber() {
|
||||||
|
if (!this.settings.external_device_number) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cleanPhoneNumber(this.settings.external_device_number) !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} */
|
||||||
|
get missedCalls() {
|
||||||
|
return this.callService.missedCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the `should_call_from_another_device` setting is set and if
|
||||||
|
* an `external_device_number` has been provided.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
get willCallFromAnotherDevice() {
|
||||||
|
return this.settings.should_call_from_another_device && this.isValidTransferNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchContacts(searchTerms = "", offset = 0, limit = 13) {
|
||||||
|
if (this._contactRpc) {
|
||||||
|
this._contactRpc.abort();
|
||||||
|
}
|
||||||
|
this._contactRpc = this.orm.call("res.partner", "get_contacts", [], {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
search_terms: searchTerms,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const contactsData = await this._contactRpc;
|
||||||
|
contactsData.forEach((contactData) =>
|
||||||
|
this.store.Persona.insert({ ...contactData, type: "partner" })
|
||||||
|
);
|
||||||
|
this._contactRpc = null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.event?.type === "abort") {
|
||||||
|
error.event.preventDefault();
|
||||||
|
} else {
|
||||||
|
this._contactRpc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRecentCalls(offset = 0, limit = 13) {
|
||||||
|
if (this._recentCallsRpc) {
|
||||||
|
this._recentCallsRpc.abort();
|
||||||
|
}
|
||||||
|
this._recentCallsRpc = this.orm.call("voip.call", "get_recent_phone_calls", [], {
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
search_terms: this.softphone.searchBarInputValue.trim(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const callsData = await this._recentCallsRpc;
|
||||||
|
callsData.forEach((data) => this.store.Call.insert(data));
|
||||||
|
this._recentCallsRpc = null;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.event?.type === "abort") {
|
||||||
|
error.event.preventDefault();
|
||||||
|
} else {
|
||||||
|
this._recentCallsRpc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchTodayCallActivities() {
|
||||||
|
if (this._activityRpc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._activityRpc = this.orm.call("mail.activity", "get_today_call_activities");
|
||||||
|
try {
|
||||||
|
const activitiesData = await this._activityRpc;
|
||||||
|
activitiesData.forEach((data) => this.store.Activity.insert(data));
|
||||||
|
} finally {
|
||||||
|
this._activityRpc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resetMissedCalls() {
|
||||||
|
if (this.missedCalls !== 0) {
|
||||||
|
this.orm.call("res.users", "reset_last_seen_phone_call");
|
||||||
|
}
|
||||||
|
this.callService.missedCalls = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveError() {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers an error that will be displayed in the softphone, and blocks the
|
||||||
|
* UI by default.
|
||||||
|
*
|
||||||
|
* @param {string} message The error message to be displayed.
|
||||||
|
* @param {Object} [options={}]
|
||||||
|
* @param {boolean} [options.isNonBlocking=false] If true, the error will
|
||||||
|
* not block the UI.
|
||||||
|
*/
|
||||||
|
triggerError(message, { isNonBlocking = false } = {}) {
|
||||||
|
const safeText = markup(escape(message).replaceAll("\n", "<br>"));
|
||||||
|
this.error = { text: safeText, isNonBlocking };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Deferred<boolean>} */
|
||||||
|
async willCallUsingVoip() {
|
||||||
|
if (!isMobileOS()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const callMethod = this.settings.how_to_call_on_mobile;
|
||||||
|
if (callMethod !== "ask") {
|
||||||
|
return callMethod === "voip";
|
||||||
|
}
|
||||||
|
const useVoip = new Deferred();
|
||||||
|
this.dialog.add(
|
||||||
|
CallMethodSelectionDialog,
|
||||||
|
{ useVoip },
|
||||||
|
{ onClose: () => useVoip.resolve(true) }
|
||||||
|
);
|
||||||
|
return useVoip;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {BeforeUnloadEvent} ev
|
||||||
|
* @returns {string|undefined}
|
||||||
|
*/
|
||||||
|
_onBeforeUnload(ev) {
|
||||||
|
if (!this.softphone.selectedCorrespondence?.call?.isInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ev.preventDefault();
|
||||||
|
return (ev.returnValue = _t(
|
||||||
|
"There is still a call in progress, are you sure you want to leave the page?"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onBusNotifications({ detail: notifications }) {
|
||||||
|
for (const { payload, type } of notifications) {
|
||||||
|
switch (type) {
|
||||||
|
case "delete_call_activity": {
|
||||||
|
const activity = this.store.Activity.insert(payload);
|
||||||
|
this.activityService.delete(activity);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "refresh_call_activities":
|
||||||
|
this.fetchTodayCallActivities();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const voipService = {
|
||||||
|
dependencies: [
|
||||||
|
"bus_service",
|
||||||
|
"dialog",
|
||||||
|
"mail.activity",
|
||||||
|
"mail.messaging",
|
||||||
|
"mail.store",
|
||||||
|
"mail.user_settings",
|
||||||
|
"orm",
|
||||||
|
"user",
|
||||||
|
"voip.call",
|
||||||
|
],
|
||||||
|
async start(env, { user }) {
|
||||||
|
const isEmployee = await user.hasGroup("base.group_user");
|
||||||
|
if (!isEmployee) {
|
||||||
|
const isReady = new Deferred();
|
||||||
|
return {
|
||||||
|
bus: new EventBus(),
|
||||||
|
get canCall() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
isReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
registry.category("main_components").add("voip.SoftphoneContainer", {
|
||||||
|
Component: SoftphoneContainer,
|
||||||
|
});
|
||||||
|
registry.category("systray").add("voip", { Component: VoipSystrayItem });
|
||||||
|
return new Voip(...arguments);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("services").add("voip", voipService);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Component, useRef } from "@odoo/owl";
|
||||||
|
|
||||||
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export class CallMethodSelectionDialog extends Component {
|
||||||
|
static components = { Dialog };
|
||||||
|
static props = { close: Function, useVoip: Promise };
|
||||||
|
static template = "voip.CallMethodSelectionDialog";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.fieldsetRef = useRef("fieldset");
|
||||||
|
this.rememberCheckboxRef = useRef("rememberCheckbox");
|
||||||
|
this.settings = useService("mail.user_settings");
|
||||||
|
this.orm = useService("orm");
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogProps() {
|
||||||
|
return { title: _t("Select a call method") };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MouseEvent} ev */
|
||||||
|
onClickConfirm(ev) {
|
||||||
|
const { value } = this.fieldsetRef.el.querySelector(
|
||||||
|
"input[type='radio'][name='call-method']:checked"
|
||||||
|
);
|
||||||
|
if (this.rememberCheckboxRef.el.checked) {
|
||||||
|
this.orm.call("res.users.settings", "set_res_users_settings", [[this.settings.id]], {
|
||||||
|
new_settings: { how_to_call_on_mobile: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.props.useVoip.resolve(value === "voip");
|
||||||
|
this.props.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="voip.CallMethodSelectionDialog">
|
||||||
|
<Dialog t-props="dialogProps">
|
||||||
|
<fieldset class="mb-3" t-ref="fieldset">
|
||||||
|
<legend>Tell us how to make the call:</legend>
|
||||||
|
<div class="ms-3">
|
||||||
|
<input type="radio" id="voip" name="call-method" value="voip" checked="checked"/>
|
||||||
|
<label for="voip" class="ps-1">Using VoIP</label>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<input type="radio" id="phone" name="call-method" value="phone"/>
|
||||||
|
<label for="phone" class="ps-1">Using device's phone</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
<input type="checkbox" id="remember" name="remember" t-ref="rememberCheckbox"/>
|
||||||
|
<label for="remember" class="ps-1">Remember?</label>
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-primary" t-on-click="onClickConfirm">Confirm</button>
|
||||||
|
</t>
|
||||||
|
</Dialog>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* @odoo-module */
|
||||||
|
|
||||||
|
import { Component, onWillStart, useRef } from "@odoo/owl";
|
||||||
|
|
||||||
|
import { browser } from "@web/core/browser/browser";
|
||||||
|
import { Dialog } from "@web/core/dialog/dialog";
|
||||||
|
import { useService } from "@web/core/utils/hooks";
|
||||||
|
import { _t } from "@web/core/l10n/translation";
|
||||||
|
|
||||||
|
export class DeviceSelectionDialog extends Component {
|
||||||
|
static components = { Dialog };
|
||||||
|
static props = { close: Function };
|
||||||
|
static template = "voip.DeviceSelectionDialog";
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.devices = [];
|
||||||
|
this.selectRef = useRef("select");
|
||||||
|
this.userAgent = useService("voip.user_agent");
|
||||||
|
onWillStart(async () => (this.devices = await this.getAudioInputDevices()));
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentDeviceId() {
|
||||||
|
return this.userAgent.preferredInputDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogProps() {
|
||||||
|
return {
|
||||||
|
title: _t("Input device selection"),
|
||||||
|
fullscreen: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Promise<{deviceId: *, label: string}[]>} */
|
||||||
|
async getAudioInputDevices() {
|
||||||
|
const devices = await browser.navigator.mediaDevices.enumerateDevices();
|
||||||
|
return devices
|
||||||
|
.filter(({ kind }) => kind === "audioinput")
|
||||||
|
.map((device, i) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
label: device.label || `Device ${i + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MouseEvent} ev */
|
||||||
|
onClickCancel(ev) {
|
||||||
|
this.props.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {MouseEvent} ev */
|
||||||
|
onClickConfirm(ev) {
|
||||||
|
this.userAgent.switchInputStream(this.selectRef.el.value);
|
||||||
|
this.props.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<templates xml:space="preserve">
|
||||||
|
<t t-name="voip.DeviceSelectionDialog">
|
||||||
|
<Dialog t-props="dialogProps">
|
||||||
|
<label for="device-select">Choose a device:</label>
|
||||||
|
<select id="device-select" t-ref="select">
|
||||||
|
<option t-foreach="devices" t-as="device" t-key="device.deviceId" t-esc="device.label" t-att-value="device.deviceId"/>
|
||||||
|
</select>
|
||||||
|
<t t-set-slot="footer">
|
||||||
|
<button class="btn btn-primary" t-on-click="onClickConfirm">Confirm</button>
|
||||||
|
<button class="btn btn-secondary" t-on-click="onClickCancel">Cancel</button>
|
||||||
|
</t>
|
||||||
|
</Dialog>
|
||||||
|
</t>
|
||||||
|
</templates>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user