mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-24 19:02:01 +02:00
19.0 vanilla
This commit is contained in:
parent
5df8c07b59
commit
daa394e8b0
2114 changed files with 564841 additions and 299642 deletions
|
|
@ -1,13 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
import base64
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
import random
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from odoo import api, Command, fields, models, modules, _
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo.fields import Domain
|
||||
from odoo.addons.bus.websocket import WebsocketConnectionHandler
|
||||
from odoo.addons.mail.tools.discuss import Store
|
||||
|
||||
BUFFER_TIME = 120 # Time in seconds between two sessions assigned to the same operator. Not enforced if the operator is the best suited.
|
||||
|
||||
|
||||
class ImLivechatChannel(models.Model):
|
||||
class Im_LivechatChannel(models.Model):
|
||||
""" Livechat Channel
|
||||
Define a communication channel, which can be accessed with 'script_external' (script tag to put on
|
||||
external website), 'script_internal' (code to be integrated with odoo website) or via 'web_page' link.
|
||||
|
|
@ -17,64 +25,206 @@ class ImLivechatChannel(models.Model):
|
|||
_name = 'im_livechat.channel'
|
||||
_inherit = ['rating.parent.mixin']
|
||||
_description = 'Livechat Channel'
|
||||
_rating_satisfaction_days = 7 # include only last 7 days to compute satisfaction
|
||||
|
||||
def _default_image(self):
|
||||
image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png')
|
||||
return base64.b64encode(open(image_path, 'rb').read())
|
||||
_rating_satisfaction_days = 14 # include only last 14 days to compute satisfaction
|
||||
|
||||
def _default_user_ids(self):
|
||||
return [(6, 0, [self._uid])]
|
||||
return [(6, 0, [self.env.uid])]
|
||||
|
||||
def _default_button_text(self):
|
||||
return _('Have a Question? Chat with us.')
|
||||
return _('Need help? Chat with us.')
|
||||
|
||||
def _default_default_message(self):
|
||||
return _('How may I help you?')
|
||||
|
||||
# attribute fields
|
||||
name = fields.Char('Channel Name', required=True)
|
||||
button_text = fields.Char('Text of the Button', default=_default_button_text,
|
||||
help="Default text displayed on the Livechat Support Button")
|
||||
button_text = fields.Char('Text of the Button', default=_default_button_text, translate=True)
|
||||
default_message = fields.Char('Welcome Message', default=_default_default_message,
|
||||
help="This is an automated 'welcome' message that your visitor will see when they initiate a new conversation.")
|
||||
input_placeholder = fields.Char('Chat Input Placeholder', help='Text that prompts the user to initiate the chat.')
|
||||
help="This is an automated 'welcome' message that your visitor will see when they initiate a new conversation.", translate=True)
|
||||
header_background_color = fields.Char(default="#875A7B", help="Default background color of the channel header once open")
|
||||
title_color = fields.Char(default="#FFFFFF", help="Default title color of the channel once open")
|
||||
button_background_color = fields.Char(default="#875A7B", help="Default background color of the Livechat button")
|
||||
button_text_color = fields.Char(default="#FFFFFF", help="Default text color of the Livechat button")
|
||||
max_sessions_mode = fields.Selection(
|
||||
[("unlimited", "Unlimited"), ("limited", "Limited")],
|
||||
default="unlimited",
|
||||
string="Sessions per Operator",
|
||||
help="If limited, operators will only handle the selected number of sessions at a time.",
|
||||
)
|
||||
max_sessions = fields.Integer(
|
||||
default=10,
|
||||
string="Maximum Sessions",
|
||||
help="Maximum number of concurrent sessions per operator.",
|
||||
)
|
||||
block_assignment_during_call = fields.Boolean("No Chats During Call", help="While on a call, agents will not receive new conversations.")
|
||||
review_link = fields.Char("Review Link", help="Visitors who leave a positive review will be redirected to this optional link.")
|
||||
|
||||
# computed fields
|
||||
web_page = fields.Char('Web Page', compute='_compute_web_page_link', store=False, readonly=True,
|
||||
help="URL to a static page where you client can discuss with the operator of the channel.")
|
||||
are_you_inside = fields.Boolean(string='Are you inside the matrix?',
|
||||
compute='_are_you_inside', store=False, readonly=True)
|
||||
available_operator_ids = fields.Many2many('res.users', compute='_compute_available_operator_ids')
|
||||
script_external = fields.Html('Script (external)', compute='_compute_script_external', store=False, readonly=True, sanitize=False)
|
||||
nbr_channel = fields.Integer('Number of conversation', compute='_compute_nbr_channel', store=False, readonly=True)
|
||||
|
||||
image_128 = fields.Image("Image", max_width=128, max_height=128, default=_default_image)
|
||||
|
||||
# relationnal fields
|
||||
user_ids = fields.Many2many('res.users', 'im_livechat_channel_im_user', 'channel_id', 'user_id', string='Operators', default=_default_user_ids)
|
||||
channel_ids = fields.One2many('mail.channel', 'livechat_channel_id', 'Sessions')
|
||||
user_ids = fields.Many2many('res.users', 'im_livechat_channel_im_user', 'channel_id', 'user_id', string='Agents', default=_default_user_ids)
|
||||
channel_ids = fields.One2many('discuss.channel', 'livechat_channel_id', 'Sessions')
|
||||
chatbot_script_count = fields.Integer(string='Number of Chatbot', compute='_compute_chatbot_script_count')
|
||||
rule_ids = fields.One2many('im_livechat.channel.rule', 'channel_id', 'Rules')
|
||||
ongoing_session_count = fields.Integer(
|
||||
"Number of Ongoing Sessions", compute="_compute_ongoing_sessions_count"
|
||||
)
|
||||
remaining_session_capacity = fields.Integer(
|
||||
"Remaining Session Capacity", compute="_compute_remaining_session_capacity"
|
||||
)
|
||||
|
||||
_max_sessions_mode_greater_than_zero = models.Constraint(
|
||||
"CHECK(max_sessions > 0)", "Concurrent session number should be greater than zero."
|
||||
)
|
||||
|
||||
def web_read(self, specification: dict[str, dict]) -> list[dict]:
|
||||
user_context = specification.get("user_ids", {}).get("context", {})
|
||||
if len(self) == 1 and user_context.pop("add_livechat_channel_ctx", None):
|
||||
user_context["im_livechat_channel_id"] = self.id
|
||||
return super().web_read(specification)
|
||||
|
||||
def _are_you_inside(self):
|
||||
for channel in self:
|
||||
channel.are_you_inside = bool(self.env.uid in [u.id for u in channel.user_ids])
|
||||
channel.are_you_inside = self.env.user in channel.user_ids
|
||||
|
||||
@api.depends("channel_ids.livechat_end_dt")
|
||||
def _compute_ongoing_sessions_count(self):
|
||||
count_by_channel = defaultdict(int)
|
||||
for key, count in self._get_ongoing_session_count_by_agent_livechat_channel().items():
|
||||
count_by_channel[key[1]] += count
|
||||
for channel in self:
|
||||
channel.ongoing_session_count = count_by_channel.get(channel, 0)
|
||||
|
||||
@api.depends(
|
||||
"block_assignment_during_call",
|
||||
"max_sessions",
|
||||
"user_ids.livechat_is_in_call",
|
||||
"user_ids.livechat_ongoing_session_count",
|
||||
)
|
||||
def _compute_remaining_session_capacity(self):
|
||||
count = self._get_ongoing_session_count_by_agent_livechat_channel()
|
||||
for channel in self:
|
||||
users = channel.user_ids
|
||||
if channel.block_assignment_during_call:
|
||||
users = users.filtered(lambda u: not u.livechat_is_in_call)
|
||||
total_capacity = channel.max_sessions * len(users)
|
||||
capacity = total_capacity - sum(
|
||||
count.get((user.partner_id, channel), 0) for user in users
|
||||
)
|
||||
channel.remaining_session_capacity = max(capacity, 0)
|
||||
|
||||
@api.depends(
|
||||
"user_ids.channel_ids.last_interest_dt",
|
||||
"user_ids.channel_ids.livechat_end_dt",
|
||||
"user_ids.channel_ids.livechat_channel_id",
|
||||
"user_ids.channel_ids.livechat_operator_id",
|
||||
"user_ids.channel_member_ids",
|
||||
"user_ids.im_status",
|
||||
"user_ids.is_in_call",
|
||||
"user_ids.partner_id",
|
||||
)
|
||||
def _compute_available_operator_ids(self):
|
||||
operators_by_livechat_channel = self._get_available_operators_by_livechat_channel()
|
||||
for livechat_channel in self:
|
||||
livechat_channel.available_operator_ids = operators_by_livechat_channel[livechat_channel]
|
||||
|
||||
@api.constrains("review_link")
|
||||
def _check_review_link(self):
|
||||
for record in self.filtered("review_link"):
|
||||
url = urlparse(record.review_link)
|
||||
if url.scheme not in ("http", "https") or not url.netloc:
|
||||
raise ValidationError(
|
||||
self.env._("Invalid URL '%s'. The Review Link must start with 'http://' or 'https://'.") % record.review_link
|
||||
)
|
||||
|
||||
def _get_available_operators_by_livechat_channel(self, users=None):
|
||||
"""Return a dictionary mapping each livechat channel in ``self`` to the users that are
|
||||
available for that livechat channel, according to the user status and the optional
|
||||
limit of concurrent sessions of the livechat channel.
|
||||
|
||||
When ``users`` are provided, each user is attempted to be mapped for each livechat
|
||||
channel. Otherwise, only the users of each respective livechat channel are considered.
|
||||
|
||||
:param users: Optional list of users to consider. Every agent in ``self`` will be
|
||||
considered if omitted.
|
||||
|
||||
"""
|
||||
counts = {}
|
||||
if livechat_channels := self.filtered(lambda c: c.max_sessions_mode == "limited"):
|
||||
counts = livechat_channels._get_ongoing_session_count_by_agent_livechat_channel(
|
||||
users, filter_online=True
|
||||
)
|
||||
|
||||
def is_available(user, channel):
|
||||
return (
|
||||
# sudo - res.users: can access agent presence to determine if they are available.
|
||||
user.sudo().presence_ids.status == "online"
|
||||
and (
|
||||
channel.max_sessions_mode == "unlimited"
|
||||
or counts.get((user.partner_id, channel), 0) < channel.max_sessions
|
||||
)
|
||||
# sudo: res.users - it's acceptable to check if the user is in call
|
||||
and (not channel.block_assignment_during_call or not user.sudo().is_in_call)
|
||||
)
|
||||
|
||||
operators_by_livechat_channel = {}
|
||||
for livechat_channel in self:
|
||||
possible_users = users if users is not None else livechat_channel.user_ids
|
||||
operators_by_livechat_channel[livechat_channel] = possible_users.filtered(
|
||||
lambda user, livechat_channel=livechat_channel: is_available(user, livechat_channel)
|
||||
)
|
||||
return operators_by_livechat_channel
|
||||
|
||||
def _get_ongoing_session_count_by_agent_livechat_channel(self, users=None, filter_online=False):
|
||||
"""Return a dictionary mapping each ``(user, livechat_channel)`` pair to the number of
|
||||
ongoing livechat sessions.
|
||||
|
||||
:param users: List of users to consider for the session count.
|
||||
:param filter_online: If ``True``, only online agents will be considered.
|
||||
:type filter_online: bool
|
||||
:returns: A dictionary mapping ``(partner_id, livechat_channel_id)`` to the session count.
|
||||
:rtype: dict
|
||||
|
||||
"""
|
||||
user_domain = Domain(False)
|
||||
for channel in self:
|
||||
active_users = users if users is not None else channel.user_ids
|
||||
if filter_online:
|
||||
# sudo - res.users: can access agent presence to determine if they are available.
|
||||
active_users = active_users.filtered(lambda u: u.sudo().presence_ids.status == "online")
|
||||
user_domain |= Domain(
|
||||
[
|
||||
("partner_id", "in", active_users.partner_id.ids),
|
||||
("channel_id.livechat_channel_id", "in", channel.ids),
|
||||
]
|
||||
)
|
||||
counts = self.env["discuss.channel.member"]._read_group(
|
||||
Domain("channel_id.livechat_end_dt", "=", False)
|
||||
& Domain("channel_id.last_interest_dt", ">=", "-15M")
|
||||
& user_domain,
|
||||
groupby=["partner_id", "channel_id.livechat_channel_id"],
|
||||
aggregates=["__count"],
|
||||
)
|
||||
return {(partner, channel): count for (partner, channel, count) in counts}
|
||||
|
||||
@api.depends('rule_ids.chatbot_script_id')
|
||||
def _compute_chatbot_script_count(self):
|
||||
data = self.env['im_livechat.channel.rule'].read_group(
|
||||
[('channel_id', 'in', self.ids)], ['chatbot_script_id:count_distinct'], ['channel_id'])
|
||||
mapped_data = {rule['channel_id'][0]: rule['chatbot_script_id'] for rule in data}
|
||||
data = self.env['im_livechat.channel.rule']._read_group(
|
||||
[('channel_id', 'in', self.ids)], ['channel_id'], ['chatbot_script_id:count_distinct'])
|
||||
mapped_data = {channel.id: count_distinct for channel, count_distinct in data}
|
||||
for channel in self:
|
||||
channel.chatbot_script_count = mapped_data.get(channel.id, 0)
|
||||
|
||||
def _compute_script_external(self):
|
||||
values = {
|
||||
"dbname": self._cr.dbname,
|
||||
"dbname": self.env.cr.dbname,
|
||||
}
|
||||
for record in self:
|
||||
values["channel_id"] = record.id
|
||||
|
|
@ -87,10 +237,10 @@ class ImLivechatChannel(models.Model):
|
|||
|
||||
@api.depends('channel_ids')
|
||||
def _compute_nbr_channel(self):
|
||||
data = self.env['mail.channel']._read_group([
|
||||
('livechat_channel_id', 'in', self.ids)
|
||||
], ['__count'], ['livechat_channel_id'], lazy=False)
|
||||
channel_count = {x['livechat_channel_id'][0]: x['__count'] for x in data}
|
||||
data = self.env['discuss.channel']._read_group([
|
||||
('livechat_channel_id', 'in', self.ids),
|
||||
], ['livechat_channel_id'], ['__count'])
|
||||
channel_count = {livechat_channel.id: count for livechat_channel, count in data}
|
||||
for record in self:
|
||||
record.nbr_channel = channel_count.get(record.id, 0)
|
||||
|
||||
|
|
@ -99,11 +249,17 @@ class ImLivechatChannel(models.Model):
|
|||
# --------------------------
|
||||
def action_join(self):
|
||||
self.ensure_one()
|
||||
return self.write({'user_ids': [(4, self._uid)]})
|
||||
if not self.env.user.has_group("im_livechat.im_livechat_group_user"):
|
||||
raise AccessError(_("Only Live Chat operators can join Live Chat channels"))
|
||||
# sudo: im_livechat.channel - operators can join channels
|
||||
self.sudo().user_ids = [Command.link(self.env.user.id)]
|
||||
Store(bus_channel=self.env.user).add(self, ["are_you_inside", "name"]).bus_send()
|
||||
|
||||
def action_quit(self):
|
||||
self.ensure_one()
|
||||
return self.write({'user_ids': [(3, self._uid)]})
|
||||
# sudo: im_livechat.channel - users can leave channels
|
||||
self.sudo().user_ids = [Command.unlink(self.env.user.id)]
|
||||
Store(bus_channel=self.env.user).add(self.sudo(), ["are_you_inside", "name"]).bus_send()
|
||||
|
||||
def action_view_rating(self):
|
||||
""" Action to display the rating relative to the channel, so all rating of the
|
||||
|
|
@ -111,8 +267,13 @@ class ImLivechatChannel(models.Model):
|
|||
:returns : the ir.action 'action_view_rating' with the correct context
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('im_livechat.rating_rating_action_livechat')
|
||||
action['context'] = {'search_default_parent_res_name': self.name}
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"im_livechat.discuss_channel_action_from_livechat_channel"
|
||||
)
|
||||
action["context"] = {
|
||||
"search_default_parent_res_name": self.name,
|
||||
"search_default_fiter_session_rated": "1"
|
||||
}
|
||||
return action
|
||||
|
||||
def action_view_chatbot_scripts(self):
|
||||
|
|
@ -130,132 +291,272 @@ class ImLivechatChannel(models.Model):
|
|||
# --------------------------
|
||||
# Channel Methods
|
||||
# --------------------------
|
||||
def _get_available_users(self):
|
||||
""" get available user of a given channel
|
||||
:retuns : return the res.users having their im_status online
|
||||
"""
|
||||
self.ensure_one()
|
||||
return self.user_ids.filtered(lambda user: user.im_status == 'online')
|
||||
|
||||
def _get_livechat_mail_channel_vals(self, anonymous_name, operator=None, chatbot_script=None, user_id=None, country_id=None):
|
||||
# partner to add to the mail.channel
|
||||
operator_partner_id = operator.partner_id.id if operator else chatbot_script.operator_partner_id.id
|
||||
members_to_add = [Command.create({'partner_id': operator_partner_id, 'is_pinned': False})]
|
||||
visitor_user = False
|
||||
if user_id:
|
||||
visitor_user = self.env['res.users'].browse(user_id)
|
||||
if visitor_user and visitor_user.active and operator and visitor_user != operator: # valid session user (not public)
|
||||
members_to_add.append(Command.create({'partner_id': visitor_user.partner_id.id}))
|
||||
|
||||
if chatbot_script:
|
||||
name = chatbot_script.title
|
||||
else:
|
||||
name = ' '.join([
|
||||
visitor_user.display_name if visitor_user else anonymous_name,
|
||||
operator.livechat_username if operator.livechat_username else operator.name
|
||||
])
|
||||
def _get_livechat_discuss_channel_vals(self, /, *, chatbot_script=None, agent=None, operator_partner, operator_model, **kwargs):
|
||||
# use the same "now" in the whole function to ensure unpin_dt > last_interest_dt
|
||||
now = fields.Datetime.now()
|
||||
last_interest_dt = now - timedelta(seconds=1)
|
||||
members_to_add = [Command.create(self._get_agent_member_vals(
|
||||
last_interest_dt=last_interest_dt, now=now,
|
||||
chatbot_script=chatbot_script,
|
||||
operator_partner=operator_partner,
|
||||
operator_model=operator_model,
|
||||
**kwargs
|
||||
))]
|
||||
guest = self.env["mail.guest"]._get_guest_from_context()
|
||||
if guest and self.env.user._is_public():
|
||||
members_to_add.append(
|
||||
Command.create({"livechat_member_type": "visitor", "guest_id": guest.id})
|
||||
)
|
||||
visitor_user = self.env["res.users"]
|
||||
if not self.env.user._is_public():
|
||||
visitor_user = self.env.user
|
||||
if visitor_user and visitor_user != agent:
|
||||
members_to_add.append(
|
||||
Command.create(
|
||||
{
|
||||
"livechat_member_type": "visitor",
|
||||
"partner_id": visitor_user.partner_id.id,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
channel_name = self._get_channel_name(
|
||||
visitor_user=visitor_user,
|
||||
guest=guest,
|
||||
agent=agent,
|
||||
chatbot_script=chatbot_script,
|
||||
operator_model=operator_model,
|
||||
**kwargs
|
||||
)
|
||||
is_chatbot_script = operator_model == 'chatbot.script'
|
||||
is_agent = operator_model == 'res.users'
|
||||
return {
|
||||
'channel_member_ids': members_to_add,
|
||||
'livechat_active': True,
|
||||
'livechat_operator_id': operator_partner_id,
|
||||
"last_interest_dt": last_interest_dt,
|
||||
'livechat_operator_id': operator_partner.id,
|
||||
'livechat_channel_id': self.id,
|
||||
'chatbot_current_step_id': chatbot_script._get_welcome_steps()[-1].id if chatbot_script else False,
|
||||
'anonymous_name': False if user_id else anonymous_name,
|
||||
'country_id': country_id,
|
||||
"livechat_failure": "no_answer" if is_agent else "no_failure",
|
||||
"livechat_status": "in_progress",
|
||||
'chatbot_current_step_id': chatbot_script._get_welcome_steps()[-1].id if is_chatbot_script else False,
|
||||
'channel_type': 'livechat',
|
||||
'name': name,
|
||||
'name': channel_name,
|
||||
}
|
||||
|
||||
def _open_livechat_mail_channel(self, anonymous_name, previous_operator_id=None, chatbot_script=None, user_id=None, country_id=None, persisted=True):
|
||||
""" Return a livechat session. If the session is persisted, creates a mail.channel record with a connected operator or with Odoobot as
|
||||
an operator if a chatbot has been configured, or return false otherwise
|
||||
:param anonymous_name : the name of the anonymous person of the session
|
||||
:param previous_operator_id : partner_id.id of the previous operator that this visitor had in the past
|
||||
:param chatbot_script : chatbot script if there is one configured
|
||||
:param user_id : the id of the logged in visitor, if any
|
||||
:param country_code : the country of the anonymous person of the session
|
||||
:param persisted: whether or not the session should be persisted
|
||||
:type anonymous_name : str
|
||||
:return : channel header
|
||||
:rtype : dict
|
||||
def _get_agent_member_vals(self, /, *, last_interest_dt, now, chatbot_script, operator_partner, operator_model, **kwargs):
|
||||
return {
|
||||
"chatbot_script_id": chatbot_script.id if operator_model == 'chatbot.script' else False,
|
||||
"last_interest_dt": last_interest_dt,
|
||||
"livechat_member_type": "agent" if operator_model == 'res.users' else "bot",
|
||||
"partner_id": operator_partner.id,
|
||||
"unpin_dt": now,
|
||||
}
|
||||
|
||||
If this visitor already had an operator within the last 7 days (information stored with the 'im_livechat_previous_operator_pid' cookie),
|
||||
the system will first try to assign that operator if he's available (to improve user experience).
|
||||
"""
|
||||
self.ensure_one()
|
||||
user_operator = False
|
||||
if chatbot_script:
|
||||
if chatbot_script.id not in self.env['im_livechat.channel.rule'].search(
|
||||
[('channel_id', 'in', self.ids)]).mapped('chatbot_script_id').ids:
|
||||
return False
|
||||
elif previous_operator_id:
|
||||
available_users = self._get_available_users()
|
||||
# previous_operator_id is the partner_id of the previous operator, need to convert to user
|
||||
if previous_operator_id in available_users.mapped('partner_id').ids:
|
||||
user_operator = next(available_user for available_user in available_users if available_user.partner_id.id == previous_operator_id)
|
||||
if not user_operator and not chatbot_script:
|
||||
user_operator = self._get_random_operator()
|
||||
if not user_operator and not chatbot_script:
|
||||
# no one available
|
||||
return False
|
||||
mail_channel_vals = self._get_livechat_mail_channel_vals(anonymous_name, user_operator, chatbot_script, user_id=user_id, country_id=country_id)
|
||||
if persisted:
|
||||
# create the session, and add the link with the given channel
|
||||
mail_channel = self.env["mail.channel"].with_context(mail_create_nosubscribe=False).sudo().create(mail_channel_vals)
|
||||
if user_operator:
|
||||
mail_channel._broadcast([user_operator.partner_id.id])
|
||||
return mail_channel.sudo().channel_info()[0]
|
||||
def _get_channel_name(self, /, *, visitor_user=None, guest=None, agent, chatbot_script, operator_model, **kwargs):
|
||||
if operator_model == 'chatbot.script':
|
||||
channel_name = chatbot_script.title
|
||||
else:
|
||||
operator_partner_id = user_operator.partner_id if user_operator else chatbot_script.operator_partner_id
|
||||
display_name = operator_partner_id.user_livechat_username or operator_partner_id.display_name
|
||||
return {
|
||||
'name': mail_channel_vals['name'],
|
||||
'chatbot_current_step_id': mail_channel_vals['chatbot_current_step_id'],
|
||||
'state': 'open',
|
||||
'operator_pid': (operator_partner_id.id, display_name.replace(',', '')),
|
||||
'chatbot_script_id': chatbot_script.id if chatbot_script else None
|
||||
}
|
||||
channel_name = ' '.join([
|
||||
visitor_user.display_name if visitor_user else guest.name,
|
||||
agent.livechat_username or agent.name
|
||||
])
|
||||
return channel_name
|
||||
|
||||
def _get_random_operator(self):
|
||||
""" Return a random operator from the available users of the channel that have the lowest number of active livechats.
|
||||
A livechat is considered 'active' if it has at least one message within the 30 minutes.
|
||||
def _get_operator_info(self, /, *, lang, country_id, previous_operator_id=None, chatbot_script_id=None, **kwargs):
|
||||
agent = self.env['res.users']
|
||||
chatbot_script = self.env['chatbot.script']
|
||||
operator_partner = self.env['res.partner']
|
||||
# The operator_model establishes the priority among potential operators (e.g., chatbot_script or ai_agent) for a live chat channel.
|
||||
# It dictates which operator model is selected when multiple are configured.
|
||||
operator_model = ''
|
||||
|
||||
(Some annoying conversions have to be made on the fly because this model holds 'res.users' as available operators
|
||||
and the mail_channel model stores the partner_id of the randomly selected operator)
|
||||
if chatbot_script_id and chatbot_script_id in self.rule_ids.chatbot_script_id.ids:
|
||||
chatbot_script = (
|
||||
self.env["chatbot.script"]
|
||||
.sudo()
|
||||
.with_context(lang=self.env["chatbot.script"]._get_chatbot_language())
|
||||
.search([("id", "=", chatbot_script_id)])
|
||||
)
|
||||
operator_partner = chatbot_script.operator_partner_id
|
||||
operator_model = 'chatbot.script'
|
||||
|
||||
if not operator_model:
|
||||
agent = self._get_operator(
|
||||
previous_operator_id=previous_operator_id,
|
||||
lang=lang,
|
||||
country_id=country_id,
|
||||
)
|
||||
operator_partner = agent.partner_id
|
||||
operator_model = 'res.users'
|
||||
|
||||
return {'agent': agent, 'chatbot_script': chatbot_script, 'operator_partner': operator_partner, 'operator_model': operator_model}
|
||||
|
||||
def _get_less_active_operator(self, operator_statuses, operators):
|
||||
""" Retrieve the most available operator based on the following criteria:
|
||||
- Lowest number of active chats.
|
||||
- Not in a call.
|
||||
- If an operator is in a call and has two or more active chats, don't
|
||||
give priority over an operator with more conversations who is not in a
|
||||
call.
|
||||
|
||||
:param operator_statuses: list of dictionaries containing the operator's
|
||||
id, the number of active chats and a boolean indicating if the
|
||||
operator is in a call. The list is ordered by the number of active
|
||||
chats (ascending) and whether the operator is in a call
|
||||
(descending).
|
||||
:param operators: recordset of :class:`ResUsers` operators to choose from.
|
||||
:return: the :class:`ResUsers` record for the chosen operator
|
||||
"""
|
||||
if not operators:
|
||||
return False
|
||||
|
||||
# 1) only consider operators in the list to choose from
|
||||
operator_statuses = [
|
||||
s for s in operator_statuses if s['partner_id'] in set(operators.partner_id.ids)
|
||||
]
|
||||
|
||||
# 2) try to select an inactive op, i.e. one w/ no active status (no recent chat)
|
||||
active_op_partner_ids = {s['partner_id'] for s in operator_statuses}
|
||||
candidates = operators.filtered(lambda o: o.partner_id.id not in active_op_partner_ids)
|
||||
if candidates:
|
||||
return random.choice(candidates)
|
||||
|
||||
# 3) otherwise select least active ops, based on status ordering (count + in_call)
|
||||
best_status = operator_statuses[0]
|
||||
best_status_op_partner_ids = {
|
||||
s['partner_id']
|
||||
for s in operator_statuses
|
||||
if (s['count'], s['in_call']) == (best_status['count'], best_status['in_call'])
|
||||
}
|
||||
candidates = operators.filtered(lambda o: o.partner_id.id in best_status_op_partner_ids)
|
||||
return random.choice(candidates)
|
||||
|
||||
def _get_operator(
|
||||
self, previous_operator_id=None, lang=None, country_id=None, expertises=None, users=None
|
||||
):
|
||||
""" Return an operator for a livechat. Try to return the previous
|
||||
operator if available. If not, one of the most available operators be
|
||||
returned.
|
||||
|
||||
A livechat is considered 'active' if it has at least one message within
|
||||
the 30 minutes. This method will try to match the given lang, expertises
|
||||
and country_id.
|
||||
|
||||
(Some annoying conversions have to be made on the fly because this model
|
||||
holds 'res.users' as available operators and the discuss_channel model
|
||||
stores the partner_id of the randomly selected operator)
|
||||
|
||||
:param previous_operator_id: partner id of the previous operator with
|
||||
whom the visitor was chatting.
|
||||
:param lang: code of the preferred lang of the visitor.
|
||||
:param country_id: id of the country of the visitor.
|
||||
:param expertises: preferred expertises for filtering operators.
|
||||
:param users: recordset of available users to use as candidates instead
|
||||
of the users of the livechat channel.
|
||||
:return : user
|
||||
:rtype : res.users
|
||||
"""
|
||||
operators = self._get_available_users()
|
||||
if len(operators) == 0:
|
||||
return False
|
||||
self.ensure_one()
|
||||
# FIXME: remove inactive call sessions so operators no longer in call are available
|
||||
# sudo: required to use garbage collecting function.
|
||||
self.env["discuss.channel.rtc.session"].sudo()._gc_inactive_sessions()
|
||||
users = users if users is not None else self.available_operator_ids
|
||||
if not users:
|
||||
return self.env["res.users"]
|
||||
if expertises is None:
|
||||
expertises = self.env["im_livechat.expertise"]
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
WITH operator_rtc_session AS (
|
||||
SELECT COUNT(DISTINCT s.id) as nbr, member.partner_id as partner_id
|
||||
FROM discuss_channel_rtc_session s
|
||||
JOIN discuss_channel_member member ON (member.id = s.channel_member_id)
|
||||
GROUP BY member.partner_id
|
||||
)
|
||||
SELECT COUNT(DISTINCT h.channel_id), COALESCE(rtc.nbr, 0) > 0 as in_call, h.partner_id
|
||||
FROM im_livechat_channel_member_history h
|
||||
JOIN discuss_channel c ON h.channel_id = c.id
|
||||
LEFT OUTER JOIN operator_rtc_session rtc ON rtc.partner_id = h.partner_id
|
||||
WHERE c.livechat_end_dt IS NULL
|
||||
AND c.last_interest_dt > ((now() at time zone 'UTC') - interval '30 minutes')
|
||||
AND h.partner_id in %s
|
||||
GROUP BY h.partner_id, rtc.nbr
|
||||
ORDER BY COUNT(DISTINCT h.channel_id) < 2 OR rtc.nbr IS NULL DESC,
|
||||
COUNT(DISTINCT h.channel_id) ASC,
|
||||
rtc.nbr IS NULL DESC
|
||||
""",
|
||||
(tuple(users.partner_id.ids),),
|
||||
)
|
||||
operator_statuses = self.env.cr.dictfetchall()
|
||||
# Try to match the previous operator
|
||||
if previous_operator_id in users.partner_id.ids:
|
||||
previous_operator_status = next(
|
||||
(
|
||||
status
|
||||
for status in operator_statuses
|
||||
if status['partner_id'] == previous_operator_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not previous_operator_status or previous_operator_status['count'] < 2 or not previous_operator_status['in_call']:
|
||||
previous_operator_user = next(
|
||||
available_user
|
||||
for available_user in users
|
||||
if available_user.partner_id.id == previous_operator_id
|
||||
)
|
||||
return previous_operator_user
|
||||
|
||||
self.env.cr.execute("""SELECT COUNT(DISTINCT c.id), c.livechat_operator_id
|
||||
FROM mail_channel c
|
||||
LEFT OUTER JOIN mail_message m ON c.id = m.res_id AND m.model = 'mail.channel'
|
||||
WHERE c.channel_type = 'livechat'
|
||||
AND c.livechat_operator_id in %s
|
||||
AND m.create_date > ((now() at time zone 'UTC') - interval '30 minutes')
|
||||
GROUP BY c.livechat_operator_id
|
||||
ORDER BY COUNT(DISTINCT c.id) asc""", (tuple(operators.mapped('partner_id').ids),))
|
||||
active_channels = self.env.cr.dictfetchall()
|
||||
agents_failing_buffer = {
|
||||
group[0]
|
||||
for group in self.env["im_livechat.channel.member.history"]._read_group(
|
||||
[
|
||||
("livechat_member_type", "=", "agent"),
|
||||
("partner_id", "in", users.partner_id.ids),
|
||||
("channel_id.livechat_end_dt", "=", False),
|
||||
(
|
||||
"create_date",
|
||||
">",
|
||||
fields.Datetime.now() - timedelta(seconds=BUFFER_TIME),
|
||||
),
|
||||
],
|
||||
groupby=["partner_id"],
|
||||
)
|
||||
}
|
||||
|
||||
# If inactive operator(s), return one of them
|
||||
active_channel_operator_ids = [active_channel['livechat_operator_id'] for active_channel in active_channels]
|
||||
inactive_operators = [operator for operator in operators if operator.partner_id.id not in active_channel_operator_ids]
|
||||
if inactive_operators:
|
||||
return random.choice(inactive_operators)
|
||||
def same_language(operator):
|
||||
return operator.partner_id.lang == lang or lang in operator.livechat_lang_ids.mapped("code")
|
||||
|
||||
# If no inactive operator, active_channels is not empty as len(operators) > 0 (see above).
|
||||
# Get the less active operator using the active_channels first element's count (since they are sorted 'ascending')
|
||||
lowest_number_of_conversations = active_channels[0]['count']
|
||||
less_active_operator = random.choice([
|
||||
active_channel['livechat_operator_id'] for active_channel in active_channels
|
||||
if active_channel['count'] == lowest_number_of_conversations])
|
||||
def all_expertises(operator):
|
||||
return operator.livechat_expertise_ids >= expertises
|
||||
|
||||
# convert the selected 'partner_id' to its corresponding res.users
|
||||
return next(operator for operator in operators if operator.partner_id.id == less_active_operator)
|
||||
def one_expertise(operator):
|
||||
return operator.livechat_expertise_ids & expertises
|
||||
|
||||
def same_country(operator):
|
||||
return operator.partner_id.country_id.id == country_id
|
||||
|
||||
# List from most important to least important. Order on each line is irrelevant, all
|
||||
# elements of a line must be satisfied together or the next line is checked.
|
||||
preferences_list = [
|
||||
[same_language, all_expertises],
|
||||
[same_language, one_expertise],
|
||||
[same_language],
|
||||
[same_country, all_expertises],
|
||||
[same_country, one_expertise],
|
||||
[same_country],
|
||||
[all_expertises],
|
||||
[one_expertise],
|
||||
]
|
||||
for preferences in preferences_list:
|
||||
operators = users
|
||||
for preference in preferences:
|
||||
operators = operators.filtered(preference)
|
||||
if operators:
|
||||
if agents_respecting_buffer := operators.filtered(
|
||||
lambda op: op.partner_id not in agents_failing_buffer
|
||||
):
|
||||
operators = agents_respecting_buffer
|
||||
return self._get_less_active_operator(operator_statuses, operators)
|
||||
return self._get_less_active_operator(operator_statuses, users)
|
||||
|
||||
def _get_channel_infos(self):
|
||||
self.ensure_one()
|
||||
|
|
@ -266,10 +567,10 @@ class ImLivechatChannel(models.Model):
|
|||
'title_color': self.title_color,
|
||||
'button_text_color': self.button_text_color,
|
||||
'button_text': self.button_text,
|
||||
'input_placeholder': self.input_placeholder,
|
||||
'default_message': self.default_message,
|
||||
"channel_name": self.name,
|
||||
"channel_id": self.id,
|
||||
"review_link": self.review_link,
|
||||
}
|
||||
|
||||
def get_livechat_info(self, username=None):
|
||||
|
|
@ -278,16 +579,19 @@ class ImLivechatChannel(models.Model):
|
|||
if username is None:
|
||||
username = _('Visitor')
|
||||
info = {}
|
||||
info['available'] = self.chatbot_script_count or len(self._get_available_users()) > 0
|
||||
info['available'] = self._is_livechat_available()
|
||||
info['server_url'] = self.get_base_url()
|
||||
info["websocket_worker_version"] = WebsocketConnectionHandler._VERSION
|
||||
if info['available']:
|
||||
info['options'] = self._get_channel_infos()
|
||||
info['options']['current_partner_id'] = self.env.user.partner_id.id
|
||||
info['options']["default_username"] = username
|
||||
return info
|
||||
|
||||
def _is_livechat_available(self):
|
||||
return self.chatbot_script_count or len(self.available_operator_ids) > 0
|
||||
|
||||
class ImLivechatChannelRule(models.Model):
|
||||
|
||||
class Im_LivechatChannelRule(models.Model):
|
||||
""" Channel Rules
|
||||
Rules defining access to the channel (countries, and url matching). It also provide the 'auto pop'
|
||||
option to open automatically the conversation.
|
||||
|
|
@ -308,14 +612,22 @@ class ImLivechatChannelRule(models.Model):
|
|||
"* 'Show with notification' is 'Show' in addition to a floating text just next to the button.\n"\
|
||||
"* 'Open automatically' displays the button and automatically opens the conversation pane.\n"\
|
||||
"* 'Hide' hides the chat button on the pages.\n")
|
||||
auto_popup_timer = fields.Integer('Open automatically timer', default=0,
|
||||
auto_popup_timer = fields.Integer('Time to Open', default=0,
|
||||
help="Delay (in seconds) to automatically open the conversation window. Note: the selected action must be 'Open automatically' otherwise this parameter will not be taken into account.")
|
||||
chatbot_script_id = fields.Many2one('chatbot.script', string='Chatbot')
|
||||
chatbot_only_if_no_operator = fields.Boolean(
|
||||
string='Enabled only if no operator', help='Enable the bot only if there is no operator available')
|
||||
channel_id = fields.Many2one('im_livechat.channel', 'Channel',
|
||||
chatbot_enabled_condition = fields.Selection(
|
||||
string="Enable ChatBot",
|
||||
selection=[
|
||||
("always", "Always"),
|
||||
("only_if_no_operator", "Only when no operator is available"),
|
||||
("only_if_operator", "Only when an operator is available"),
|
||||
],
|
||||
required=True,
|
||||
default="always",
|
||||
)
|
||||
channel_id = fields.Many2one('im_livechat.channel', 'Channel', index='btree_not_null',
|
||||
help="The channel of the rule")
|
||||
country_ids = fields.Many2many('res.country', 'im_livechat_channel_country_rel', 'channel_id', 'country_id', 'Country',
|
||||
country_ids = fields.Many2many('res.country', 'im_livechat_channel_country_rel', 'channel_id', 'country_id', 'Countries',
|
||||
help="The rule will only be applied for these countries. Example: if you select 'Belgium' and 'United States' and that you set the action to 'Hide', the chat button will be hidden on the specified URL from the visitors located in these 2 countries. This feature requires GeoIP installed on your server.")
|
||||
sequence = fields.Integer('Matching order', default=10,
|
||||
help="Given the order to find a matching rule. If 2 rules are matching for the given url/country, the one with the lowest sequence will be chosen.")
|
||||
|
|
@ -332,9 +644,21 @@ class ImLivechatChannelRule(models.Model):
|
|||
for rule in rules:
|
||||
# url might not be set because it comes from referer, in that
|
||||
# case match the first rule with no regex_url
|
||||
if re.search(rule.regex_url or '', url or ''):
|
||||
return rule
|
||||
return False
|
||||
if not re.search(rule.regex_url or "", url or ""):
|
||||
continue
|
||||
if rule.chatbot_script_id and (
|
||||
not rule.chatbot_script_id.active or not rule.chatbot_script_id.script_step_ids
|
||||
):
|
||||
continue
|
||||
if (
|
||||
rule.chatbot_enabled_condition == "only_if_operator"
|
||||
and not rule.channel_id.available_operator_ids
|
||||
or rule.chatbot_enabled_condition == "only_if_no_operator"
|
||||
and rule.channel_id.available_operator_ids
|
||||
):
|
||||
continue
|
||||
return rule
|
||||
return self.env["im_livechat.channel.rule"]
|
||||
# first, search the country specific rules (the first match is returned)
|
||||
if country_id: # don't include the country in the research if geoIP is not installed
|
||||
domain = [('country_ids', 'in', [country_id]), ('channel_id', '=', channel_id)]
|
||||
|
|
@ -344,3 +668,13 @@ class ImLivechatChannelRule(models.Model):
|
|||
# second, fallback on the rules without country
|
||||
domain = [('country_ids', '=', False), ('channel_id', '=', channel_id)]
|
||||
return _match(self.search(domain))
|
||||
|
||||
def _is_bot_configured(self):
|
||||
return bool(self.chatbot_script_id)
|
||||
|
||||
def _to_store_defaults(self, target):
|
||||
return [
|
||||
"action",
|
||||
"auto_popup_timer",
|
||||
Store.One("chatbot_script_id"),
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue