Initial commit: Mail packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:51 +02:00
commit 4e53507711
1948 changed files with 751201 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# -*- coding: utf-8 -*
from . import chatbot_message
from . import chatbot_script
from . import chatbot_script_answer
from . import chatbot_script_step
from . import res_users
from . import res_partner
from . import im_livechat_channel
from . import mail_channel
from . import mail_channel_member
from . import mail_message
from . import res_users_settings
from . import rating_rating
from . import digest

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
class ChatbotMailMessage(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.
"""
_name = 'chatbot.message'
_description = 'Chatbot Message'
_order = 'create_date desc, id desc'
_rec_name = 'mail_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")
script_step_id = fields.Many2one(
'chatbot.script.step', string='Chatbot Step', required=True, ondelete='cascade')
user_script_answer_id = fields.Many2one('chatbot.script.answer', string="User's answer", ondelete="set null")
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"),
]

View file

@ -0,0 +1,216 @@
# -*- 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
class ChatbotScript(models.Model):
_name = 'chatbot.script'
_description = 'Chatbot Script'
_inherit = ['image.mixin', 'utm.source.mixin']
_rec_name = 'title'
_order = 'title, id'
# we keep a separate field for UI since name is manipulated by 'utm.source.mixin'
title = fields.Char('Title', required=True, translate=True, default="Chatbot")
active = fields.Boolean(default=True)
image_1920 = fields.Image(related='operator_partner_id.image_1920', readonly=False)
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)
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")
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}
for script in self:
script.livechat_channel_count = mapped_channels.get(script.id, 0)
@api.depends('script_step_ids.step_type')
def _compute_first_step_warning(self):
for script in self:
allowed_first_step_types = [
'question_selection',
'question_email',
'question_phone',
'free_input_single',
'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':
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(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
answers from the copy instead of references to the answers of the original.
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)
if 'question_ids' in default:
return clone_chatbot_script
original_steps = self.script_step_ids.sorted()
clone_steps = clone_chatbot_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
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
@api.model_create_multi
def create(self, vals_list):
operator_partners_values = [{
'name': vals['title'],
'image_1920': vals.get('image_1920', False),
'active': False,
} for vals in vals_list if 'operator_partner_id' not in vals and 'title' in vals]
operator_partners = self.env['res.partner'].create(operator_partners_values)
for vals, partner in zip(
[vals for vals in vals_list if 'operator_partner_id' not in vals and 'title' in vals],
operator_partners
):
vals['operator_partner_id'] = partner.id
return super().create(vals_list)
def write(self, vals):
res = super().write(vals)
if 'title' in vals:
self.operator_partner_id.write({'name': vals['title']})
return res
def _get_welcome_steps(self):
""" Returns a sub-set of script_step_ids that only contains the "welcoming steps".
We consider those as all the steps the bot will say before expecting a first answer from
the end user.
Example 1:
- step 1 (question_selection): What do you want to do? - Create a Lead, -Create a Ticket
- step 2 (text): Thank you for visiting our website!
-> The welcoming steps will only contain step 1, since directly after that we expect an
input from the user
Example 2:
- step 1 (text): Hello! I'm a bot!
- step 2 (text): I am here to help lost users.
- step 3 (question_selection): What do you want to do? - Create a Lead, -Create a Ticket
- step 4 (text): Thank you for visiting our website!
-> The welcoming steps will contain steps 1, 2 and 3.
Meaning the bot will have a small monologue with himself before expecting an input from the
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,
to avoid bloating the channels with bot messages if the end-user never interacts with it. """
self.ensure_one()
welcome_steps = self.env['chatbot.script.step']
for step in self.script_step_ids:
welcome_steps += step
if step.step_type != 'text':
break
return welcome_steps
def _post_welcome_steps(self, mail_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()'. """
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
if not is_html_empty(welcome_step.message):
posted_messages += mail_channel.with_context(mail_create_nosubscribe=True).message_post(
author_id=self.operator_partner_id.id,
body=plaintext2html(welcome_step.message),
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
return posted_messages
def action_view_livechat_channels(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('im_livechat.im_livechat_channel_action')
action['domain'] = [('rule_ids.chatbot_script_id', 'in', self.ids)]
return action
# --------------------------
# 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()
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):
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 = mail_channel._chatbot_post_message(self, plaintext2html(error_message))
return {
'success': bool(email_normalized),
'posted_message': posted_message,
'error_message': error_message,
}

View file

@ -0,0 +1,65 @@
# -*- 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
import textwrap
class ChatbotScriptAnswer(models.Model):
_name = 'chatbot.script.answer'
_description = 'Chatbot Script Answer'
_order = 'script_step_id, sequence, id'
name = fields.Char(string='Answer', required=True, translate=True)
sequence = fields.Integer(string='Sequence', default=1)
redirect_link = fields.Char('Redirect Link',
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_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()
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
@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 = []
# 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])])
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)

View file

@ -0,0 +1,383 @@
# -*- 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 collections import defaultdict
from markupsafe import Markup
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)
sequence = fields.Integer(string='Sequence')
chatbot_script_id = fields.Many2one(
'chatbot.script', string='Chatbot', required=True, ondelete='cascade')
step_type = fields.Selection([
('text', 'Text'),
('question_selection', 'Question'),
('question_email', 'Email'),
('question_phone', 'Phone'),
('forward_operator', 'Forward to Operator'),
('free_input_single', 'Free Input'),
('free_input_multi', 'Free Input (Multi-Line)'),
], default='text', required=True)
# answers
answer_ids = fields.One2many(
'chatbot.script.answer', 'script_step_id',
copy=True, string='Answers')
triggering_answer_ids = fields.Many2many(
'chatbot.script.answer', domain="[('script_step_id.sequence', '<', sequence)]",
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_child = fields.Boolean(compute='_compute_is_forward_operator_child')
@api.depends('sequence')
def _compute_triggering_answer_ids(self):
for step in self.filtered('triggering_answer_ids'):
update_command = [Command.unlink(answer.id) for answer in step.triggering_answer_ids
if answer.script_step_id.sequence >= step.sequence]
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')
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']
).sorted(lambda s: s.sequence, reverse=True)
for step in self:
parent_steps = parent_steps_by_chatbot[step.chatbot_script_id.id].filtered(
lambda s: s.sequence < step.sequence
)
parent = step
while True:
parent = parent._get_parent_step(parent_steps)
if not parent or parent.step_type == 'forward_operator':
break
step.is_forward_operator_child = parent and parent.step_type == 'forward_operator'
@api.model_create_multi
def create(self, vals_list):
""" Ensure we correctly assign sequences when creating steps.
Indeed, sequences are very important within the script, and will break the whole flow if
not correctly defined.
This override will group created steps by chatbot_id and increment the sequence accordingly.
It will also look for an existing step for that chatbot and resume from the highest sequence.
This cannot be done in a default_value for the sequence field as we cannot search by
runbot_id.
It is also safer and more efficient to do it here (we can batch everything).
It is still possible to manually pass the 'sequence' in the values, which will take priority. """
vals_by_chatbot_id = {}
for vals in vals_list:
chatbot_id = vals.get('chatbot_script_id')
if chatbot_id:
step_values = vals_by_chatbot_id.get(chatbot_id, [])
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
}
for chatbot_id, step_vals in vals_by_chatbot_id.items():
current_sequence = 0
if chatbot_id in max_sequence_by_chatbot:
current_sequence = max_sequence_by_chatbot[chatbot_id] + 1
for vals in step_vals:
if 'sequence' in vals:
current_sequence = vals.get('sequence')
else:
vals['sequence'] = current_sequence
current_sequence += 1
return super().create(vals_list)
# --------------------------
# 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
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
(see step_type 'question_email')
'phone': The phone extracted from the mail.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 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."""
partner = False
user_inputs = mail_channel._chatbot_find_customer_values_in_messages({
'question_email': 'email',
'question_phone': 'phone',
})
input_email = user_inputs.get('email', False)
input_phone = user_inputs.get('phone', False)
if self.env.user._is_public() and create_partner:
partner = self.env['res.partner'].create({
'name': input_email,
'email': input_email,
'phone': input_phone,
})
elif not self.env.user._is_public():
partner = self.env.user.partner_id
if update_partner:
# update email/phone value from partner if not set
update_values = {}
if input_email and not partner.email:
update_values['email'] = input_email
if input_phone and not partner.phone:
update_values['phone'] = input_phone
if update_values:
partner.write(update_values)
description = Markup('')
if input_email:
description += Markup('%s<strong>%s</strong><br>') % (_('Please contact me on: '), input_email)
if input_phone:
description += Markup('%s<strong>%s</strong><br>') % (_('Please call me on: '), input_phone)
if description:
description += Markup('<br>')
return {
'partner': partner,
'email': input_email,
'phone': input_phone,
'description': description,
}
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
them must be a 'OR'. If is contains multiple triggering answers from different steps the
condition between them must be a 'AND'.
e.g:
STEP 1 : A B
STEP 2 : C D
STEP 3 : E
STEP 4 ONLY IF A B C E
Scenario 1 (A C E):
A in (A B) -> OK
C in (C) -> OK
E in (E) -> OK
-> OK
Scenario 2 (B D E):
B in (A B) -> OK
D in (C) -> NOK
E in (E) -> OK
-> NOK
"""
self.ensure_one()
domain = [('chatbot_script_id', '=', self.chatbot_script_id.id), ('sequence', '>', self.sequence)]
if selected_answer_ids:
domain = expression.AND([domain, [
'|',
('triggering_answer_ids', '=', False),
('triggering_answer_ids', 'in', selected_answer_ids.ids)]])
steps = self.env['chatbot.script.step'].search(domain)
for step in steps:
if not step.triggering_answer_ids:
return step
answers_by_step = defaultdict(list)
for answer in step.triggering_answer_ids:
answers_by_step[answer.script_step_id.id].append(answer)
if all(any(answer in step_triggering_answers for answer in selected_answer_ids)
for step_triggering_answers in answers_by_step.values()):
return step
return self.env['chatbot.script.step']
def _get_parent_step(self, all_parent_steps):
""" Returns the first preceding step that matches either the triggering answers
or the possible answers the user can select """
self.ensure_one()
if not self.chatbot_script_id.ids:
return self.env['chatbot.script.step']
for step in all_parent_steps:
if step.sequence >= self.sequence:
continue
if self.triggering_answer_ids:
if not (all(answer in self.triggering_answer_ids for answer in step.triggering_answer_ids) or
any(answer in self.triggering_answer_ids for answer in step.answer_ids)):
continue
elif step.triggering_answer_ids:
continue
return step
return self.env['chatbot.script.step']
def _is_last_step(self, mail_channel=False):
self.ensure_one()
mail_channel = mail_channel or self.env['mail.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):
return True
return False
def _process_answer(self, mail_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').
Some extra processing is done for steps of type 'question_email' and 'question_phone' where
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 message_body:
:return: script step to display next
:rtype: 'chatbot.script.step' """
self.ensure_one()
user_text_answer = html2plaintext(message_body)
if self.step_type == 'question_email' and not email_normalize(user_text_answer):
# 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']:
chatbot_message = self.env['chatbot.message'].search([
('mail_channel_id', '=', mail_channel.id),
('script_step_id', '=', self.id),
], limit=1)
if chatbot_message:
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)
def _process_step(self, mail_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.
Some extra processing may be required for special step types such as 'forward_operator',
'create_lead', 'create_ticket' (in their related bridge modules).
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 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
}

View file

@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class Digest(models.Model):
_inherit = 'digest.digest'
kpi_livechat_rating = fields.Boolean('% of Happiness')
kpi_livechat_rating_value = fields.Float(digits=(16, 2), compute='_compute_kpi_livechat_rating_value')
kpi_livechat_conversations = fields.Boolean('Conversations handled')
kpi_livechat_conversations_value = fields.Integer(compute='_compute_kpi_livechat_conversations_value')
kpi_livechat_response = fields.Boolean('Time to answer (sec)')
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
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)
])
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
)
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['kpi_livechat_conversations'] = 'im_livechat.im_livechat_report_operator_action'
res['kpi_livechat_response'] = 'im_livechat.im_livechat_report_channel_time_to_answer_action'
return res

View file

@ -0,0 +1,346 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import random
import re
from odoo import api, Command, fields, models, modules, _
class ImLivechatChannel(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.
It provides rating tools, and access rules for anonymous people.
"""
_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())
def _default_user_ids(self):
return [(6, 0, [self._uid])]
def _default_button_text(self):
return _('Have a Question? 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")
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.')
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")
# 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)
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')
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')
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])
@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}
for channel in self:
channel.chatbot_script_count = mapped_data.get(channel.id, 0)
def _compute_script_external(self):
values = {
"dbname": self._cr.dbname,
}
for record in self:
values["channel_id"] = record.id
values["url"] = record.get_base_url()
record.script_external = self.env['ir.qweb']._render('im_livechat.external_loader', values) if record.id else False
def _compute_web_page_link(self):
for record in self:
record.web_page = "%s/im_livechat/support/%i" % (record.get_base_url(), record.id) if record.id else False
@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}
for record in self:
record.nbr_channel = channel_count.get(record.id, 0)
# --------------------------
# Action Methods
# --------------------------
def action_join(self):
self.ensure_one()
return self.write({'user_ids': [(4, self._uid)]})
def action_quit(self):
self.ensure_one()
return self.write({'user_ids': [(3, self._uid)]})
def action_view_rating(self):
""" Action to display the rating relative to the channel, so all rating of the
sessions of the current channel
: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}
return action
def action_view_chatbot_scripts(self):
action = self.env['ir.actions.act_window']._for_xml_id('im_livechat.chatbot_script_action')
chatbot_script_ids = self.env['im_livechat.channel.rule'].search(
[('channel_id', 'in', self.ids)]).mapped('chatbot_script_id')
if len(chatbot_script_ids) == 1:
action['res_id'] = chatbot_script_ids.id
action['view_mode'] = 'form'
action['views'] = [(False, 'form')]
else:
action['domain'] = [('id', 'in', chatbot_script_ids.ids)]
return action
# --------------------------
# 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
])
return {
'channel_member_ids': members_to_add,
'livechat_active': True,
'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,
'channel_type': 'livechat',
'name': 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
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]
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
}
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.
(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)
:return : user
:rtype : res.users
"""
operators = self._get_available_users()
if len(operators) == 0:
return False
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()
# 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)
# 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])
# 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 _get_channel_infos(self):
self.ensure_one()
return {
'header_background_color': self.header_background_color,
'button_background_color': self.button_background_color,
'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,
}
def get_livechat_info(self, username=None):
self.ensure_one()
if username is None:
username = _('Visitor')
info = {}
info['available'] = self.chatbot_script_count or len(self._get_available_users()) > 0
info['server_url'] = self.get_base_url()
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
class ImLivechatChannelRule(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.
"""
_name = 'im_livechat.channel.rule'
_description = 'Livechat Channel Rules'
_order = 'sequence asc'
regex_url = fields.Char('URL Regex',
help="Regular expression specifying the web pages this rule will be applied on.")
action = fields.Selection([
('display_button', 'Show'),
('display_button_and_text', 'Show with notification'),
('auto_popup', 'Open automatically'),
('hide_button', 'Hide')], string='Live Chat Button', required=True, default='display_button',
help="* 'Show' displays the chat button on the pages.\n"\
"* '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,
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',
help="The channel of the rule")
country_ids = fields.Many2many('res.country', 'im_livechat_channel_country_rel', 'channel_id', 'country_id', 'Country',
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.")
def match_rule(self, channel_id, url, country_id=False):
""" determine if a rule of the given channel matches with the given url
:param channel_id : the identifier of the channel_id
:param url : the url to match with a rule
:param country_id : the identifier of the country
:returns the rule that matches the given condition. False otherwise.
:rtype : im_livechat.channel.rule
"""
def _match(rules):
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
# 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)]
rule = _match(self.search(domain))
if rule:
return rule
# second, fallback on the rules without country
domain = [('country_ids', '=', False), ('channel_id', '=', channel_id)]
return _match(self.search(domain))

View file

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

View file

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

View file

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
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.
In addition, 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,
}
# 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

View file

@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class Rating(models.Model):
_inherit = "rating.rating"
@api.depends('res_model', 'res_id')
def _compute_res_name(self):
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':
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()
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['views'] = [[view_id, 'form']]
return action

View file

@ -0,0 +1,26 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields
class Partners(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')
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
@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)

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
class Users(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.")
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + ['livechat_username']
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_WRITEABLE_FIELDS + ['livechat_username']

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResUsersSettings(models.Model):
_inherit = 'res.users.settings'
is_discuss_sidebar_category_livechat_open = fields.Boolean("Is category livechat open", default=True)