mirror of
https://github.com/bringout/oca-ocb-mail.git
synced 2026-04-22 20:02:10 +02:00
19.0 vanilla
This commit is contained in:
parent
5df8c07b59
commit
daa394e8b0
2114 changed files with 564841 additions and 299642 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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...'))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue