19.0 vanilla

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

View file

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