19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:39 +01:00
parent 5df8c07b59
commit daa394e8b0
2114 changed files with 564841 additions and 299642 deletions

View file

@ -1,14 +1,21 @@
# -*- coding: utf-8 -*
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import chatbot_message
from . import chatbot_script
from . import chatbot_script_answer
from . import chatbot_script_step
from . import discuss_call_history
from . import res_users
from . import res_groups
from . import res_partner
from . import im_livechat_channel
from . import mail_channel
from . import mail_channel_member
from . import im_livechat_expertise
from . import ir_websocket
from . import discuss_channel
from . import discuss_channel_member
from . import discuss_channel_rtc_session
from . import im_livechat_channel_member_history
from . import im_livechat_conversation_tag
from . import mail_message
from . import res_users_settings
from . import rating_rating

View file

@ -4,7 +4,7 @@
from odoo import models, fields
class ChatbotMailMessage(models.Model):
class ChatbotMessage(models.Model):
""" Chatbot Mail Message
We create a new model to store the related step to a mail.message and the user's answer.
We do this in a new model to avoid bloating the 'mail.message' model.
@ -13,16 +13,20 @@ class ChatbotMailMessage(models.Model):
_name = 'chatbot.message'
_description = 'Chatbot Message'
_order = 'create_date desc, id desc'
_rec_name = 'mail_channel_id'
_rec_name = 'discuss_channel_id'
mail_message_id = fields.Many2one('mail.message', string='Related Mail Message', required=True, ondelete="cascade")
mail_channel_id = fields.Many2one('mail.channel', string='Discussion Channel', required=True, ondelete="cascade")
mail_message_id = fields.Many2one('mail.message', string='Related Mail Message')
discuss_channel_id = fields.Many2one('discuss.channel', string='Discussion Channel', required=True, index=True, ondelete="cascade")
script_step_id = fields.Many2one(
'chatbot.script.step', string='Chatbot Step', required=True, ondelete='cascade')
"chatbot.script.step", string="Chatbot Step", index="btree_not_null")
user_script_answer_id = fields.Many2one('chatbot.script.answer', string="User's answer", ondelete="set null")
user_raw_script_answer_id = fields.Integer(help="Id of the script answer. Useful for statistics when answer is deleted.")
user_raw_answer = fields.Html(string="User's raw answer")
_sql_constraints = [
('_unique_mail_message_id', 'unique (mail_message_id)',
"A mail.message can only be linked to a single chatbot message"),
]
_unique_mail_message_id = models.Constraint(
'unique (mail_message_id)',
'A mail.message can only be linked to a single chatbot message',
)
_channel_id_user_raw_script_answer_id_idx = models.Index(
"(discuss_channel_id, user_raw_script_answer_id) WHERE user_raw_script_answer_id IS NOT NULL",
)

View file

@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models, fields
from odoo.tools import email_normalize, html2plaintext, is_html_empty, plaintext2html
from odoo import api, Command, models, fields
from odoo.http import request
from odoo.tools import email_normalize, get_lang, html2plaintext, is_html_empty, plaintext2html
from odoo.addons.mail.tools.discuss import Store
from odoo.exceptions import ValidationError
class ChatbotScript(models.Model):
@ -20,21 +23,33 @@ class ChatbotScript(models.Model):
script_step_ids = fields.One2many('chatbot.script.step', 'chatbot_script_id',
copy=True, string='Script Steps')
operator_partner_id = fields.Many2one('res.partner', string='Bot Operator',
ondelete='restrict', required=True, copy=False)
ondelete='restrict', required=True, copy=False, index=True)
livechat_channel_count = fields.Integer(string='Livechat Channel Count', compute='_compute_livechat_channel_count')
first_step_warning = fields.Selection([
('first_step_operator', 'First Step Operator'),
('first_step_invalid', 'First Step Invalid'),
], compute="_compute_first_step_warning")
@api.constrains("script_step_ids")
def _check_question_selection(self):
for step in self.script_step_ids:
if step.step_type == "question_selection" and not step.answer_ids:
raise ValidationError(self.env._("Step of type 'Question' must have answers."))
@api.onchange("script_step_ids")
def _onchange_script_step_ids(self):
for step in self.script_step_ids:
if step.step_type != "question_selection" and step.answer_ids:
step.answer_ids = [Command.clear()]
def _compute_livechat_channel_count(self):
channels_data = self.env['im_livechat.channel.rule'].read_group(
[('chatbot_script_id', 'in', self.ids)], ['channel_id:count_distinct'], ['chatbot_script_id'])
mapped_channels = {channel['chatbot_script_id'][0]: channel['channel_id'] for channel in channels_data}
channels_data = self.env['im_livechat.channel.rule']._read_group(
[('chatbot_script_id', 'in', self.ids)], ['chatbot_script_id'], ['channel_id:count_distinct'])
mapped_channels = {chatbot_script.id: count_distinct for chatbot_script, count_distinct in channels_data}
for script in self:
script.livechat_channel_count = mapped_channels.get(script.id, 0)
@api.depends('script_step_ids.step_type')
@api.depends("script_step_ids.is_forward_operator", "script_step_ids.step_type" )
def _compute_first_step_warning(self):
for script in self:
allowed_first_step_types = [
@ -45,14 +60,17 @@ class ChatbotScript(models.Model):
'free_input_multi',
]
welcome_steps = script.script_step_ids and script._get_welcome_steps()
if welcome_steps and welcome_steps[-1].step_type == 'forward_operator':
if welcome_steps and welcome_steps[-1].is_forward_operator:
script.first_step_warning = 'first_step_operator'
elif welcome_steps and welcome_steps[-1].step_type not in allowed_first_step_types:
script.first_step_warning = 'first_step_invalid'
else:
script.first_step_warning = False
@api.returns('self', lambda value: value.id)
def copy_data(self, default=None):
vals_list = super().copy_data(default=default)
return [dict(vals, title=self.env._("%s (copy)", script.title)) for script, vals in zip(self, vals_list)]
def copy(self, default=None):
""" Correctly copy the 'triggering_answer_ids' field from the original script_step_ids to the clone.
This needs to be done in post-processing to make sure we get references to the newly created
@ -60,35 +78,32 @@ class ChatbotScript(models.Model):
This implementation assumes that the order of created steps and answers will be kept between
the original and the clone, using 'zip()' to match the records between the two. """
default = default or {}
default['title'] = self.title + _(' (copy)')
clone_chatbot_script = super().copy(default=default)
new_scripts = super().copy(default=default)
if 'question_ids' in default:
return clone_chatbot_script
return new_scripts
original_steps = self.script_step_ids.sorted()
clone_steps = clone_chatbot_script.script_step_ids.sorted()
for old_script, new_script in zip(self, new_scripts):
original_steps = old_script.script_step_ids.sorted()
clone_steps = new_script.script_step_ids.sorted()
answers_map = {}
for clone_step, original_step in zip(clone_steps, original_steps):
for clone_answer, original_answer in zip(clone_step.answer_ids.sorted(), original_step.answer_ids.sorted()):
answers_map[original_answer] = clone_answer
answers_map = {}
for clone_step, original_step in zip(clone_steps, original_steps):
for clone_answer, original_answer in zip(clone_step.answer_ids.sorted(), original_step.answer_ids.sorted()):
answers_map[original_answer] = clone_answer
for clone_step, original_step in zip(clone_steps, original_steps):
clone_step.write({
'triggering_answer_ids': [
(4, answer.id)
for answer in [
answers_map[original_answer]
for original_answer
in original_step.triggering_answer_ids
for clone_step, original_step in zip(clone_steps, original_steps):
clone_step.write({
'triggering_answer_ids': [
(4, answer.id)
for answer in [
answers_map[original_answer]
for original_answer
in original_step.triggering_answer_ids
]
]
]
})
return clone_chatbot_script
})
return new_scripts
@api.model_create_multi
def create(self, vals_list):
@ -137,7 +152,7 @@ class ChatbotScript(models.Model):
end user.
This is important because we need to display those welcoming steps in a special fashion on
the frontend, since those are not inserted into the mail.channel as actual mail.messages,
the frontend, since those are not inserted into the discuss.channel as actual mail.messages,
to avoid bloating the channels with bot messages if the end-user never interacts with it. """
self.ensure_one()
@ -149,23 +164,23 @@ class ChatbotScript(models.Model):
return welcome_steps
def _post_welcome_steps(self, mail_channel):
def _post_welcome_steps(self, discuss_channel):
""" Welcome messages are only posted after the visitor's first interaction with the chatbot.
See 'chatbot.script#_get_welcome_steps()' for more details.
Side note: it is important to set the 'chatbot_current_step_id' on each iteration so that
it's correctly set when going into 'mail_channel#_message_post_after_hook()'. """
it's correctly set when going into 'discuss_channel#_message_post_after_hook()'. """
self.ensure_one()
posted_messages = self.env['mail.message']
for welcome_step in self._get_welcome_steps():
mail_channel.chatbot_current_step_id = welcome_step.id
discuss_channel.chatbot_current_step_id = welcome_step.id
if not is_html_empty(welcome_step.message):
posted_messages += mail_channel.with_context(mail_create_nosubscribe=True).message_post(
posted_messages += discuss_channel.with_context(mail_post_autofollow_author_skip=True).message_post(
author_id=self.operator_partner_id.id,
body=plaintext2html(welcome_step.message),
body=plaintext2html(welcome_step.message, with_paragraph=False),
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
@ -182,35 +197,29 @@ class ChatbotScript(models.Model):
# Tooling / Misc
# --------------------------
def _format_for_frontend(self):
""" Small utility method that formats the script into a dict usable by the frontend code. """
self.ensure_one()
def _to_store_defaults(self, target):
return [Store.One("operator_partner_id", ["name"]), "title"]
return {
'chatbot_script_id': self.id,
'chatbot_name': self.title,
'chatbot_operator_partner_id': self.operator_partner_id.id,
'chatbot_welcome_steps': [
step._format_for_frontend()
for step in self._get_welcome_steps()
]
}
def _validate_email(self, email_address, mail_channel):
def _validate_email(self, email_address, discuss_channel):
email_address = html2plaintext(email_address)
email_normalized = email_normalize(email_address)
posted_message = False
error_message = False
if not email_normalized:
error_message = _(
error_message = self.env._(
"'%(input_email)s' does not look like a valid email. Can you please try again?",
input_email=email_address
)
posted_message = mail_channel._chatbot_post_message(self, plaintext2html(error_message))
posted_message = discuss_channel._chatbot_post_message(self, plaintext2html(error_message))
return {
'success': bool(email_normalized),
'posted_message': posted_message,
'error_message': error_message,
}
def _get_chatbot_language(self):
return get_lang(
self.env, lang_code=request and request.httprequest.cookies.get("frontend_lang")
).code

View file

@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields
from odoo.osv import expression
from odoo.fields import Domain
import textwrap
@ -18,48 +17,30 @@ class ChatbotScriptAnswer(models.Model):
help="The visitor will be redirected to this link upon clicking the option "
"(note that the script will end if the link is external to the livechat website).")
script_step_id = fields.Many2one(
'chatbot.script.step', string='Script Step', required=True, ondelete='cascade')
'chatbot.script.step', string='Script Step', required=True, index=True, ondelete='cascade')
chatbot_script_id = fields.Many2one(related='script_step_id.chatbot_script_id')
def name_get(self):
if self._context.get('chatbot_script_answer_display_short_name'):
return super().name_get()
@api.depends('script_step_id')
@api.depends_context('chatbot_script_answer_display_short_name')
def _compute_display_name(self):
if self.env.context.get('chatbot_script_answer_display_short_name'):
return super()._compute_display_name()
result = []
for answer in self:
answer_message = answer.script_step_id.message.replace('\n', ' ')
shortened_message = textwrap.shorten(answer_message, width=26, placeholder=" [...]")
result.append((
answer.id,
"%s: %s" % (shortened_message, answer.name)
))
return result
if answer.script_step_id:
answer_message = answer.script_step_id.message.replace('\n', ' ')
shortened_message = textwrap.shorten(answer_message, width=26, placeholder=" [...]")
answer.display_name = f"{shortened_message}: {answer.name}"
else:
answer.display_name = answer.name
@api.model
def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
"""
Search the records whose name or step message are matching the ``name`` pattern.
The chatbot_script_id is also passed to the context through the custom widget
('chatbot_triggering_answers_widget') This allows to only see the question_answer
from the same chatbot you're configuring.
"""
force_domain_chatbot_script_id = self.env.context.get('force_domain_chatbot_script_id')
if name and operator == 'ilike':
if not args:
args = []
def _search_display_name(self, operator, value):
"""Search the records whose name or step message are matching the ``name`` pattern."""
if value and operator == 'ilike':
# search on both name OR step's message (combined with passed args)
name_domain = [('name', operator, name)]
step_domain = [('script_step_id.message', operator, name)]
domain = expression.AND([args, expression.OR([name_domain, step_domain])])
return Domain('name', operator, value) | Domain('script_step_id.message', operator, value)
return super()._search_display_name(operator, value)
else:
domain = args or []
if force_domain_chatbot_script_id:
domain = expression.AND([domain, [('chatbot_script_id', '=', force_domain_chatbot_script_id)]])
return self._search(domain, limit=limit, access_rights_uid=name_get_uid)
def _to_store_defaults(self, target):
return ["name", "redirect_link"]

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models, fields
from odoo.exceptions import ValidationError
from odoo.fields import Command
from odoo.osv import expression
from odoo.tools import html2plaintext, is_html_empty, email_normalize, plaintext2html
from odoo.fields import Command, Domain
from odoo.tools import html2plaintext, email_normalize
from odoo.addons.mail.tools.discuss import Store
from collections import defaultdict
from markupsafe import Markup
@ -15,12 +14,12 @@ class ChatbotScriptStep(models.Model):
_name = 'chatbot.script.step'
_description = 'Chatbot Script Step'
_order = 'sequence, id'
_rec_name = 'message'
message = fields.Text(string='Message', translate=True)
name = fields.Char(string="Name", compute="_compute_name")
message = fields.Html(string="Message", translate=True)
sequence = fields.Integer(string='Sequence')
chatbot_script_id = fields.Many2one(
'chatbot.script', string='Chatbot', required=True, ondelete='cascade')
'chatbot.script', string='Chatbot', required=True, index=True, ondelete='cascade')
step_type = fields.Selection([
('text', 'Text'),
('question_selection', 'Question'),
@ -35,12 +34,28 @@ class ChatbotScriptStep(models.Model):
'chatbot.script.answer', 'script_step_id',
copy=True, string='Answers')
triggering_answer_ids = fields.Many2many(
'chatbot.script.answer', domain="[('script_step_id.sequence', '<', sequence)]",
'chatbot.script.answer', domain="[('script_step_id.sequence', '<', sequence), ('script_step_id.chatbot_script_id', '=', chatbot_script_id)]",
compute='_compute_triggering_answer_ids', readonly=False, store=True,
copy=False, # copied manually, see chatbot.script#copy
string='Only If', help='Show this step only if all of these answers have been selected.')
# forward-operator specifics
is_forward_operator = fields.Boolean(compute="_compute_is_forward_operator")
is_forward_operator_child = fields.Boolean(compute='_compute_is_forward_operator_child')
operator_expertise_ids = fields.Many2many(
"im_livechat.expertise",
string="Operator Expertise",
help="When forwarding live chat conversations, the chatbot will prioritize users with matching expertise.",
)
@api.depends("sequence", "chatbot_script_id")
@api.depends_context('lang')
def _compute_name(self):
for step in self:
step.name = self.env._(
"%(title)s - Step %(sequence)d",
title=step.chatbot_script_id.title,
sequence=step.sequence,
)
@api.depends('sequence')
def _compute_triggering_answer_ids(self):
@ -50,13 +65,25 @@ class ChatbotScriptStep(models.Model):
if update_command:
step.triggering_answer_ids = update_command
@api.depends('sequence', 'triggering_answer_ids', 'chatbot_script_id.script_step_ids.triggering_answer_ids',
'chatbot_script_id.script_step_ids.answer_ids', 'chatbot_script_id.script_step_ids.sequence')
@api.depends("step_type")
def _compute_is_forward_operator(self):
for step in self:
step.is_forward_operator = step.step_type == "forward_operator"
@api.depends(
"chatbot_script_id.script_step_ids.answer_ids",
"chatbot_script_id.script_step_ids.is_forward_operator",
"chatbot_script_id.script_step_ids.sequence",
"chatbot_script_id.script_step_ids.step_type",
"chatbot_script_id.script_step_ids.triggering_answer_ids",
"sequence",
"triggering_answer_ids",
)
def _compute_is_forward_operator_child(self):
parent_steps_by_chatbot = {}
for chatbot in self.chatbot_script_id:
parent_steps_by_chatbot[chatbot.id] = chatbot.script_step_ids.filtered(
lambda step: step.step_type in ['forward_operator', 'question_selection']
lambda step: step.is_forward_operator or step.step_type == "question_selection"
).sorted(lambda s: s.sequence, reverse=True)
for step in self:
parent_steps = parent_steps_by_chatbot[step.chatbot_script_id.id].filtered(
@ -65,9 +92,9 @@ class ChatbotScriptStep(models.Model):
parent = step
while True:
parent = parent._get_parent_step(parent_steps)
if not parent or parent.step_type == 'forward_operator':
if not parent or parent.is_forward_operator:
break
step.is_forward_operator_child = parent and parent.step_type == 'forward_operator'
step.is_forward_operator_child = parent and parent.is_forward_operator
@api.model_create_multi
def create(self, vals_list):
@ -92,18 +119,15 @@ class ChatbotScriptStep(models.Model):
step_values.append(vals)
vals_by_chatbot_id[chatbot_id] = step_values
max_sequence_by_chatbot = {}
if vals_by_chatbot_id:
read_group_results = self.env['chatbot.script.step'].read_group(
[('chatbot_script_id', 'in', list(vals_by_chatbot_id.keys()))],
['sequence:max'],
['chatbot_script_id']
)
max_sequence_by_chatbot = {
read_group_result['chatbot_script_id'][0]: read_group_result['sequence']
for read_group_result in read_group_results
}
read_group_results = self.env['chatbot.script.step']._read_group(
[('chatbot_script_id', 'in', list(vals_by_chatbot_id))],
['chatbot_script_id'],
['sequence:max'],
)
max_sequence_by_chatbot = {
chatbot_script.id: sequence
for chatbot_script, sequence in read_group_results
}
for chatbot_id, step_vals in vals_by_chatbot_id.items():
current_sequence = 0
@ -123,33 +147,33 @@ class ChatbotScriptStep(models.Model):
# Business Methods
# --------------------------
def _chatbot_prepare_customer_values(self, mail_channel, create_partner=True, update_partner=True):
""" Common method that allows retreiving default customer values from the mail.channel
def _chatbot_prepare_customer_values(self, discuss_channel, create_partner=True, update_partner=True):
""" Common method that allows retreiving default customer values from the discuss.channel
following a chatbot.script.
This method will return a dict containing the 'customer' values such as:
{
'partner': The created partner (see 'create_partner') or the partner from the
environment if not public
'email': The email extracted from the mail.channel messages
'email': The email extracted from the discuss.channel messages
(see step_type 'question_email')
'phone': The phone extracted from the mail.channel messages
'phone': The phone extracted from the discuss.channel messages
(see step_type 'question_phone')
'description': A default description containing the "Please contact me on" and "Please
call me on" with the related email and phone numbers.
Can be used as a default description to create leads or tickets for example.
}
:param record mail_channel: the mail.channel holding the visitor's conversation with the bot.
:param record discuss_channel: the discuss.channel holding the visitor's conversation with the bot.
:param bool create_partner: whether or not to create a res.partner is the current user is public.
Defaults to True.
:param bool update_partner: whether or not to set update the email and phone on the res.partner
from the environment (if not a public user) if those are not set yet. Defaults to True.
:return dict: a dict containing the customer values."""
:returns: a dict containing the customer values."""
partner = False
user_inputs = mail_channel._chatbot_find_customer_values_in_messages({
user_inputs = discuss_channel._chatbot_find_customer_values_in_messages({
'question_email': 'email',
'question_phone': 'phone',
})
@ -176,9 +200,9 @@ class ChatbotScriptStep(models.Model):
description = Markup('')
if input_email:
description += Markup('%s<strong>%s</strong><br>') % (_('Please contact me on: '), input_email)
description += Markup("%s<strong>%s</strong><br>") % (_("Email: "), input_email)
if input_phone:
description += Markup('%s<strong>%s</strong><br>') % (_('Please call me on: '), input_phone)
description += Markup("%s<strong>%s</strong><br>") % (_("Phone: "), input_phone)
if description:
description += Markup('<br>')
@ -189,6 +213,17 @@ class ChatbotScriptStep(models.Model):
'description': description,
}
def _find_first_user_free_input(self, discuss_channel):
"""Find the first message from the visitor responding to a free_input step."""
chatbot_partner = self.chatbot_script_id.operator_partner_id
user_answers = discuss_channel.chatbot_message_ids.filtered(
lambda m: m.mail_message_id.author_id != chatbot_partner
).sorted("id")
for answer in user_answers:
if answer.script_step_id.step_type in ("free_input_single", "free_input_multi"):
return answer.mail_message_id
return self.env["mail.message"]
def _fetch_next_step(self, selected_answer_ids):
""" Fetch the next step depending on the user's selected answers.
If a step contains multiple triggering answers from the same step the condition between
@ -219,12 +254,9 @@ class ChatbotScriptStep(models.Model):
-> NOK
"""
self.ensure_one()
domain = [('chatbot_script_id', '=', self.chatbot_script_id.id), ('sequence', '>', self.sequence)]
domain = Domain('chatbot_script_id', '=', self.chatbot_script_id.id) & Domain('sequence', '>', self.sequence)
if selected_answer_ids:
domain = expression.AND([domain, [
'|',
('triggering_answer_ids', '=', False),
('triggering_answer_ids', 'in', selected_answer_ids.ids)]])
domain &= Domain('triggering_answer_ids', 'in', selected_answer_ids.ids + [False])
steps = self.env['chatbot.script.step'].search(domain)
for step in steps:
if not step.triggering_answer_ids:
@ -257,18 +289,20 @@ class ChatbotScriptStep(models.Model):
return step
return self.env['chatbot.script.step']
def _is_last_step(self, mail_channel=False):
def _is_last_step(self, discuss_channel=False):
self.ensure_one()
mail_channel = mail_channel or self.env['mail.channel']
discuss_channel = discuss_channel or self.env['discuss.channel']
# if it's not a question and if there is no next step, then we end the script
if self.step_type != 'question_selection' and not self._fetch_next_step(
mail_channel.chatbot_message_ids.user_script_answer_id):
# sudo: chatbot.script.answser - visitor can access their own answers
if self.step_type != "question_selection" and not self._fetch_next_step(
discuss_channel.sudo().chatbot_message_ids.user_script_answer_id
):
return True
return False
def _process_answer(self, mail_channel, message_body):
def _process_answer(self, discuss_channel, message_body):
""" Method called when the user reacts to the current chatbot.script step.
For most chatbot.script.step#step_types it simply returns the next chatbot.script.step of
the script (see '_fetch_next_step').
@ -277,7 +311,7 @@ class ChatbotScriptStep(models.Model):
we store the user raw answer (the mail message HTML body) into the chatbot.message in order
to be able to recover it later (see '_chatbot_prepare_customer_values').
:param mail_channel:
:param discuss_channel:
:param message_body:
:return: script step to display next
:rtype: 'chatbot.script.step' """
@ -289,9 +323,14 @@ class ChatbotScriptStep(models.Model):
# if this error is raised, display an error message but do not go to next step
raise ValidationError(_('"%s" is not a valid email.', user_text_answer))
if self.step_type in ['question_email', 'question_phone']:
if self.step_type in [
"question_email",
"question_phone",
"free_input_single",
"free_input_multi",
]:
chatbot_message = self.env['chatbot.message'].search([
('mail_channel_id', '=', mail_channel.id),
('discuss_channel_id', '=', discuss_channel.id),
('script_step_id', '=', self.id),
], limit=1)
@ -299,9 +338,10 @@ class ChatbotScriptStep(models.Model):
chatbot_message.write({'user_raw_answer': message_body})
self.env.flush_all()
return self._fetch_next_step(mail_channel.chatbot_message_ids.user_script_answer_id)
# sudo: chatbot.script.answer - visitor can access their own answer
return self._fetch_next_step(discuss_channel.sudo().chatbot_message_ids.user_script_answer_id)
def _process_step(self, mail_channel):
def _process_step(self, discuss_channel):
""" When we reach a chatbot.step in the script we need to do some processing on behalf of
the bot. Which is for most chatbot.script.step#step_types just posting the message field.
@ -310,74 +350,15 @@ class ChatbotScriptStep(models.Model):
Those will have a dedicated processing method with specific docstrings.
Returns the mail.message posted by the chatbot's operator_partner_id. """
self.ensure_one()
# We change the current step to the new step
mail_channel.chatbot_current_step_id = self.id
if self.step_type == 'forward_operator':
return self._process_step_forward_operator(mail_channel)
return discuss_channel._forward_human_operator(chatbot_script_step=self)
return discuss_channel._chatbot_post_message(self.chatbot_script_id, self.message)
return mail_channel._chatbot_post_message(self.chatbot_script_id, plaintext2html(self.message))
def _process_step_forward_operator(self, mail_channel):
""" Special type of step that will add a human operator to the conversation when reached,
which stops the script and allow the visitor to discuss with a real person.
In case we don't find any operator (e.g: no-one is available) we don't post any messages.
The script will continue normally, which allows to add extra steps when it's the case
(e.g: ask for the visitor's email and create a lead). """
human_operator = False
posted_message = False
if mail_channel.livechat_channel_id:
human_operator = mail_channel.livechat_channel_id._get_random_operator()
# handle edge case where we found yourself as available operator -> don't do anything
# it will act as if no-one is available (which is fine)
if human_operator and human_operator != self.env.user:
mail_channel.sudo().add_members(
human_operator.partner_id.ids,
open_chat_window=True,
post_joined_message=False)
# rename the channel to include the operator's name
mail_channel.sudo().name = ' '.join([
self.env.user.display_name if not self.env.user._is_public() else mail_channel.anonymous_name,
human_operator.livechat_username if human_operator.livechat_username else human_operator.name
])
if self.message:
# first post the message of the step (if we have one)
posted_message = mail_channel._chatbot_post_message(self.chatbot_script_id, plaintext2html(self.message))
# then post a small custom 'Operator has joined' notification
mail_channel._chatbot_post_message(
self.chatbot_script_id,
Markup('<div class="o_mail_notification">%s</div>') % _('%s has joined', human_operator.partner_id.name))
mail_channel._broadcast(human_operator.partner_id.ids)
mail_channel.channel_pin(pinned=True)
return posted_message
# --------------------------
# Tooling / Misc
# --------------------------
def _format_for_frontend(self):
""" Small utility method that formats the step into a dict usable by the frontend code. """
self.ensure_one()
return {
'chatbot_script_step_id': self.id,
'chatbot_step_answers': [{
'id': answer.id,
'label': answer.name,
'redirect_link': answer.redirect_link,
} for answer in self.answer_ids],
'chatbot_step_message': plaintext2html(self.message) if not is_html_empty(self.message) else False,
'chatbot_step_is_last': self._is_last_step(),
'chatbot_step_type': self.step_type
}
def _to_store_defaults(self, target):
return [
Store.Many("answer_ids"),
Store.Attr("is_last", lambda step: step._is_last_step()),
"message",
"step_type",
]

View file

@ -4,7 +4,7 @@
from odoo import fields, models
class Digest(models.Model):
class DigestDigest(models.Model):
_inherit = 'digest.digest'
kpi_livechat_rating = fields.Boolean('% of Happiness')
@ -15,40 +15,35 @@ class Digest(models.Model):
kpi_livechat_response_value = fields.Float(digits=(16, 2), compute='_compute_kpi_livechat_response_value')
def _compute_kpi_livechat_rating_value(self):
channels = self.env['mail.channel'].search([('livechat_operator_id', '=', self.env.user.partner_id.id)])
for record in self:
start, end, company = record._get_kpi_compute_parameters()
domain = [
('create_date', '>=', start), ('create_date', '<', end),
('rated_partner_id', '=', self.env.user.partner_id.id)
]
ratings = channels.rating_get_grades(domain)
record.kpi_livechat_rating_value = ratings['great'] * 100 / sum(ratings.values()) if sum(ratings.values()) else 0
channels = self.env['discuss.channel'].search([('channel_type', '=', 'livechat')])
start, end, __ = self._get_kpi_compute_parameters()
domain = [
('create_date', '>=', start),
('create_date', '<', end),
]
ratings = channels.rating_get_grades(domain)
self.kpi_livechat_rating_value = (
ratings['great'] * 100 / sum(ratings.values())
if sum(ratings.values()) else 0
)
def _compute_kpi_livechat_conversations_value(self):
for record in self:
start, end, company = record._get_kpi_compute_parameters()
record.kpi_livechat_conversations_value = self.env['mail.channel'].search_count([
('channel_type', '=', 'livechat'),
('livechat_operator_id', '=', self.env.user.partner_id.id),
('create_date', '>=', start), ('create_date', '<', end)
])
start, end, __ = self._get_kpi_compute_parameters()
self.kpi_livechat_conversations_value = self.env['discuss.channel'].search_count([
('channel_type', '=', 'livechat'),
('create_date', '>=', start), ('create_date', '<', end),
])
def _compute_kpi_livechat_response_value(self):
for record in self:
start, end, company = record._get_kpi_compute_parameters()
response_time = self.env['im_livechat.report.operator'].sudo()._read_group([
('start_date', '>=', start), ('start_date', '<', end),
('partner_id', '=', self.env.user.partner_id.id)], ['partner_id', 'time_to_answer'], ['partner_id'])
record.kpi_livechat_response_value = sum(
response['time_to_answer']
for response in response_time
if response['time_to_answer'] > 0
)
start, end, __ = self._get_kpi_compute_parameters()
response_time = self.env['im_livechat.report.channel'].sudo()._read_group([
('start_date', '>=', start),
('start_date', '<', end),
], [], ['time_to_answer:avg'])
self.kpi_livechat_response_value = response_time[0][0]
def _compute_kpis_actions(self, company, user):
res = super(Digest, self)._compute_kpis_actions(company, user)
res['kpi_livechat_rating'] = 'im_livechat.rating_rating_action_livechat_report'
res = super()._compute_kpis_actions(company, user)
res['kpi_livechat_conversations'] = 'im_livechat.im_livechat_report_operator_action'
res['kpi_livechat_response'] = 'im_livechat.im_livechat_report_channel_time_to_answer_action'
return res

View file

@ -0,0 +1,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class DiscussCallHistory(models.Model):
_inherit = "discuss.call.history"
livechat_participant_history_ids = fields.Many2many("im_livechat.channel.member.history")

View file

@ -0,0 +1,997 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, tools
from odoo.addons.mail.tools.discuss import Store
from odoo.tools import email_normalize, email_split, html2plaintext, plaintext2html
from odoo.tools.mimetypes import get_extension
import json
from markupsafe import Markup
from pytz import timezone
def is_livechat_channel(channel):
"""Predicate to filter channels for which the channel type is 'livechat'.
:returns: Whether the channel is a live chat channel.
:rtype: bool
"""
return channel.channel_type == "livechat"
class DiscussChannel(models.Model):
""" Chat Session
Reprensenting a conversation between users.
It extends the base method for anonymous usage.
"""
_name = 'discuss.channel'
_inherit = ['rating.mixin', 'discuss.channel']
channel_type = fields.Selection(selection_add=[('livechat', 'Livechat Conversation')], ondelete={'livechat': 'cascade'})
duration = fields.Float('Duration', compute='_compute_duration', help='Duration of the session in hours')
livechat_lang_id = fields.Many2one("res.lang", string="Language", help="Lang of the visitor of the channel.")
livechat_end_dt = fields.Datetime(
"Session end date",
help="Session is closed when either the visitor or the last agent leaves the conversation.",
)
livechat_channel_id = fields.Many2one('im_livechat.channel', 'Channel', index='btree_not_null')
livechat_operator_id = fields.Many2one('res.partner', string='Operator', index='btree_not_null')
livechat_channel_member_history_ids = fields.One2many("im_livechat.channel.member.history", "channel_id")
livechat_expertise_ids = fields.Many2many(
"im_livechat.expertise",
"discuss_channel_im_livechat_expertise_rel",
"discuss_channel_id",
"im_livechat_expertise_id",
store=True,
)
livechat_agent_history_ids = fields.One2many(
"im_livechat.channel.member.history",
string="Agents (History)",
compute="_compute_livechat_agent_history_ids",
search="_search_livechat_agent_history_ids",
)
livechat_bot_history_ids = fields.One2many(
"im_livechat.channel.member.history",
string="Bots (History)",
compute="_compute_livechat_bot_history_ids",
search="_search_livechat_bot_history_ids",
)
livechat_customer_history_ids = fields.One2many(
"im_livechat.channel.member.history",
string="Customers (History)",
compute="_compute_livechat_customer_history_ids",
search="_search_livechat_customer_history_ids",
)
livechat_agent_partner_ids = fields.Many2many(
"res.partner",
"im_livechat_channel_member_history_discuss_channel_agent_rel",
string="Agents",
compute="_compute_livechat_agent_partner_ids",
store=True,
)
livechat_bot_partner_ids = fields.Many2many(
"res.partner",
"im_livechat_channel_member_history_discuss_channel_bot_rel",
string="Bots",
compute="_compute_livechat_bot_partner_ids",
context={"active_test": False},
store=True,
)
livechat_customer_partner_ids = fields.Many2many(
"res.partner",
"im_livechat_channel_member_history_discuss_channel_customer_rel",
string="Customers (Partners)",
compute="_compute_livechat_customer_partner_ids",
store=True,
)
livechat_customer_guest_ids = fields.Many2many(
"mail.guest",
string="Customers (Guests)",
compute="_compute_livechat_customer_guest_ids",
)
livechat_agent_requesting_help_history = fields.Many2one(
"im_livechat.channel.member.history",
string="Help Requested (Agent)",
compute="_compute_livechat_agent_requesting_help_history",
store=True,
)
livechat_agent_providing_help_history = fields.Many2one(
"im_livechat.channel.member.history",
string="Help Provided (Agent)",
compute="_compute_livechat_agent_providing_help_history",
store=True,
)
livechat_note = fields.Html(
"Live Chat Note",
sanitize_style=True,
groups="base.group_user",
help="Note about the session, visible to all internal users having access to the session.",
)
livechat_status = fields.Selection(
selection=[
("in_progress", "In progress"),
("waiting", "Waiting for customer"),
("need_help", "Looking for help"),
],
compute="_compute_livechat_status",
groups="base.group_user",
readonly=False,
store=True,
)
livechat_outcome = fields.Selection(
[
("no_answer", "Never Answered"),
("no_agent", "No one Available"),
("no_failure", "Success"),
("escalated", "Escalated"),
],
compute="_compute_livechat_outcome",
store=True,
)
livechat_conversation_tag_ids = fields.Many2many(
"im_livechat.conversation.tag",
"livechat_conversation_tag_rel",
groups="im_livechat.im_livechat_group_user",
string="Live Chat Conversation Tags",
help="Tags to qualify the conversation.",
)
livechat_start_hour = fields.Float(
"Session Start Hour", compute="_compute_livechat_start_hour", store=True
)
livechat_week_day = fields.Selection(
[
("0", "Monday"),
("1", "Tuesday"),
("2", "Wednesday"),
("3", "Thursday"),
("4", "Friday"),
("5", "Saturday"),
("6", "Sunday"),
],
string="Day of the Week",
compute="_compute_livechat_week_day",
store=True,
)
livechat_matches_self_lang = fields.Boolean(
compute="_compute_livechat_matches_self_lang", search="_search_livechat_matches_self_lang"
)
livechat_matches_self_expertise = fields.Boolean(
compute="_compute_livechat_matches_self_expertise",
search="_search_livechat_matches_self_expertise",
)
chatbot_current_step_id = fields.Many2one('chatbot.script.step', string='Chatbot Current Step')
chatbot_message_ids = fields.One2many('chatbot.message', 'discuss_channel_id', string='Chatbot Messages')
country_id = fields.Many2one('res.country', string="Country", help="Country of the visitor of the channel")
livechat_failure = fields.Selection(
selection=[
("no_answer", "Never Answered"),
("no_agent", "No one Available"),
("no_failure", "No Failure"),
],
string="Live Chat Session Failure",
)
livechat_is_escalated = fields.Boolean("Is session escalated", compute="_compute_livechat_is_escalated", store=True)
rating_last_text = fields.Selection(store=True)
_livechat_operator_id = models.Constraint(
"CHECK((channel_type = 'livechat' and livechat_operator_id is not null) or (channel_type != 'livechat'))",
'Livechat Operator ID is required for a channel of type livechat.',
)
_livechat_end_dt_status_constraint = models.Constraint(
"CHECK(livechat_end_dt IS NULL or livechat_status IS NULL)",
"Closed Live Chat session should not have a status.",
)
_livechat_end_dt_idx = models.Index("(livechat_end_dt) WHERE livechat_end_dt IS NULL")
_livechat_failure_idx = models.Index(
"(livechat_failure) WHERE livechat_failure IN ('no_answer', 'no_agent')"
)
_livechat_is_escalated_idx = models.Index(
"(livechat_is_escalated) WHERE livechat_is_escalated IS TRUE"
)
_livechat_channel_type_create_date_idx = models.Index(
"(channel_type, create_date) WHERE channel_type = 'livechat'"
)
def write(self, vals):
if "livechat_status" not in vals and "livechat_expertise_ids" not in vals:
return super().write(vals)
need_help_before = self.filtered(lambda c: c.livechat_status == "need_help")
result = super().write(vals)
need_help_after = self.filtered(lambda c: c.livechat_status == "need_help")
group_livechat_user = self.env.ref("im_livechat.im_livechat_group_user")
store = Store(bus_channel=group_livechat_user, bus_subchannel="LOOKING_FOR_HELP")
added_need_help = need_help_after - need_help_before
removed_need_help = need_help_before - need_help_after
store.add(added_need_help)
store.add(removed_need_help, ["livechat_status"])
if "livechat_expertise_ids" in vals:
store.add(self, Store.Many("livechat_expertise_ids"))
if added_need_help or removed_need_help:
group_livechat_user._bus_send(
"im_livechat.looking_for_help/update",
{
"added_channel_ids": added_need_help.ids,
"removed_channel_ids": removed_need_help.ids,
},
subchannel="LOOKING_FOR_HELP",
)
store.bus_send()
return result
@api.depends("livechat_end_dt")
def _compute_duration(self):
for record in self:
end = record.livechat_end_dt or fields.Datetime.now()
start = record.create_date or fields.Datetime.now()
record.duration = (end - start).total_seconds() / 3600
@api.depends("livechat_end_dt")
def _compute_livechat_status(self):
for channel in self.filtered(lambda c: c.livechat_end_dt):
channel.livechat_status = False
@api.depends("livechat_agent_history_ids")
def _compute_livechat_is_escalated(self):
for channel in self:
channel.livechat_is_escalated = len(channel.livechat_agent_history_ids) > 1
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
def _compute_livechat_agent_history_ids(self):
for channel in self:
channel.livechat_agent_history_ids = (
channel.livechat_channel_member_history_ids.filtered(
lambda h: h.livechat_member_type == "agent",
)
)
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
def _compute_livechat_bot_history_ids(self):
for channel in self:
channel.livechat_bot_history_ids = channel.livechat_channel_member_history_ids.filtered(
lambda h: h.livechat_member_type == "bot",
)
def _search_livechat_bot_history_ids(self, operator, value):
if operator != "in":
return NotImplemented
bot_history_query = self.env["im_livechat.channel.member.history"]._search(
[
("livechat_member_type", "=", "bot"),
("id", "in", value),
],
)
return [("id", "in", bot_history_query.subselect("channel_id"))]
@api.depends("livechat_channel_member_history_ids.livechat_member_type")
def _compute_livechat_customer_history_ids(self):
for channel in self:
channel.livechat_customer_history_ids = (
channel.livechat_channel_member_history_ids.filtered(
lambda h: h.livechat_member_type == "visitor",
)
)
def _search_livechat_customer_history_ids(self, operator, value):
if operator != "in":
return NotImplemented
customer_history_query = self.env["im_livechat.channel.member.history"]._search(
[
("livechat_member_type", "=", "visitor"),
("id", "in", value),
],
)
return [("id", "in", customer_history_query.subselect("channel_id"))]
@api.depends("livechat_agent_history_ids.partner_id")
def _compute_livechat_agent_partner_ids(self):
for channel in self:
channel.livechat_agent_partner_ids = (
channel.livechat_agent_history_ids.partner_id
)
def _search_livechat_agent_history_ids(self, operator, value):
if operator not in ("any", "in"):
return NotImplemented
if operator == "in" and len(value) == 1 and not next(iter(value)):
return [
(
"id",
"not in",
self.env["im_livechat.channel.member.history"]
._search([("livechat_member_type", "=", "agent")])
.subselect("channel_id"),
),
]
query = (
self.env["im_livechat.channel.member.history"]._search(value)
if isinstance(value, fields.Domain)
else value
)
agent_history_query = self.env["im_livechat.channel.member.history"]._search(
[
("livechat_member_type", "=", "agent"),
("id", "in", query),
],
)
return [("id", "in", agent_history_query.subselect("channel_id"))]
@api.depends("livechat_bot_history_ids.partner_id")
def _compute_livechat_bot_partner_ids(self):
for channel in self:
channel.livechat_bot_partner_ids = (
channel.livechat_bot_history_ids.partner_id
)
@api.depends("livechat_customer_history_ids.partner_id")
def _compute_livechat_customer_partner_ids(self):
for channel in self:
channel.livechat_customer_partner_ids = (
channel.livechat_customer_history_ids.partner_id
)
# @api.depends("livechat_customer_history_ids.guest_id")
def _compute_livechat_customer_guest_ids(self):
for channel in self:
channel.livechat_customer_guest_ids = (
channel.livechat_customer_history_ids.guest_id
)
@api.depends("livechat_agent_history_ids")
def _compute_livechat_agent_requesting_help_history(self):
for channel in self:
channel.livechat_agent_requesting_help_history = (
channel.livechat_agent_history_ids.sorted(lambda h: (h.create_date, h.id))[0]
if channel.livechat_is_escalated
else None
)
@api.depends("livechat_agent_history_ids")
def _compute_livechat_agent_providing_help_history(self):
for channel in self:
channel.livechat_agent_providing_help_history = (
channel.livechat_agent_history_ids.sorted(
lambda h: (h.create_date, h.id), reverse=True
)[0]
if channel.livechat_is_escalated
else None
)
@api.depends("livechat_is_escalated", "livechat_failure")
def _compute_livechat_outcome(self):
for channel in self:
self.livechat_outcome = (
"escalated" if channel.livechat_is_escalated else channel.livechat_failure
)
@api.depends_context("user")
def _compute_livechat_matches_self_lang(self):
for channel in self:
channel.livechat_matches_self_lang = (
channel.livechat_lang_id in self.env.user.livechat_lang_ids
or channel.livechat_lang_id.code == self.env.user.lang
)
def _search_livechat_matches_self_lang(self, operator, value):
if operator != "in" or value not in ({True}, {False}):
return NotImplemented
operator = "in" if value == {True} else "not in"
lang_codes = self.env.user.livechat_lang_ids.mapped("code")
lang_codes.append(self.env.user.lang)
return [("livechat_lang_id.code", operator, lang_codes)]
@api.depends_context("user")
def _compute_livechat_matches_self_expertise(self):
for channel in self:
channel.livechat_matches_self_expertise = bool(
channel.livechat_expertise_ids & self.env.user.livechat_expertise_ids
)
def _search_livechat_matches_self_expertise(self, operator, value):
if operator != "in" or value not in ({True}, {False}):
return NotImplemented
operator = "in" if value == {True} else "not in"
return [("livechat_expertise_ids", operator, self.env.user.livechat_expertise_ids.ids)]
@api.depends("create_date")
def _compute_livechat_start_hour(self):
for channel in self:
channel.livechat_start_hour = channel.create_date.hour
@api.depends("create_date")
def _compute_livechat_week_day(self):
for channel in self:
channel.livechat_week_day = str(channel.create_date.weekday())
def _sync_field_names(self):
field_names = super()._sync_field_names()
field_names[None].append(
Store.One(
"livechat_operator_id",
self.env["discuss.channel"]._store_livechat_operator_id_fields(),
predicate=is_livechat_channel,
),
)
field_names["internal_users"].extend(
[
Store.Attr("description", predicate=is_livechat_channel),
Store.Attr("livechat_note", predicate=is_livechat_channel),
Store.Attr("livechat_status", predicate=is_livechat_channel),
Store.Many("livechat_expertise_ids", ["name"], predicate=is_livechat_channel),
# sudo: internal users having access to the channel can read its tags
Store.Many(
"livechat_conversation_tag_ids",
["name", "color"],
predicate=is_livechat_channel,
sudo=True,
),
],
)
return field_names
def _store_livechat_operator_id_fields(self):
"""Return the standard fields to include in Store for livechat_operator_id."""
return ["avatar_128", *self.env["res.partner"]._get_store_livechat_username_fields()]
def _to_store_defaults(self, target: Store.Target):
fields = [
"chatbot_current_step",
Store.One("country_id", ["code", "name"], predicate=is_livechat_channel),
Store.One(
"livechat_lang_id",
["name"],
predicate=is_livechat_channel,
),
Store.Attr("livechat_end_dt", predicate=is_livechat_channel),
# sudo - res.partner: accessing livechat operator is allowed
Store.One(
"livechat_operator_id",
self.env["discuss.channel"]._store_livechat_operator_id_fields(),
predicate=is_livechat_channel,
sudo=True,
),
]
if target.is_internal(self.env):
fields.append(
Store.One(
"livechat_channel_id", ["name"], predicate=is_livechat_channel, sudo=True
)
)
fields.extend(
[
Store.Attr("description", predicate=is_livechat_channel),
Store.Attr("livechat_note", predicate=is_livechat_channel),
Store.Attr("livechat_outcome", predicate=is_livechat_channel),
Store.Attr("livechat_status", predicate=is_livechat_channel),
Store.Many("livechat_expertise_ids", ["name"], predicate=is_livechat_channel),
# sudo: internal users having access to the channel can read its tags
Store.Many(
"livechat_conversation_tag_ids",
["name", "color"],
predicate=is_livechat_channel,
sudo=True,
),
],
)
return super()._to_store_defaults(target) + fields
def _to_store(self, store: Store, fields):
"""Extends the channel header by adding the livechat operator and the 'anonymous' profile"""
super()._to_store(store, [f for f in fields if f != "chatbot_current_step"])
if "chatbot_current_step" not in fields:
return
lang = self.env["chatbot.script"]._get_chatbot_language()
for channel in self.filtered(lambda channel: channel.chatbot_current_step_id):
# sudo: chatbot.script.step - returning the current script/step of the channel
current_step_sudo = channel.chatbot_current_step_id.sudo().with_context(lang=lang)
chatbot_script = current_step_sudo.chatbot_script_id
step_message = self.env["chatbot.message"]
if not current_step_sudo.is_forward_operator:
step_message = channel.sudo().chatbot_message_ids.filtered(
lambda m: m.script_step_id == current_step_sudo
and m.mail_message_id.author_id == chatbot_script.operator_partner_id
)[:1]
current_step = {
"scriptStep": current_step_sudo.id,
"message": step_message.mail_message_id.id,
"operatorFound": current_step_sudo.is_forward_operator
and channel.livechat_operator_id != chatbot_script.operator_partner_id,
}
store.add(current_step_sudo)
store.add(chatbot_script)
chatbot_data = {
"script": chatbot_script.id,
"steps": [current_step],
"currentStep": current_step,
}
store.add(channel, {"chatbot": chatbot_data})
@api.autovacuum
def _gc_empty_livechat_sessions(self):
hours = 1 # never remove empty session created within the last hour
self.env.cr.execute("""
SELECT id as id
FROM discuss_channel C
WHERE NOT EXISTS (
SELECT 1
FROM mail_message M
WHERE M.res_id = C.id AND m.model = 'discuss.channel'
) AND C.channel_type = 'livechat' AND livechat_channel_id IS NOT NULL AND
COALESCE(write_date, create_date, (now() at time zone 'UTC'))::timestamp
< ((now() at time zone 'UTC') - interval %s)""", ("%s hours" % hours,))
empty_channel_ids = [item['id'] for item in self.env.cr.dictfetchall()]
self.browse(empty_channel_ids).unlink()
@api.autovacuum
def _gc_bot_only_ongoing_sessions(self):
"""Garbage collect bot-only livechat sessions with no activity for over 1 day."""
stale_sessions = self.search([
("channel_type", "=", "livechat"),
("livechat_end_dt", "=", False),
("last_interest_dt", "<=", "-1d"),
("livechat_agent_partner_ids", "=", False),
])
stale_sessions.livechat_end_dt = fields.Datetime.now()
def execute_command_history(self, **kwargs):
self._bus_send(
"im_livechat.history_command",
{"id": self.id, "partner_id": self.env.user.partner_id.id},
)
def _get_visitor_leave_message(self, operator=False, cancel=False):
return _('Visitor left the conversation.')
def _close_livechat_session(self, **kwargs):
""" Set deactivate the livechat channel and notify (the operator) the reason of closing the session."""
self.ensure_one()
if not self.livechat_end_dt:
member = self.channel_member_ids.filtered(lambda m: m.is_self)
if member:
# sudo: discuss.channel.rtc.session - member of current user can leave call
member.sudo()._rtc_leave_call()
# sudo: discuss.channel - visitor left the conversation, state must be updated
self.sudo().livechat_end_dt = fields.Datetime.now()
Store(bus_channel=self).add(self, "livechat_end_dt").bus_send()
# avoid useless notification if the channel is empty
if not self.message_ids:
return
# Notify that the visitor has left the conversation
# sudo: mail.message - posting visitor leave message is allowed
self.sudo().message_post(
author_id=self.env.ref('base.partner_root').id,
body=Markup('<div class="o_mail_notification o_hide_author">%s</div>')
% self._get_visitor_leave_message(**kwargs),
message_type='notification',
subtype_xmlid='mail.mt_comment'
)
# Rating Mixin
def _rating_get_parent_field_name(self):
return 'livechat_channel_id'
def _email_livechat_transcript(self, email):
company = self.env.user.company_id
tz = "UTC"
# sudo: discuss.channel - access partner's/guest's timezone
for customer in self.sudo().livechat_customer_history_ids:
customer_tz = customer.partner_id.tz or customer.guest_id.timezone
if customer_tz:
tz = customer_tz
break
render_context = {
"company": company,
"channel": self,
"tz": timezone(tz),
}
mail_body = self.env['ir.qweb']._render('im_livechat.livechat_email_template', render_context, minimal_qcontext=True)
mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
mail = self.env['mail.mail'].sudo().create({
'subject': _('Conversation with %s', self.livechat_operator_id.user_livechat_username or self.livechat_operator_id.name),
'email_from': company.catchall_formatted or company.email_formatted,
'author_id': self.env.user.partner_id.id,
'email_to': email_split(email)[0],
'body_html': mail_body,
})
mail.send()
def _attachment_to_html(self, attachment):
if attachment.mimetype.startswith("image/"):
return Markup(
"<img src='%s?access_token=%s' alt='%s' style='max-width: 75%%; height: auto; padding: 5px;'>",
) % (
attachment.image_src,
attachment.generate_access_token()[0],
attachment.name,
)
file_extension = get_extension(attachment.display_name)
attachment_data = {
"id": attachment.id,
"access_token": attachment.generate_access_token()[0],
"checksum": attachment.checksum,
"extension": file_extension.lstrip("."),
"mimetype": attachment.mimetype,
"filename": attachment.display_name,
"url": attachment.url,
}
return Markup(
"<div data-embedded='file' data-oe-protected='true' contenteditable='false' data-embedded-props='%s'/>",
) % json.dumps({"fileData": attachment_data})
def _get_channel_history(self):
"""
Converting message body back to plaintext for correct data formatting in HTML field.
"""
self.ensure_one()
parts = []
previous_message_author = None
# sudo - mail.message: getting empty/notification messages to exclude them is allowed.
messages = (
self.message_ids.sudo().filtered(lambda m: m.message_type != "notification")
- self.message_ids.sudo()._filter_empty()
)
for message in messages.sorted("id"):
# sudo - res.partner: accessing livechat username or name is allowed to visitor
message_author = message.author_id.sudo() or message.author_guest_id
if previous_message_author != message_author:
parts.append(
Markup("<br/><strong>%s:</strong><br/>")
% (
(message_author.user_livechat_username if message_author._name == "res.partner" else None)
or message_author.name
),
)
if not tools.is_html_empty(message.body):
parts.append(Markup("%s<br/>") % html2plaintext(message.body))
previous_message_author = message_author
for attachment in message.attachment_ids:
previous_message_author = message_author
# sudo - ir.attachment: public user can read attachment metadata
parts.append(Markup("%s<br/>") % self._attachment_to_html(attachment.sudo()))
return Markup("").join(parts)
def _get_livechat_session_fields_to_store(self):
return [
Store.One(
"livechat_lang_id", ["name"],
predicate=is_livechat_channel,
),
]
# =======================
# Chatbot
# =======================
def _chatbot_find_customer_values_in_messages(self, step_type_to_field):
"""
Look for user's input in the channel's messages based on a dictionary
mapping the step_type to the field name of the model it will be used on.
:param dict step_type_to_field: a dict of step types to customer fields
to fill, like : {'question_email': 'email_from', 'question_phone': 'mobile'}
"""
values = {}
filtered_message_ids = self.chatbot_message_ids.filtered(
# sudo: chatbot.script.step - getting the type of the current step
lambda m: m.script_step_id.sudo().step_type in step_type_to_field
)
for message_id in filtered_message_ids:
field_name = step_type_to_field[message_id.script_step_id.step_type]
if not values.get(field_name):
values[field_name] = html2plaintext(message_id.user_raw_answer or '')
return values
def _chatbot_post_message(self, chatbot_script, body):
""" Small helper to post a message as the chatbot operator
:param record chatbot_script
:param string body: message HTML body """
# sudo: mail.message - chat bot is allowed to post a message which
# requires reading its partner among other things.
return self.with_context(mail_post_autofollow_author_skip=True).sudo().message_post(
author_id=chatbot_script.sudo().operator_partner_id.id,
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
def _chatbot_validate_email(self, email_address, chatbot_script):
email_address = html2plaintext(email_address)
email_normalized = email_normalize(email_address)
posted_message = False
error_message = False
if not email_normalized:
error_message = _(
"'%(input_email)s' does not look like a valid email. Can you please try again?",
input_email=email_address
)
posted_message = self._chatbot_post_message(chatbot_script, plaintext2html(error_message))
return {
'success': bool(email_normalized),
'posted_message': posted_message,
'error_message': error_message,
}
def _add_members(
self,
*,
guests=None,
partners=None,
users=None,
create_member_params=None,
invite_to_rtc_call=False,
post_joined_message=True,
inviting_partner=None,
):
all_new_members = super()._add_members(
guests=guests,
partners=partners,
users=users,
create_member_params=create_member_params,
invite_to_rtc_call=invite_to_rtc_call,
post_joined_message=post_joined_message,
inviting_partner=inviting_partner,
)
for channel in all_new_members.channel_id:
# sudo: discuss.channel - accessing livechat_status in internal code is acceptable
if channel.sudo().livechat_status == "need_help":
# sudo: discuss.channel - writing livechat_status when a new operator joins is acceptable
channel.sudo().livechat_status = "in_progress"
return all_new_members
def _message_post_after_hook(self, message, msg_vals):
"""
This method is called just before _notify_thread() method which is calling the _to_store()
method. We need a 'chatbot.message' record before it happens to correctly display the message.
It's created only if the mail channel is linked to a chatbot step. We also need to save the
user answer if the current step is a question selection.
"""
if self.chatbot_current_step_id and not self.livechat_agent_history_ids:
selected_answer = (
self.env["chatbot.script.answer"]
.browse(self.env.context.get("selected_answer_id"))
.exists()
)
if selected_answer and selected_answer in self.chatbot_current_step_id.answer_ids:
# sudo - chatbot.message: finding the question message to update the user answer is allowed.
question_msg = (
self.env["chatbot.message"]
.sudo()
.search(
[
("discuss_channel_id", "=", self.id),
("script_step_id", "=", self.chatbot_current_step_id.id),
],
order="id DESC",
limit=1,
)
)
question_msg.user_script_answer_id = selected_answer
question_msg.user_raw_script_answer_id = selected_answer.id
if store := self.env.context.get("message_post_store"):
store.add(message).add(question_msg.mail_message_id)
partner, guest = self.env["res.partner"]._get_current_persona()
Store(bus_channel=partner or guest).add_model_values(
"ChatbotStep",
{
"id": (self.chatbot_current_step_id.id, question_msg.mail_message_id.id),
"scriptStep": self.chatbot_current_step_id.id,
"message": question_msg.mail_message_id.id,
"selectedAnswer": selected_answer.id,
},
).bus_send()
self.env["chatbot.message"].sudo().create(
{
"mail_message_id": message.id,
"discuss_channel_id": self.id,
"script_step_id": self.chatbot_current_step_id.id,
}
)
author_history = self.env["im_livechat.channel.member.history"]
# sudo - discuss.channel: accessing history to update its state is acceptable
if message.author_id or message.author_guest_id:
author_history = self.sudo().livechat_channel_member_history_ids.filtered(
lambda h: h.partner_id == message.author_id
if message.author_id
else h.guest_id == message.author_guest_id
)
if author_history:
if message.message_type not in ("notification", "user_notification"):
author_history.message_count += 1
if author_history.livechat_member_type == "agent" and not author_history.response_time_hour:
author_history.response_time_hour = (
fields.Datetime.now() - author_history.create_date
).total_seconds() / 3600
if not self.livechat_end_dt and author_history.livechat_member_type == "agent":
self.livechat_failure = "no_failure"
# sudo: discuss.channel - accessing livechat_status in internal code is acceptable
if (
not self.livechat_end_dt
and self.sudo().livechat_status == "waiting"
and author_history.livechat_member_type == "visitor"
):
# sudo: discuss.channel - writing livechat_status when a message is posted is acceptable
self.sudo().livechat_status = "in_progress"
return super()._message_post_after_hook(message, msg_vals)
def _chatbot_restart(self, chatbot_script):
# sudo: discuss.channel - visitor can clear current step to restart the script
self.sudo().chatbot_current_step_id = False
# sudo: discuss.channel - visitor can reactivate livechat
self.sudo().livechat_end_dt = False
# sudo: chatbot.message - visitor can clear chatbot messages to restart the script
self.sudo().chatbot_message_ids.unlink()
return self._chatbot_post_message(
chatbot_script,
Markup('<div class="o_mail_notification">%s</div>') % _('Restarting conversation...'),
)
def _get_allowed_channel_member_create_params(self):
return super()._get_allowed_channel_member_create_params() + [
"chatbot_script_id",
"livechat_member_type",
]
def _types_allowing_seen_infos(self):
return super()._types_allowing_seen_infos() + ["livechat"]
def _types_allowing_unfollow(self):
return super()._types_allowing_unfollow() + ["livechat"]
def _action_unfollow(self, partner=None, guest=None, post_leave_message=True):
super()._action_unfollow(partner, guest, post_leave_message)
# sudo - discuss.channel: user just left but we need to close the live
# chat if the last operator left.
channel_sudo = self.sudo()
if (
channel_sudo.channel_type == "livechat"
and not channel_sudo.livechat_end_dt
and channel_sudo.member_count == 1
):
# sudo: discuss.channel - last operator left the conversation, state must be updated.
channel_sudo.livechat_end_dt = fields.Datetime.now()
Store(bus_channel=self).add(channel_sudo, "livechat_end_dt").bus_send()
def livechat_join_channel_needing_help(self):
"""Join a live chat for which help was requested.
:returns: Whether the live chat was joined. False if the live chat could not
be joined because another agent already joined the channel in the meantime.
:rtype: bool
"""
self.ensure_one()
if self.livechat_status != "need_help":
return False
self._add_members(users=self.env.user)
return True
def _forward_human_operator(self, chatbot_script_step=None, users=None):
""" Add a human operator to the conversation. The conversation with the chatbot (scripted chatbot or ai agent) is stopped
the visitor will continue the conversation with a real person.
In case we don't find any operator (e.g: no-one is available) we don't post any messages.
The chat with the chatbot will continue normally, which allows to add extra steps when it's the case
(e.g: ask for the visitor's email and create a lead).
:param chatbot_script_step: the forward to operator chatbot script step if the forwarding is done through
a scripted chatbot (not used if the forwarding is done through an AI Agent).
:param users: recordset of candidate operators, if not provided the currently available
users of the livechat channel are used as candidates instead.
"""
human_operator = False
posted_message = self.env['mail.message']
if chatbot_script_step is None:
chatbot_script_step = self.env['chatbot.script.step']
if self.livechat_channel_id:
human_operator = self._get_human_operator(users, chatbot_script_step)
# handle edge case where we found yourself as available operator -> don't do anything
# it will act as if no-one is available (which is fine)
if human_operator and human_operator != self.env.user:
# first post the message of the step (if we have one)
posted_message = self._post_current_chatbot_step_message(chatbot_script_step)
# sudo - discuss.channel: let the chat bot proceed to the forward step (change channel operator, add human operator
# as member, remove bot from channel, rename channel and finally broadcast the channel to the new operator).
channel_sudo = self.sudo()
bot_partner_id = channel_sudo.channel_member_ids.filtered(lambda m: m.livechat_member_type == "bot").partner_id
# next, add the human_operator to the channel and post a "Operator invited to the channel" notification
create_member_params = {'livechat_member_type': 'agent'}
if chatbot_script_step.operator_expertise_ids:
create_member_params['agent_expertise_ids'] = chatbot_script_step.operator_expertise_ids.ids
channel_sudo.livechat_expertise_ids |= chatbot_script_step.operator_expertise_ids
channel_sudo._add_new_members_to_channel(
create_member_params=create_member_params,
inviting_partner=bot_partner_id,
users=human_operator,
)
channel_sudo._action_unfollow(partner=bot_partner_id, post_leave_message=False)
# finally, rename the channel to include the operator's name
channel_sudo._update_forwarded_channel_data(
livechat_failure="no_answer",
livechat_operator_id=human_operator.partner_id,
operator_name=human_operator.livechat_username if human_operator.livechat_username else human_operator.name,
)
channel_sudo._add_next_step_message_to_store(chatbot_script_step)
channel_sudo._broadcast(human_operator.partner_id.ids)
self.channel_pin(pinned=True)
else:
# sudo: discuss.channel - visitor tried getting operator, outcome must be updated
self.sudo().livechat_failure = "no_agent"
return posted_message
def _get_human_operator(self, users, chatbot_script_step):
operator_params = {
'lang': self.env.context.get("lang"),
'country_id': self.country_id.id,
'users': users
}
if chatbot_script_step:
operator_params['expertises'] = chatbot_script_step.operator_expertise_ids
# sudo: res.users - visitor can access operator of their channel
human_operator = self.livechat_channel_id.sudo()._get_operator(**operator_params)
return human_operator
def _post_current_chatbot_step_message(self, chatbot_script_step):
posted_message = self.env['mail.message']
if chatbot_script_step and chatbot_script_step.message:
posted_message = self._chatbot_post_message(chatbot_script_step.chatbot_script_id, chatbot_script_step.message)
return posted_message
def _add_new_members_to_channel(self, create_member_params, inviting_partner, users=None, partners=None):
member_params = {
'create_member_params': create_member_params,
'inviting_partner': inviting_partner
}
if users:
member_params['users'] = users
if partners:
member_params['partners'] = partners
self._add_members(**member_params)
def _update_forwarded_channel_data(self, /, *, livechat_failure, livechat_operator_id, operator_name):
self.write(
{
"livechat_failure": livechat_failure,
"livechat_operator_id": livechat_operator_id,
"name": " ".join(
[
self.env.user.display_name
if not self.env.user._is_public()
else self.sudo().self_member_id.guest_id.name,
operator_name
]
)
}
)
def _add_next_step_message_to_store(self, chatbot_script_step):
if chatbot_script_step:
step_message = next((
# sudo - chatbot.message.id: visitor can access chat bot messages.
m.mail_message_id for m in self.sudo().chatbot_message_ids.sorted("id")
if m.script_step_id == chatbot_script_step
and m.mail_message_id.author_id == chatbot_script_step.chatbot_script_id.operator_partner_id
), self.env["mail.message"])
Store(bus_channel=self).add_model_values(
"ChatbotStep",
{
"id": (chatbot_script_step.id, step_message.id),
"scriptStep": chatbot_script_step.id,
"message": step_message.id,
"operatorFound": True,
},
).bus_send()

View file

@ -0,0 +1,196 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import api, models, fields
from odoo.fields import Domain
from odoo.addons.mail.tools.discuss import Store
class DiscussChannelMember(models.Model):
_inherit = 'discuss.channel.member'
livechat_member_history_ids = fields.One2many("im_livechat.channel.member.history", "member_id")
livechat_member_type = fields.Selection(
[("agent", "Agent"), ("visitor", "Visitor"), ("bot", "Chatbot")],
compute="_compute_livechat_member_type",
# sudo - reading the history of a member the user has access to is acceptable.
compute_sudo=True,
inverse="_inverse_livechat_member_type",
)
chatbot_script_id = fields.Many2one(
"chatbot.script",
compute="_compute_chatbot_script_id",
inverse="_inverse_chatbot_script_id",
compute_sudo=True,
)
agent_expertise_ids = fields.Many2many(
"im_livechat.expertise",
compute="_compute_agent_expertise_ids",
# sudo - reading the history of a member the user has access to is acceptable.
compute_sudo=True,
inverse="_inverse_agent_expertise_ids",
)
@api.model_create_multi
def create(self, vals_list):
members = super().create(vals_list)
guest = self.env["mail.guest"]._get_guest_from_context()
for member in members.filtered(
lambda m: m.channel_id.channel_type == "livechat" and not m.livechat_member_type
):
# After login, the guest cookie is still available, allowing us to
# reconcile the user with their previous guest member.
if (
guest
and member.is_self
and guest in member.channel_id.livechat_customer_guest_ids
):
# sudo - discuss.channel.member: setting livechat member type
# after member creation is allowed.
member.sudo().livechat_member_type = "visitor"
continue
member.sudo().livechat_member_type = "agent"
return members
@api.depends("livechat_member_history_ids.livechat_member_type")
def _compute_livechat_member_type(self):
for member in self:
member.livechat_member_type = member.livechat_member_history_ids.livechat_member_type
@api.depends("livechat_member_history_ids.chatbot_script_id")
def _compute_chatbot_script_id(self):
for member in self:
member.chatbot_script_id = member.livechat_member_history_ids.chatbot_script_id
@api.depends("livechat_member_history_ids.agent_expertise_ids")
def _compute_agent_expertise_ids(self):
for member in self:
member.agent_expertise_ids = member.livechat_member_history_ids.agent_expertise_ids
def _create_or_update_history(self, values_by_member):
members_without_history = self.filtered(lambda m: not m.livechat_member_history_ids)
history_domain = Domain.OR(
[
[
("channel_id", "=", member.channel_id.id),
("partner_id", "=", member.partner_id.id)
if member.partner_id
else ("guest_id", "=", member.guest_id.id),
]
for member in members_without_history
]
)
history_by_channel_persona = {}
for history in self.env["im_livechat.channel.member.history"].search_fetch(
history_domain, ["channel_id", "guest_id", "member_id", "partner_id"]
):
persona = history.partner_id or history.guest_id
history_by_channel_persona[history.channel_id, persona] = history
to_create = members_without_history.filtered(
lambda m: (m.channel_id, m.partner_id or m.guest_id) not in history_by_channel_persona
)
self.env["im_livechat.channel.member.history"].create(
[{"member_id": member.id, **values_by_member[member]} for member in to_create]
)
for member in self - to_create:
persona = member.partner_id or member.guest_id
history = (
member.livechat_member_history_ids
or history_by_channel_persona[member.channel_id, persona]
)
if history.member_id != member:
values_by_member[member]["member_id"] = member.id
if member in values_by_member:
history.write(values_by_member[member])
def _inverse_livechat_member_type(self):
# sudo - im_livechat.channel.member: creating/updating history following
# "livechat_member_type" modification is acceptable.
self.sudo()._create_or_update_history(
{member: {"livechat_member_type": member.livechat_member_type} for member in self},
)
def _inverse_chatbot_script_id(self):
# sudo - im_livechat.channel.member: creating/updating history following
# "chatbot_script_id" modification is acceptable.
self.sudo()._create_or_update_history(
{member: {"chatbot_script_id": member.chatbot_script_id.id} for member in self}
)
def _inverse_agent_expertise_ids(self):
# sudo - im_livechat.channel.member.history: creating/udpating history following
# "agent_expetise_ids" modification is acceptable.
self.sudo()._create_or_update_history(
{member: {"agent_expertise_ids": member.agent_expertise_ids.ids} for member in self}
)
@api.autovacuum
def _gc_unpin_livechat_sessions(self):
""" Unpin read livechat sessions with no activity for at least one day to
clean the operator's interface """
members = self.env['discuss.channel.member'].search([
('is_pinned', '=', True),
('last_seen_dt', '<=', datetime.now() - timedelta(days=1)),
('channel_id.channel_type', '=', 'livechat'),
])
sessions_to_be_unpinned = members.filtered(lambda m: m.message_unread_counter == 0)
sessions_to_be_unpinned.write({'unpin_dt': fields.Datetime.now()})
sessions_to_be_unpinned.channel_id.livechat_end_dt = fields.Datetime.now()
for member in sessions_to_be_unpinned:
Store(bus_channel=member._bus_channel()).add(
member.channel_id,
{"close_chat_window": True, "livechat_end_dt": fields.Datetime.now()},
).bus_send()
def _to_store_defaults(self, target):
return super()._to_store_defaults(target) + [
Store.Attr(
"livechat_member_type",
predicate=lambda member: member.channel_id.channel_type == "livechat",
)
]
def _get_store_partner_fields(self, fields):
self.ensure_one()
if self.channel_id.channel_type == 'livechat':
new_fields = [
"active",
"avatar_128",
Store.One("country_id", ["code", "name"]),
"im_status",
"is_public",
*self.env["res.partner"]._get_store_livechat_username_fields(),
]
if self.livechat_member_type == "visitor":
new_fields += ["offline_since", "email"]
return new_fields
return super()._get_store_partner_fields(fields)
def _get_store_guest_fields(self, fields):
self.ensure_one()
if self.channel_id.channel_type == 'livechat':
return [
"avatar_128",
Store.One("country_id", ["code", "name"]),
"im_status",
"name",
"offline_since",
]
return super()._get_store_guest_fields(fields)
def _get_rtc_invite_members_domain(self, *a, **kw):
domain = super()._get_rtc_invite_members_domain(*a, **kw)
if self.channel_id.channel_type == "livechat":
domain &= Domain("partner_id", "not in", self._get_excluded_rtc_members_partner_ids())
return domain
def _get_excluded_rtc_members_partner_ids(self):
chatbot = self.channel_id.chatbot_current_step_id.chatbot_script_id
excluded_partner_ids = [chatbot.operator_partner_id.id] if chatbot else []
return excluded_partner_ids
def _get_html_link_title(self):
if self.channel_id.channel_type == "livechat" and self.partner_id.user_livechat_username:
return self.partner_id.user_livechat_username
return super()._get_html_link_title()

View file

@ -0,0 +1,21 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class DiscussChannelRtcSession(models.Model):
_inherit = "discuss.channel.rtc.session"
@api.model_create_multi
def create(self, vals_list):
rtc_sessions = super().create(vals_list)
for livechat_session in rtc_sessions.filtered(
lambda s: s.channel_member_id.livechat_member_type in ("agent", "visitor")
):
call_history = livechat_session.channel_id.call_history_ids.sorted(
lambda c: (c.create_date, c.id)
)[-1]
call_history.livechat_participant_history_ids |= (
livechat_session.channel_member_id.livechat_member_history_ids
)
return rtc_sessions

View file

@ -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"),
]

View file

@ -0,0 +1,198 @@
from odoo import api, models, fields
from odoo.exceptions import ValidationError
class ImLivechatChannelMemberHistory(models.Model):
_name = "im_livechat.channel.member.history"
_description = "Keep the channel member history"
_rec_names_search = ["partner_id", "guest_id"]
member_id = fields.Many2one("discuss.channel.member", index="btree_not_null")
livechat_member_type = fields.Selection(
[("agent", "Agent"), ("visitor", "Visitor"), ("bot", "Chatbot")],
compute="_compute_member_fields",
store=True,
)
channel_id = fields.Many2one(
"discuss.channel",
compute="_compute_member_fields",
index=True,
ondelete="cascade",
store=True,
)
guest_id = fields.Many2one(
"mail.guest", compute="_compute_member_fields", index="btree_not_null", store=True
)
partner_id = fields.Many2one(
"res.partner", compute="_compute_member_fields", index="btree_not_null", store=True
)
chatbot_script_id = fields.Many2one(
"chatbot.script", compute="_compute_member_fields", index="btree_not_null", store=True
)
agent_expertise_ids = fields.Many2many(
"im_livechat.expertise", compute="_compute_member_fields", store=True
)
conversation_tag_ids = fields.Many2many(
"im_livechat.conversation.tag",
"im_livechat_channel_member_history_conversation_tag_rel",
related="channel_id.livechat_conversation_tag_ids",
)
avatar_128 = fields.Binary(compute="_compute_avatar_128")
# REPORTING FIELDS
session_country_id = fields.Many2one("res.country", related="channel_id.country_id")
session_livechat_channel_id = fields.Many2one(
"im_livechat.channel", "Live chat channel", related="channel_id.livechat_channel_id"
)
session_outcome = fields.Selection(related="channel_id.livechat_outcome")
session_start_hour = fields.Float(related="channel_id.livechat_start_hour")
session_week_day = fields.Selection(related="channel_id.livechat_week_day")
session_duration_hour = fields.Float(
"Session Duration",
help="Time spent by the persona in the session in hours",
compute="_compute_session_duration_hour",
aggregator="avg",
store=True,
)
rating_id = fields.Many2one("rating.rating", compute="_compute_rating_id", store=True)
rating = fields.Float(related="rating_id.rating")
rating_text = fields.Selection(string="Rating text", related="rating_id.rating_text")
call_history_ids = fields.Many2many("discuss.call.history")
has_call = fields.Float(compute="_compute_has_call", store=True)
call_count = fields.Float("# of Sessions with Calls", related="has_call", aggregator="sum")
call_percentage = fields.Float("Session with Calls (%)", related="has_call", aggregator="avg")
call_duration_hour = fields.Float(
"Call Duration", compute="_compute_call_duration_hour", aggregator="sum", store=True
)
message_count = fields.Integer("# of Messages per Session", aggregator="avg")
help_status = fields.Selection(
selection=[
("requested", "Help Requested"),
("provided", "Help Provided"),
],
compute="_compute_help_status",
store=True,
)
response_time_hour = fields.Float("Response Time", aggregator="avg")
_member_id_unique = models.Constraint(
"UNIQUE(member_id)", "Members can only be linked to one history"
)
_channel_id_partner_id_unique = models.UniqueIndex(
"(channel_id, partner_id) WHERE partner_id IS NOT NULL",
"One partner can only be linked to one history on a channel",
)
_channel_id_guest_id_unique = models.UniqueIndex(
"(channel_id, guest_id) WHERE guest_id IS NOT NULL",
"One guest can only be linked to one history on a channel",
)
_partner_id_or_guest_id_constraint = models.Constraint(
"CHECK(partner_id IS NULL OR guest_id IS NULL)",
"History should either be linked to a partner or a guest but not both",
)
@api.constrains("channel_id")
def _constraint_channel_id(self):
# sudo: im_livechat.channel.member.history - skipping ACL for
# constraint, more performant and no sensitive information is leaked.
if failing_histories := self.sudo().filtered(
lambda h: h.channel_id.channel_type != "livechat"
):
raise ValidationError(
self.env._(
"Cannot create history as it is only available for live chats: %(histories)s.",
histories=failing_histories.member_id.mapped("display_name")
)
)
@api.depends("member_id")
def _compute_member_fields(self):
for history in self:
history.channel_id = history.channel_id or history.member_id.channel_id
history.guest_id = history.guest_id or history.member_id.guest_id
history.partner_id = history.partner_id or history.member_id.partner_id
history.livechat_member_type = (
history.livechat_member_type or history.member_id.livechat_member_type
)
history.chatbot_script_id = history.chatbot_script_id or history.member_id.chatbot_script_id
history.agent_expertise_ids = (
history.agent_expertise_ids or history.member_id.agent_expertise_ids
)
@api.depends("livechat_member_type", "partner_id.name", "partner_id.display_name", "guest_id.name")
def _compute_display_name(self):
for history in self:
name = history.partner_id.name or history.guest_id.name
if history.partner_id and history.livechat_member_type == "visitor":
name = history.partner_id.display_name
history.display_name = name or self.env._("Unknown")
@api.depends("partner_id.avatar_128", "guest_id.avatar_128")
def _compute_avatar_128(self):
for history in self:
history.avatar_128 = history.partner_id.avatar_128 or history.guest_id.avatar_128
# ===================================================================
# REPORTING
# ===================================================================
@api.depends("call_history_ids")
def _compute_has_call(self):
for history in self:
history.has_call = 1 if history.call_history_ids else 0
@api.depends("call_history_ids.duration_hour")
def _compute_call_duration_hour(self):
for history in self:
history.call_duration_hour = sum(history.call_history_ids.mapped("duration_hour"))
@api.depends(
"channel_id.livechat_agent_requesting_help_history",
"channel_id.livechat_agent_providing_help_history",
)
def _compute_help_status(self):
agent_histories = self.filtered(lambda h: h.livechat_member_type == "agent")
(self - agent_histories).help_status = None
for history in agent_histories:
if history.channel_id.livechat_agent_requesting_help_history == history:
history.help_status = "requested"
elif history.channel_id.livechat_agent_providing_help_history == history:
history.help_status = "provided"
@api.depends("channel_id.rating_ids")
def _compute_rating_id(self):
agent_histories = self.filtered(lambda h: h.livechat_member_type in ("agent", "bot"))
(self - agent_histories).rating_id = None
for history in agent_histories:
history.rating_id = history.channel_id.rating_ids.filtered(
lambda r: r.rated_partner_id == history.partner_id
)[:1] # Live chats only allow one rating.
@api.depends("create_date", "channel_id.livechat_end_dt", "channel_id.message_ids")
def _compute_session_duration_hour(self):
ongoing_chats = self.channel_id.filtered(lambda c: not c.livechat_end_dt)
last_msg_dt_by_channel_id = {
message.res_id: message.create_date for message in ongoing_chats._get_last_messages()
}
for history in self:
end = history.channel_id.livechat_end_dt or last_msg_dt_by_channel_id.get(
history.channel_id.id, fields.Datetime.now()
)
history.session_duration_hour = (end - history.create_date).total_seconds() / 3600
@api.model
def action_open_discuss_channel_view(self, domain=()):
discuss_channels = self.search_fetch(domain, ["channel_id"]).channel_id
action = self.env["ir.actions.act_window"]._for_xml_id("im_livechat.discuss_channel_action")
if len(discuss_channels) == 1:
action["res_id"] = discuss_channels.id
action["view_mode"] = "form"
action["views"] = [view for view in action["views"] if view[1] == "form"]
return action
action["context"] = {}
action["domain"] = [("id", "in", discuss_channels.ids)]
action["mobile_view_mode"] = "list"
action["view_mode"] = "list"
action["views"] = [view for view in action["views"] if view[1] in ("list", "form")]
return action

View file

@ -0,0 +1,35 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from random import randint
from odoo import fields, models, api, Command
class Im_LivechatConversationTag(models.Model):
"""Tags for Live Chat conversations."""
_name = "im_livechat.conversation.tag"
_description = "Live Chat Conversation Tags"
_order = "name"
@api.model
def _get_default_color(self):
return randint(1, 11)
name = fields.Char("Name", required=True)
color = fields.Integer("Color", default=_get_default_color)
conversation_ids = fields.Many2many(
"discuss.channel",
"livechat_conversation_tag_rel",
string="Discuss Channels",
)
_name_unique = models.UniqueIndex("(name)")
@api.ondelete(at_uninstall=False)
def _unlink_sync_conversation(self):
# For triggering the _sync_field_names before being unlinked
# sudo: users who can delete tags can remove them from conversations in cascade
self.sudo().conversation_ids.livechat_conversation_tag_ids = [
Command.unlink(tag.id) for tag in self
]

View file

@ -0,0 +1,51 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import Command, fields, models
class Im_LivechatExpertise(models.Model):
"""Expertise of Live Chat users."""
_name = "im_livechat.expertise"
_description = "Live Chat Expertise"
_order = "name"
name = fields.Char("Name", required=True, translate=True)
user_ids = fields.Many2many(
"res.users",
string="Operators",
compute="_compute_user_ids",
inverse="_inverse_user_ids",
store=False,
)
_name_unique = models.UniqueIndex("(name)")
def _compute_user_ids(self):
users_by_expertise = self._get_users_by_expertise()
for expertise in self:
expertise.user_ids = users_by_expertise[expertise]
def _inverse_user_ids(self):
users_by_expertise = self._get_users_by_expertise()
for expertise in self:
for user in expertise.user_ids - users_by_expertise[expertise]:
# sudo: res.users: livechat manager can add expertise on users
user.sudo().livechat_expertise_ids = [Command.link(expertise.id)]
for user in users_by_expertise[expertise] - expertise.user_ids:
# sudo: res.users: livechat manager can remove expertise on users
user.sudo().livechat_expertise_ids = [Command.unlink(expertise.id)]
def _get_users_by_expertise(self):
users_by_expertise = defaultdict(lambda: self.env["res.users"])
settings_domain = [("livechat_expertise_ids", "in", self.ids)]
# sudo: res.users.settings: livechat manager can read expertise on users
user_settings = self.env["res.users.settings"].sudo().search(settings_domain)
for user_setting in user_settings:
for expertise in user_setting.livechat_expertise_ids:
users_by_expertise[expertise] |= user_setting.user_id
for expertise, users in users_by_expertise.items():
users_by_expertise[expertise] = users.with_prefetch(user_settings.user_id.ids)
return users_by_expertise

View file

@ -0,0 +1,21 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrWebsocket(models.AbstractModel):
_inherit = "ir.websocket"
def _build_bus_channel_list(self, channels):
channels = list(channels) # do not alter original list
if any(
channel == "im_livechat.looking_for_help"
for channel in channels
if isinstance(channel, str)
):
if self.env.user.has_group("im_livechat.im_livechat_group_user"):
channels.append(
(self.env.ref("im_livechat.im_livechat_group_user"), "LOOKING_FOR_HELP")
)
channels.remove("im_livechat.looking_for_help")
return super()._build_bus_channel_list(channels)

View file

@ -1,235 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools import email_normalize, email_split, html_escape, html2plaintext, plaintext2html
from markupsafe import Markup
class MailChannel(models.Model):
""" Chat Session
Reprensenting a conversation between users.
It extends the base method for anonymous usage.
"""
_name = 'mail.channel'
_inherit = ['mail.channel', 'rating.mixin']
anonymous_name = fields.Char('Anonymous Name')
channel_type = fields.Selection(selection_add=[('livechat', 'Livechat Conversation')], ondelete={'livechat': 'cascade'})
livechat_active = fields.Boolean('Is livechat ongoing?', help='Livechat session is active until visitor leaves the conversation.')
livechat_channel_id = fields.Many2one('im_livechat.channel', 'Channel', index='btree_not_null')
livechat_operator_id = fields.Many2one('res.partner', string='Operator')
chatbot_current_step_id = fields.Many2one('chatbot.script.step', string='Chatbot Current Step')
chatbot_message_ids = fields.One2many('chatbot.message', 'mail_channel_id', string='Chatbot Messages')
country_id = fields.Many2one('res.country', string="Country", help="Country of the visitor of the channel")
_sql_constraints = [('livechat_operator_id', "CHECK((channel_type = 'livechat' and livechat_operator_id is not null) or (channel_type != 'livechat'))",
'Livechat Operator ID is required for a channel of type livechat.')]
def _compute_is_chat(self):
super(MailChannel, self)._compute_is_chat()
for record in self:
if record.channel_type == 'livechat':
record.is_chat = True
def _channel_message_notifications(self, message, message_format=False):
""" When a anonymous user create a mail.channel, the operator is not notify (to avoid massive polling when
clicking on livechat button). So when the anonymous person is sending its FIRST message, the channel header
should be added to the notification, since the user cannot be listining to the channel.
"""
notifications = super()._channel_message_notifications(message=message, message_format=message_format)
for channel in self:
# add uuid to allow anonymous to listen
if channel.channel_type == 'livechat':
notifications.append([channel.uuid, 'mail.channel/new_message', notifications[0][2]])
if not message.author_id:
unpinned_members = self.channel_member_ids.filtered(lambda member: not member.is_pinned)
if unpinned_members:
unpinned_members.write({'is_pinned': True})
notifications = self._channel_channel_notifications(unpinned_members.partner_id.ids) + notifications
return notifications
def channel_info(self):
""" Extends the channel header by adding the livechat operator and the 'anonymous' profile
:rtype : list(dict)
"""
channel_infos = super().channel_info()
channel_infos_dict = dict((c['id'], c) for c in channel_infos)
for channel in self:
channel_infos_dict[channel.id]['channel']['anonymous_name'] = channel.anonymous_name
channel_infos_dict[channel.id]['channel']['anonymous_country'] = {
'code': channel.country_id.code,
'id': channel.country_id.id,
'name': channel.country_id.name,
} if channel.country_id else [('clear',)]
if channel.livechat_operator_id:
display_name = channel.livechat_operator_id.user_livechat_username or channel.livechat_operator_id.display_name
channel_infos_dict[channel.id]['operator_pid'] = (channel.livechat_operator_id.id, display_name.replace(',', ''))
return list(channel_infos_dict.values())
@api.autovacuum
def _gc_empty_livechat_sessions(self):
hours = 1 # never remove empty session created within the last hour
self.env.cr.execute("""
SELECT id as id
FROM mail_channel C
WHERE NOT EXISTS (
SELECT 1
FROM mail_message M
WHERE M.res_id = C.id AND m.model = 'mail.channel'
) AND C.channel_type = 'livechat' AND livechat_channel_id IS NOT NULL AND
COALESCE(write_date, create_date, (now() at time zone 'UTC'))::timestamp
< ((now() at time zone 'UTC') - interval %s)""", ("%s hours" % hours,))
empty_channel_ids = [item['id'] for item in self.env.cr.dictfetchall()]
self.browse(empty_channel_ids).unlink()
def _execute_command_help_message_extra(self):
msg = super(MailChannel, self)._execute_command_help_message_extra()
return msg + _("Type <b>:shortcut</b> to insert a canned response in your message.<br>")
def execute_command_history(self, **kwargs):
self.env['bus.bus']._sendone(self.uuid, 'im_livechat.history_command', {'id': self.id})
def _send_history_message(self, pid, page_history):
message_body = _('No history found')
if page_history:
html_links = ['<li><a href="%s" target="_blank">%s</a></li>' % (html_escape(page), html_escape(page)) for page in page_history]
message_body = '<ul>%s</ul>' % (''.join(html_links))
self._send_transient_message(self.env['res.partner'].browse(pid), message_body)
def _message_update_content_after_hook(self, message):
self.ensure_one()
if self.channel_type == 'livechat':
self.env['bus.bus']._sendone(self.uuid, 'mail.message/insert', {
'id': message.id,
'body': message.body,
})
return super()._message_update_content_after_hook(message=message)
def _get_visitor_leave_message(self, operator=False, cancel=False):
return _('Visitor has left the conversation.')
def _close_livechat_session(self, **kwargs):
""" Set deactivate the livechat channel and notify (the operator) the reason of closing the session."""
self.ensure_one()
if self.livechat_active:
self.livechat_active = False
# avoid useless notification if the channel is empty
if not self.message_ids:
return
# Notify that the visitor has left the conversation
self.message_post(author_id=self.env.ref('base.partner_root').id,
body=self._get_visitor_leave_message(**kwargs), message_type='comment', subtype_xmlid='mail.mt_comment')
# Rating Mixin
def _rating_get_parent_field_name(self):
return 'livechat_channel_id'
def _email_livechat_transcript(self, email):
company = self.env.user.company_id
render_context = {
"company": company,
"channel": self,
}
mail_body = self.env['ir.qweb']._render('im_livechat.livechat_email_template', render_context, minimal_qcontext=True)
mail_body = self.env['mail.render.mixin']._replace_local_links(mail_body)
mail = self.env['mail.mail'].sudo().create({
'subject': _('Conversation with %s', self.livechat_operator_id.user_livechat_username or self.livechat_operator_id.name),
'email_from': company.catchall_formatted or company.email_formatted,
'author_id': self.env.user.partner_id.id,
'email_to': email_split(email)[0],
'body_html': mail_body,
})
mail.send()
def _get_channel_history(self):
"""
Converting message body back to plaintext for correct data formatting in HTML field.
"""
return Markup('').join(
Markup('%s: %s<br/>') % (message.author_id.name or self.anonymous_name, html2plaintext(message.body))
for message in self.message_ids.sorted('id')
)
# =======================
# Chatbot
# =======================
def _chatbot_find_customer_values_in_messages(self, step_type_to_field):
"""
Look for user's input in the channel's messages based on a dictionary
mapping the step_type to the field name of the model it will be used on.
:param dict step_type_to_field: a dict of step types to customer fields
to fill, like : {'question_email': 'email_from', 'question_phone': 'mobile'}
"""
values = {}
filtered_message_ids = self.chatbot_message_ids.filtered(
lambda m: m.script_step_id.step_type in step_type_to_field.keys()
)
for message_id in filtered_message_ids:
field_name = step_type_to_field[message_id.script_step_id.step_type]
if not values.get(field_name):
values[field_name] = html2plaintext(message_id.user_raw_answer or '')
return values
def _chatbot_post_message(self, chatbot_script, body):
""" Small helper to post a message as the chatbot operator
:param record chatbot_script
:param string body: message HTML body """
return self.with_context(mail_create_nosubscribe=True).message_post(
author_id=chatbot_script.operator_partner_id.id,
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
def _chatbot_validate_email(self, email_address, chatbot_script):
email_address = html2plaintext(email_address)
email_normalized = email_normalize(email_address)
posted_message = False
error_message = False
if not email_normalized:
error_message = _(
"'%(input_email)s' does not look like a valid email. Can you please try again?",
input_email=email_address
)
posted_message = self._chatbot_post_message(chatbot_script, plaintext2html(error_message))
return {
'success': bool(email_normalized),
'posted_message': posted_message,
'error_message': error_message,
}
def _message_post_after_hook(self, message, msg_vals):
"""
This method is called just before _notify_thread() method which is calling the _message_format()
method. We need a 'chatbot.message' record before it happens to correctly display the message.
It's created only if the mail channel is linked to a chatbot step.
"""
if self.chatbot_current_step_id:
self.env['chatbot.message'].sudo().create({
'mail_message_id': message.id,
'mail_channel_id': self.id,
'script_step_id': self.chatbot_current_step_id.id,
})
return super(MailChannel, self)._message_post_after_hook(message, msg_vals)
def _chatbot_restart(self, chatbot_script):
self.write({
'chatbot_current_step_id': False
})
self.chatbot_message_ids.unlink()
return self._chatbot_post_message(
chatbot_script,
'<div class="o_mail_notification">%s</div>' % _('Restarting conversation...'))

View file

@ -1,42 +0,0 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from odoo import api, models
class ChannelMember(models.Model):
_inherit = 'mail.channel.member'
@api.autovacuum
def _gc_unpin_livechat_sessions(self):
""" Unpin read livechat sessions with no activity for at least one day to
clean the operator's interface """
members = self.env['mail.channel.member'].search([
('is_pinned', '=', True),
('last_seen_dt', '<=', datetime.now() - timedelta(days=1)),
('channel_id.channel_type', '=', 'livechat'),
])
sessions_to_be_unpinned = members.filtered(lambda m: m.message_unread_counter == 0)
sessions_to_be_unpinned.write({'is_pinned': False})
self.env['bus.bus']._sendmany([(member.partner_id, 'mail.channel/unpin', {'id': member.channel_id.id}) for member in sessions_to_be_unpinned])
def _get_partner_data(self, fields=None):
if self.channel_id.channel_type == 'livechat':
data = {
'active': self.partner_id.active,
'id': self.partner_id.id,
'is_public': self.partner_id.is_public,
}
if self.partner_id.user_livechat_username:
data['user_livechat_username'] = self.partner_id.user_livechat_username
else:
data['name'] = self.partner_id.name
if not self.partner_id.is_public:
data['country'] = {
'code': self.partner_id.country_id.code,
'id': self.partner_id.country_id.id,
'name': self.partner_id.country_id.name,
} if self.partner_id.country_id else [('clear',)]
return data
return super()._get_partner_data(fields=fields)

View file

@ -1,47 +1,77 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.addons.mail.tools.discuss import Store
class MailMessage(models.Model):
_inherit = 'mail.message'
def _message_format(self, fnames, format_reply=True, legacy=False):
"""Override to remove email_from and to return the livechat username if applicable.
A third param is added to the author_id tuple in this case to be able to differentiate it
from the normal name in client code.
def _to_store_defaults(self, target):
return super()._to_store_defaults(target) + ["chatbot_current_step"]
In addition, if we are currently running a chatbot.script, we include the information about
def _to_store(self, store: Store, fields, **kwargs):
"""If we are currently running a chatbot.script, we include the information about
the chatbot.message related to this mail.message.
This allows the frontend display to include the additional features
(e.g: Show additional buttons with the available answers for this step). """
vals_list = super()._message_format(fnames=fnames, format_reply=format_reply, legacy=legacy)
for vals in vals_list:
message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
mail_channel = self.env['mail.channel'].browse(message_sudo.res_id) if message_sudo.model == 'mail.channel' else self.env['mail.channel']
if mail_channel.channel_type == 'livechat':
if message_sudo.author_id:
vals.pop('email_from')
if message_sudo.author_id.user_livechat_username:
vals['author'] = {
'id': message_sudo.author_id.id,
'user_livechat_username': message_sudo.author_id.user_livechat_username,
(e.g: Show additional buttons with the available answers for this step)."""
super()._to_store(store, [f for f in fields if f != "chatbot_current_step"], **kwargs)
if "chatbot_current_step" not in fields:
return
channel_messages = self.filtered(lambda message: message.channel_id)
channel_by_message = channel_messages._record_by_message()
for message in channel_messages.filtered(
lambda message: channel_by_message[message].channel_type == "livechat"
):
channel = channel_by_message[message]
# sudo: chatbot.script.step - checking whether the current message is from chatbot
chatbot = channel.chatbot_current_step_id.sudo().chatbot_script_id.operator_partner_id
if channel.chatbot_current_step_id and message.author_id == chatbot:
chatbot_message = (
self.env["chatbot.message"]
.sudo()
.search([("mail_message_id", "=", message.id)], limit=1)
)
if step := chatbot_message.script_step_id:
step_data = {
"id": (step.id, message.id),
"message": message.id,
"scriptStep": Store.One(step, ["id", "message", "step_type"]),
"operatorFound": step.is_forward_operator
and channel.livechat_operator_id != chatbot,
}
# sudo: chatbot.script.step - members of a channel can access the current chatbot step
if mail_channel.chatbot_current_step_id \
and message_sudo.author_id == mail_channel.chatbot_current_step_id.sudo().chatbot_script_id.operator_partner_id:
chatbot_message_id = self.env['chatbot.message'].sudo().search([
('mail_message_id', '=', message_sudo.id)], limit=1)
if chatbot_message_id.script_step_id:
vals['chatbot_script_step_id'] = chatbot_message_id.script_step_id.id
if chatbot_message_id.script_step_id.step_type == 'question_selection':
vals['chatbot_step_answers'] = [{
'id': answer.id,
'label': answer.name,
'redirect_link': answer.redirect_link,
} for answer in chatbot_message_id.script_step_id.answer_ids]
if chatbot_message_id.user_script_answer_id:
vals['chatbot_selected_answer_id'] = chatbot_message_id.user_script_answer_id.id
return vals_list
if answer := chatbot_message.user_script_answer_id:
step_data["selectedAnswer"] = {
"id": answer.id,
"label": answer.name,
}
if step.step_type in [
"free_input_multi",
"free_input_single",
"question_email",
"question_phone",
]:
# sudo: chatbot.message - checking the user answer to the step is allowed
user_answer_message = (
self.env["chatbot.message"]
.sudo()
.search(
[
("script_step_id", "=", step.id),
("id", "!=", chatbot_message.id),
("discuss_channel_id", "=", channel.id),
],
limit=1,
)
)
step_data["rawAnswer"] = [
"markup",
user_answer_message.user_raw_answer,
]
store.add_model_values("ChatbotStep", step_data)
store.add(
message, {"chatbotStep": {"scriptStep": step.id, "message": message.id}}
)
def _get_store_partner_name_fields(self):
if self.channel_id.channel_type == "livechat":
return self.env["res.partner"]._get_store_livechat_username_fields()
return super()._get_store_partner_name_fields()

View file

@ -4,8 +4,7 @@
from odoo import api, models
class Rating(models.Model):
class RatingRating(models.Model):
_inherit = "rating.rating"
@api.depends('res_model', 'res_id')
@ -13,15 +12,23 @@ class Rating(models.Model):
for rating in self:
# cannot change the rec_name of session since it is use to create the bus channel
# so, need to override this method to set the same alternative rec_name as in reporting
if rating.res_model == 'mail.channel':
if rating.res_model == 'discuss.channel':
current_object = self.env[rating.res_model].sudo().browse(rating.res_id)
rating.res_name = ('%s / %s') % (current_object.livechat_channel_id.name, current_object.id)
else:
super(Rating, rating)._compute_res_name()
super(RatingRating, rating)._compute_res_name()
def action_open_rated_object(self):
action = super(Rating, self).action_open_rated_object()
if self.res_model == 'mail.channel':
view_id = self.env.ref('im_livechat.mail_channel_view_form').id
action = super().action_open_rated_object()
if self.res_model == 'discuss.channel':
if self.env[self.res_model].browse(self.res_id):
ctx = self.env.context.copy()
ctx.update({'active_id': self.res_id})
return {
'type': 'ir.actions.client',
'tag': 'mail.action_discuss',
'context': ctx,
}
view_id = self.env.ref('im_livechat.discuss_channel_view_form').id
action['views'] = [[view_id, 'form']]
return action

View file

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.fields import Command
class ResGroups(models.Model):
""" Update of res.users class
- add a preference about username for livechat purpose
"""
_inherit = "res.groups"
def write(self, vals):
if vals.get("user_ids"):
operator_group = self.env.ref("im_livechat.im_livechat_group_user")
if operator_group in self.all_implied_ids:
operators = operator_group.all_user_ids
result = super().write(vals)
lost_operators = operators - operator_group.all_user_ids
# sudo - im_livechat.channel: user manager can remove user from livechat channels
self.env["im_livechat.channel"].sudo() \
.search([("user_ids", "in", lost_operators.ids)]) \
.write({"user_ids": [Command.unlink(operator.id) for operator in lost_operators]})
return result
return super().write(vals)

View file

@ -1,26 +1,112 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields
import ast
from markupsafe import Markup
from odoo import api, models, fields, _
from odoo.addons.mail.tools.discuss import Store
from odoo.tools.misc import OrderedSet
from odoo.fields import Domain
class Partners(models.Model):
class ResPartner(models.Model):
"""Update of res.partner class to take into account the livechat username."""
_inherit = 'res.partner'
user_livechat_username = fields.Char(compute='_compute_user_livechat_username')
chatbot_script_ids = fields.One2many("chatbot.script", "operator_partner_id")
livechat_channel_count = fields.Integer(compute='_compute_livechat_channel_count')
def _get_channels_as_member(self):
channels = super()._get_channels_as_member()
channels |= self.env['mail.channel'].search([
('channel_type', '=', 'livechat'),
('channel_member_ids', 'in', self.env['mail.channel.member'].sudo()._search([
('partner_id', '=', self.id),
('is_pinned', '=', True),
])),
])
return channels
def _search_for_channel_invite_to_store(self, store: Store, channel):
super()._search_for_channel_invite_to_store(store, channel)
if channel.channel_type != "livechat" or not self:
return
lang_name_by_code = dict(self.env["res.lang"].get_installed())
invite_by_self_count_by_partner = dict(
self.env["discuss.channel.member"]._read_group(
[["create_uid", "=", self.env.user.id], ["partner_id", "in", self.ids]],
groupby=["partner_id"],
aggregates=["__count"],
)
)
active_livechat_partners = (
self.env["im_livechat.channel"].search([]).available_operator_ids.partner_id
)
for partner in self:
languages = list(OrderedSet([
lang_name_by_code[partner.lang],
# sudo: res.users.settings - operator can access other operators languages
*partner.user_ids.sudo().livechat_lang_ids.mapped("name")
]))
store.add(
partner,
{
"invite_by_self_count": invite_by_self_count_by_partner.get(partner, 0),
"is_available": partner in active_livechat_partners,
"lang_name": languages[0],
# sudo: res.users.settings - operator can access other operators expertises
"livechat_expertise": partner.user_ids.sudo().livechat_expertise_ids.mapped("name"),
"livechat_languages": languages[1:],
# sudo: res.users.settings - operator can access other operators livechat usernames
"user_livechat_username": partner.sudo().user_livechat_username,
},
# sudo - res.partner: checking if operator is in call for live
# chat invitation is acceptable.
extra_fields=[Store.Attr("is_in_call", sudo=True)]
)
@api.depends('user_ids.livechat_username')
def _compute_user_livechat_username(self):
for partner in self:
partner.user_livechat_username = next(iter(partner.user_ids.mapped('livechat_username')), False)
def _compute_livechat_channel_count(self):
livechat_count_by_partner = dict(
self.env["im_livechat.channel.member.history"]._read_group(
domain=[("partner_id", "in", self.ids), ("livechat_member_type", "=", "visitor")],
groupby=["partner_id"],
aggregates=["channel_id:count_distinct"],
)
)
for partner in self:
partner.livechat_channel_count = livechat_count_by_partner.get(partner, 0)
def _get_store_livechat_username_fields(self):
"""Return the fields to be stored for live chat username."""
return [
Store.Attr("name", predicate=lambda p: not p.user_livechat_username),
"user_livechat_username",
]
def _bus_send_history_message(self, channel, page_history):
message_body = _("No history found")
if page_history:
message_body = Markup("<ul>%s</ul>") % (
Markup("").join(
Markup('<li><a href="%(page)s" target="_blank">%(page)s</a></li>')
% {"page": page}
for page in page_history
)
)
self._bus_send_transient_message(channel, message_body)
@api.depends_context("im_livechat_hide_partner_company")
def _compute_display_name(self):
if not self.env.context.get("im_livechat_hide_partner_company"):
super()._compute_display_name()
return
portal_partners = self.filtered("partner_share")
super(ResPartner, portal_partners)._compute_display_name()
for partner in self - portal_partners:
partner.display_name = partner.name
def action_view_livechat_sessions(self):
action = self.env["ir.actions.act_window"]._for_xml_id("im_livechat.discuss_channel_action")
livechat_channel_ids = self.env['im_livechat.channel.member.history'].search([
('partner_id', '=', self.id),
('livechat_member_type', '=', 'visitor'),
]).channel_id.ids
action["domain"] = Domain.AND([
ast.literal_eval(action["domain"]),
[('id', 'in', livechat_channel_ids)]
])
return action

View file

@ -1,21 +1,156 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo.addons.mail.tools.discuss import Store
from odoo.fields import Command
class Users(models.Model):
class ResUsers(models.Model):
""" Update of res.users class
- add a preference about username for livechat purpose
"""
_inherit = 'res.users'
livechat_username = fields.Char("Livechat Username", help="This username will be used as your name in the livechat channels.")
livechat_channel_ids = fields.Many2many(
"im_livechat.channel", "im_livechat_channel_im_user", "user_id", "channel_id", copy=False
)
livechat_username = fields.Char(
string="Livechat Username",
groups="im_livechat.im_livechat_group_user,base.group_erp_manager",
compute="_compute_livechat_username",
inverse="_inverse_livechat_username",
store=False,
)
livechat_lang_ids = fields.Many2many(
"res.lang",
string="Livechat Languages",
groups="im_livechat.im_livechat_group_user,base.group_erp_manager",
compute="_compute_livechat_lang_ids",
inverse="_inverse_livechat_lang_ids",
store=False,
)
livechat_expertise_ids = fields.Many2many(
"im_livechat.expertise",
string="Live Chat Expertise",
groups="im_livechat.im_livechat_group_user,base.group_erp_manager",
compute="_compute_livechat_expertise_ids",
inverse="_inverse_livechat_expertise_ids",
store=False,
help="When forwarding live chat conversations, the chatbot will prioritize users with matching expertise.",
)
livechat_ongoing_session_count = fields.Integer(
"Number of Ongoing sessions",
compute="_compute_livechat_ongoing_session_count",
groups="im_livechat.im_livechat_group_user",
)
livechat_is_in_call = fields.Boolean(
help="Whether the user is in a call, only available if the user is in a live chat agent",
compute="_compute_livechat_is_in_call",
groups="im_livechat.im_livechat_group_user",
)
has_access_livechat = fields.Boolean(compute='_compute_has_access_livechat', string='Has access to Livechat', store=False, readonly=True)
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['livechat_username']
return super().SELF_READABLE_FIELDS + [
"has_access_livechat",
"livechat_expertise_ids",
"livechat_lang_ids",
"livechat_username",
]
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['livechat_username']
return super().SELF_WRITEABLE_FIELDS + [
"livechat_expertise_ids",
"livechat_lang_ids",
"livechat_username",
]
@api.depends("livechat_channel_ids", "is_in_call")
def _compute_livechat_is_in_call(self):
for user in self:
# sudo - res.users: checking if user is in call is allowed if the user is member of a live chat channel.
user.livechat_is_in_call = user.sudo().is_in_call if user.livechat_channel_ids else None
@api.depends_context("im_livechat_channel_id")
@api.depends("livechat_channel_ids.channel_ids.livechat_end_dt", "partner_id")
def _compute_livechat_ongoing_session_count(self):
domain = [
("channel_id.livechat_end_dt", "=", False),
("member_id", "!=", False),
("partner_id", "in", self.partner_id.ids),
("channel_id.last_interest_dt", ">=", "-15M"),
]
if channel_id := self.env.context.get('im_livechat_channel_id'):
domain.append(("session_livechat_channel_id", "=", channel_id))
count_by_partner = dict(
self.env["im_livechat.channel.member.history"]._read_group(
domain, ["partner_id"], ["__count"],
),
)
for user in self:
user.livechat_ongoing_session_count = count_by_partner.get(user.partner_id, 0)
@api.depends('res_users_settings_id.livechat_username')
def _compute_livechat_username(self):
for user in self:
# sudo: livechat user can see the livechat username of any other user
user.livechat_username = user.sudo().res_users_settings_id.livechat_username
def _inverse_livechat_username(self):
for user in self:
settings = self.env['res.users.settings']._find_or_create_for_user(user)
settings.livechat_username = user.livechat_username
@api.depends('res_users_settings_id.livechat_lang_ids')
def _compute_livechat_lang_ids(self):
for user in self:
# sudo: livechat user can see the livechat languages of any other user
user.livechat_lang_ids = user.sudo().res_users_settings_id.livechat_lang_ids
def _inverse_livechat_lang_ids(self):
for user in self:
settings = self.env['res.users.settings']._find_or_create_for_user(user)
settings.livechat_lang_ids = user.livechat_lang_ids
@api.depends("res_users_settings_id.livechat_expertise_ids")
def _compute_livechat_expertise_ids(self):
for user in self:
# sudo: livechat user can see the livechat expertise of any other user
user.livechat_expertise_ids = user.sudo().res_users_settings_id.livechat_expertise_ids
def _inverse_livechat_expertise_ids(self):
for user in self:
settings = self.env["res.users.settings"]._find_or_create_for_user(user)
settings.livechat_expertise_ids = user.livechat_expertise_ids
@api.depends("group_ids")
def _compute_has_access_livechat(self):
for user in self.sudo():
user.has_access_livechat = user.has_group('im_livechat.im_livechat_group_user')
def write(self, vals):
if vals.get("group_ids"):
operator_group = self.env.ref("im_livechat.im_livechat_group_user")
if operator_group in self.all_group_ids:
result = super().write(vals)
lost_operators = self.filtered_domain([("all_group_ids", "not in", operator_group.id)])
# sudo - im_livechat.channel: user manager can remove user from livechat channels
self.env["im_livechat.channel"].sudo() \
.search([("user_ids", "in", lost_operators.ids)]) \
.write({"user_ids": [Command.unlink(operator.id) for operator in lost_operators]})
return result
return super().write(vals)
def _init_store_data(self, store: Store):
super()._init_store_data(store)
store.add_global_values(has_access_livechat=self.env.user.has_access_livechat)
if not self.env.user._is_public():
store.add(
self.env.user,
Store.Attr(
"is_livechat_manager",
lambda u: u.has_group("im_livechat.im_livechat_group_manager"),
),
)

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
@ -7,4 +6,11 @@ from odoo import fields, models
class ResUsersSettings(models.Model):
_inherit = 'res.users.settings'
is_discuss_sidebar_category_livechat_open = fields.Boolean("Is category livechat open", default=True)
livechat_username = fields.Char("Livechat Username", help="This username will be used as your name in the livechat channels.")
livechat_lang_ids = fields.Many2many(comodel_name='res.lang', string='Livechat languages',
help="These languages, in addition to your main language, will be used to assign you to Live Chat sessions.")
livechat_expertise_ids = fields.Many2many(
"im_livechat.expertise",
string="Live Chat Expertise",
help="When forwarding live chat conversations, the chatbot will prioritize users with matching expertise.",
)