pengyuthon wechat

This commit is contained in:
李鹏宇
2024-12-15 22:36:45 +08:00
parent f2072e5f62
commit 3109245fcf
267 changed files with 107181 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+45
View File
@@ -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",
],
},
}
+5
View File
@@ -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';
+130
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1135
View File
File diff suppressed because it is too large Load Diff
+1169
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1121
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1168
View File
File diff suppressed because it is too large Load Diff
+1143
View File
File diff suppressed because it is too large Load Diff
+1121
View File
File diff suppressed because it is too large Load Diff
+1190
View File
File diff suppressed because it is too large Load Diff
+1141
View File
File diff suppressed because it is too large Load Diff
+1181
View File
File diff suppressed because it is too large Load Diff
+1181
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1170
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1122
View File
File diff suppressed because it is too large Load Diff
+1180
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1181
View File
File diff suppressed because it is too large Load Diff
+1137
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1139
View File
File diff suppressed because it is too large Load Diff
+1130
View File
File diff suppressed because it is too large Load Diff
+1147
View File
File diff suppressed because it is too large Load Diff
+1122
View File
File diff suppressed because it is too large Load Diff
+1111
View File
File diff suppressed because it is too large Load Diff
+1173
View File
File diff suppressed because it is too large Load Diff
+1116
View File
File diff suppressed because it is too large Load Diff
+1183
View File
File diff suppressed because it is too large Load Diff
+1136
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1141
View File
File diff suppressed because it is too large Load Diff
+1148
View File
File diff suppressed because it is too large Load Diff
+1135
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1128
View File
File diff suppressed because it is too large Load Diff
+1120
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1137
View File
File diff suppressed because it is too large Load Diff
+1150
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1120
View File
File diff suppressed because it is too large Load Diff
+1140
View File
File diff suppressed because it is too large Load Diff
+1187
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1142
View File
File diff suppressed because it is too large Load Diff
+1117
View File
File diff suppressed because it is too large Load Diff
+1179
View File
File diff suppressed because it is too large Load Diff
+1126
View File
File diff suppressed because it is too large Load Diff
+1184
View File
File diff suppressed because it is too large Load Diff
+1116
View File
File diff suppressed because it is too large Load Diff
+1125
View File
File diff suppressed because it is too large Load Diff
+1138
View File
File diff suppressed because it is too large Load Diff
+1143
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1127
View File
File diff suppressed because it is too large Load Diff
+1139
View File
File diff suppressed because it is too large Load Diff
+1165
View File
File diff suppressed because it is too large Load Diff
+1129
View File
File diff suppressed because it is too large Load Diff
+1172
View File
File diff suppressed because it is too large Load Diff
+1170
View File
File diff suppressed because it is too large Load Diff
+1111
View File
File diff suppressed because it is too large Load Diff
+1137
View File
File diff suppressed because it is too large Load Diff
+1137
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -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
+134
View File
@@ -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
+23
View File
@@ -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,
)
+31
View File
@@ -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
]
+134
View File
@@ -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,
}
)
+41
View File
@@ -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,
)
+168
View File
@@ -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
]
+121
View File
@@ -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()
+3
View File
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_voip_call_admin voip_call_manager model_voip_call base.group_system 1 1 1 1
3 access_voip_call_user voip_call_user model_voip_call base.group_user 1 1 1 0
+15
View File
@@ -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

+1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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>
+120
View File
@@ -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();
+90
View File
@@ -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;
},
});
+91
View File
@@ -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();
}
}
}
+642
View File
@@ -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];
}
}
},
});
+316
View File
@@ -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