diff --git a/addons/im_livechat/__manifest__.py b/addons/im_livechat/__manifest__.py
index 16a4b5db78c..a02f8337d4a 100644
--- a/addons/im_livechat/__manifest__.py
+++ b/addons/im_livechat/__manifest__.py
@@ -23,11 +23,16 @@ Help your customers with this chat, and analyse their feedback.
"data/mail_shortcode_data.xml",
"data/mail_data.xml",
"data/im_livechat_channel_data.xml",
+ "data/im_livechat_chatbot_data.xml",
'data/digest_data.xml',
+ 'views/chatbot_script_answer_views.xml',
+ 'views/chatbot_script_step_views.xml',
+ 'views/chatbot_script_views.xml',
"views/rating_views.xml",
"views/mail_channel_views.xml",
"views/im_livechat_channel_views.xml",
"views/im_livechat_channel_templates.xml",
+ "views/im_livechat_chatbot_templates.xml",
"views/res_users_views.xml",
"views/digest_views.xml",
"report/im_livechat_report_channel_views.xml",
@@ -37,7 +42,7 @@ Help your customers with this chat, and analyse their feedback.
"data/im_livechat_channel_demo.xml",
'data/mail_shortcode_demo.xml',
],
- 'depends': ["mail", "rating", "digest"],
+ 'depends': ["mail", "rating", "digest", "utm"],
'installable': True,
'auto_install': False,
'application': True,
@@ -49,6 +54,11 @@ Help your customers with this chat, and analyse their feedback.
'web.assets_backend': [
'im_livechat/static/src/js/im_livechat_channel_form_view.js',
'im_livechat/static/src/js/im_livechat_channel_form_controller.js',
+ 'im_livechat/static/src/js/im_livechat_chatbot_form_view.js',
+ 'im_livechat/static/src/js/im_livechat_chatbot_form_controller.js',
+ 'im_livechat/static/src/js/im_livechat_chatbot_step_form_view_dialog.js',
+ 'im_livechat/static/src/js/chatbot_script_answers_m2m_tags.js',
+ 'im_livechat/static/src/js/chatbot_script_step_o2m.js',
'im_livechat/static/src/components/*/*.js',
'im_livechat/static/src/models/*.js',
'im_livechat/static/src/scss/im_livechat_history.scss',
@@ -62,6 +72,11 @@ Help your customers with this chat, and analyse their feedback.
'im_livechat/static/src/legacy/public_livechat.js',
'im_livechat/static/src/legacy/models/*',
'im_livechat/static/src/legacy/widgets/*',
+ 'im_livechat/static/src/legacy/public_livechat_chatbot.js',
+ 'im_livechat/static/src/legacy/website_livechat_message_chatbot.js',
+ ],
+ 'web.assets_tests': [
+ 'im_livechat/static/tests/tours/**/*',
],
'web.assets_qweb': [
'im_livechat/static/src/components/*/*.xml',
@@ -162,12 +177,15 @@ Help your customers with this chat, and analyse their feedback.
'im_livechat/static/src/legacy/public_livechat.js',
'im_livechat/static/src/legacy/models/*',
'im_livechat/static/src/legacy/widgets/*',
+ 'im_livechat/static/src/legacy/public_livechat_chatbot.js',
+ 'im_livechat/static/src/legacy/website_livechat_message_chatbot.js',
('include', 'web._assets_helpers'),
'web/static/lib/bootstrap/scss/_variables.scss',
'im_livechat/static/src/scss/im_livechat_bootstrap.scss',
'im_livechat/static/src/legacy/public_livechat.scss',
+ 'im_livechat/static/src/legacy/public_livechat_chatbot.scss',
]
},
'license': 'LGPL-3',
diff --git a/addons/im_livechat/controllers/__init__.py b/addons/im_livechat/controllers/__init__.py
index 757b12a1f17..86ba50891b2 100644
--- a/addons/im_livechat/controllers/__init__.py
+++ b/addons/im_livechat/controllers/__init__.py
@@ -1,2 +1,5 @@
# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import chatbot
from . import main
diff --git a/addons/im_livechat/controllers/chatbot.py b/addons/im_livechat/controllers/chatbot.py
new file mode 100644
index 00000000000..df945067842
--- /dev/null
+++ b/addons/im_livechat/controllers/chatbot.py
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import http
+from odoo.http import request
+from odoo.tools import is_html_empty, plaintext2html
+
+
+class LivechatChatbotScriptController(http.Controller):
+ @http.route('/chatbot/restart', type="json", auth="public", cors="*")
+ def chatbot_restart(self, channel_uuid, chatbot_script_id):
+
+ mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid)], limit=1)
+ chatbot = request.env['chatbot.script'].browse(chatbot_script_id)
+ if not mail_channel or not chatbot.exists():
+ return None
+
+ return mail_channel._chatbot_restart(chatbot).message_format()[0]
+
+ @http.route('/chatbot/post_welcome_steps', type="json", auth="public", cors="*")
+ def chatbot_post_welcome_steps(self, channel_uuid, chatbot_script_id):
+ mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid)], limit=1)
+ chatbot = request.env['chatbot.script'].sudo().browse(chatbot_script_id)
+ if not mail_channel or not chatbot.exists():
+ return None
+
+ return chatbot._post_welcome_steps(mail_channel).message_format()
+
+ @http.route('/chatbot/answer/save', type="json", auth="public", cors="*")
+ def chatbot_save_answer(self, channel_uuid, message_id, selected_answer_id):
+ mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid)], limit=1)
+ chatbot_message = request.env['chatbot.message'].sudo().search([
+ ('mail_message_id', '=', message_id),
+ ('mail_channel_id', '=', mail_channel.id),
+ ], limit=1)
+ selected_answer = request.env['chatbot.script.answer'].sudo().browse(selected_answer_id)
+
+ if not mail_channel or not chatbot_message or not selected_answer.exists():
+ return
+
+ if selected_answer in chatbot_message.script_step_id.answer_ids:
+ chatbot_message.write({'user_script_answer_id': selected_answer_id})
+
+ @http.route('/chatbot/step/trigger', type="json", auth="public", cors="*")
+ def chatbot_trigger_step(self, channel_uuid, chatbot_script_id=None):
+ mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid)], limit=1)
+ if not mail_channel:
+ return None
+
+ next_step = False
+ if mail_channel.chatbot_current_step_id:
+ chatbot = mail_channel.chatbot_current_step_id.chatbot_script_id
+ user_messages = mail_channel.message_ids.filtered(
+ lambda message: message.author_id != chatbot.operator_partner_id
+ )
+ user_answer = request.env['mail.message'].sudo()
+ if user_messages:
+ user_answer = user_messages.sorted(lambda message: message.id)[-1]
+ next_step = mail_channel.chatbot_current_step_id._process_answer(mail_channel, user_answer.body)
+ elif chatbot_script_id: # when restarting, we don't have a "current step" -> set "next" as first step of the script
+ chatbot = request.env['chatbot.script'].sudo().browse(chatbot_script_id)
+ if chatbot.exists():
+ next_step = chatbot.script_step_ids[:1]
+
+ if not next_step:
+ return None
+
+ posted_message = next_step._process_step(mail_channel)
+ return {
+ 'chatbot_posted_message': posted_message.message_format()[0] if posted_message else None,
+ 'chatbot_step': {
+ 'chatbot_operator_found': next_step.step_type == 'forward_operator' and len(
+ mail_channel.channel_last_seen_partner_ids) > 2,
+ 'chatbot_script_step_id': next_step.id,
+ 'chatbot_step_answers': [{
+ 'id': answer.id,
+ 'label': answer.name,
+ 'redirect_link': answer.redirect_link,
+ } for answer in next_step.answer_ids],
+ 'chatbot_step_is_last': next_step._is_last_step(mail_channel),
+ 'chatbot_step_message': plaintext2html(next_step.message) if not is_html_empty(next_step.message) else False,
+ 'chatbot_step_type': next_step.step_type,
+ }
+ }
+
+ @http.route('/chatbot/step/validate_email', type="json", auth="public", cors="*")
+ def chatbot_validate_email(self, channel_uuid):
+ mail_channel = request.env['mail.channel'].sudo().search([('uuid', '=', channel_uuid)], limit=1)
+ if not mail_channel or not mail_channel.chatbot_current_step_id:
+ return None
+
+ chatbot = mail_channel.chatbot_current_step_id.chatbot_script_id
+ user_messages = mail_channel.message_ids.filtered(
+ lambda message: message.author_id != chatbot.operator_partner_id
+ )
+
+ if user_messages:
+ user_answer = user_messages.sorted(lambda message: message.id)[-1]
+ result = chatbot._validate_email(user_answer.body, mail_channel)
+
+ if result['posted_message']:
+ result['posted_message'] = result['posted_message'].message_format()[0]
+
+ return result
diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py
index 8f49f968d21..f833d5d3fde 100644
--- a/addons/im_livechat/controllers/main.py
+++ b/addons/im_livechat/controllers/main.py
@@ -1,9 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-import base64
-
-from odoo import http,tools, _
+from odoo import http, tools, _
from odoo.http import request
from odoo.addons.base.models.assetsbundle import AssetsBundle
@@ -30,9 +28,9 @@ class LivechatController(http.Controller):
@http.route('/im_livechat/load_templates', type='json', auth='none', cors="*")
def load_templates(self, **kwargs):
- base_url = request.httprequest.base_url
templates = [
'im_livechat/static/src/legacy/public_livechat.xml',
+ 'im_livechat/static/src/legacy/public_livechat_chatbot.xml',
]
return [tools.file_open(tmpl, 'rb').read() for tmpl in templates]
@@ -50,31 +48,35 @@ class LivechatController(http.Controller):
@http.route('/im_livechat/init', type='json', auth="public", cors="*")
def livechat_init(self, channel_id):
- available = len(request.env['im_livechat.channel'].sudo().browse(channel_id)._get_available_users())
+ operator_available = len(request.env['im_livechat.channel'].sudo().browse(channel_id)._get_available_users())
rule = {}
- if available:
- # find the country from the request
- country_id = False
- country_code = request.session.geoip.get('country_code') if request.session.geoip else False
- if country_code:
- country_id = request.env['res.country'].sudo().search([('code', '=', country_code)], limit=1).id
- # extract url
- url = request.httprequest.headers.get('Referer')
- # find the first matching rule for the given country and url
- matching_rule = request.env['im_livechat.channel.rule'].sudo().match_rule(channel_id, url, country_id)
- if matching_rule:
- rule = {
- 'action': matching_rule.action,
- 'auto_popup_timer': matching_rule.auto_popup_timer,
- 'regex_url': matching_rule.regex_url,
- }
+ # find the country from the request
+ country_id = False
+ country_code = request.session.geoip.get('country_code') if request.session.geoip else False
+ if country_code:
+ country_id = request.env['res.country'].sudo().search([('code', '=', country_code)], limit=1).id
+ # extract url
+ url = request.httprequest.headers.get('Referer')
+ # find the first matching rule for the given country and url
+ matching_rule = request.env['im_livechat.channel.rule'].sudo().match_rule(channel_id, url, country_id)
+ if matching_rule and (not matching_rule.chatbot_script_id or matching_rule.chatbot_script_id.script_step_ids):
+ rule = {
+ 'action': matching_rule.action,
+ 'auto_popup_timer': matching_rule.auto_popup_timer,
+ 'regex_url': matching_rule.regex_url,
+ }
+ if matching_rule.chatbot_script_id and (not matching_rule.chatbot_only_if_no_operator or
+ (not operator_available and matching_rule.chatbot_only_if_no_operator)) and matching_rule.chatbot_script_id.script_step_ids:
+ chatbot_script = matching_rule.chatbot_script_id
+ rule.update({'chatbot': chatbot_script._format_for_frontend()})
return {
- 'available_for_me': available and (not rule or rule['action'] != 'hide_button'),
+ 'available_for_me': (rule and rule.get('chatbot'))
+ or operator_available and (not rule or rule['action'] != 'hide_button'),
'rule': rule,
}
@http.route('/im_livechat/get_session', type="json", auth='public', cors="*")
- def get_session(self, channel_id, anonymous_name, previous_operator_id=None, **kwargs):
+ def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, **kwargs):
user_id = None
country_id = None
# if the user is identifiy (eg: portal user on the frontend), don't use the anonymous name. The user will be added to session.
@@ -93,7 +95,16 @@ class LivechatController(http.Controller):
if previous_operator_id:
previous_operator_id = int(previous_operator_id)
- return request.env["im_livechat.channel"].with_context(lang=False).sudo().browse(channel_id)._open_livechat_mail_channel(anonymous_name, previous_operator_id, user_id, country_id)
+ chatbot_script = False
+ if chatbot_script_id:
+ chatbot_script = request.env['chatbot.script'].sudo().browse(chatbot_script_id)
+
+ return request.env["im_livechat.channel"].with_context(lang=False).sudo().browse(channel_id)._open_livechat_mail_channel(
+ anonymous_name,
+ previous_operator_id=previous_operator_id,
+ chatbot_script=chatbot_script,
+ user_id=user_id,
+ country_id=country_id)
@http.route('/im_livechat/feedback', type='json', auth='public', cors="*")
def feedback(self, uuid, rate, reason=None, **kwargs):
diff --git a/addons/im_livechat/data/im_livechat_chatbot_data.xml b/addons/im_livechat/data/im_livechat_chatbot_data.xml
new file mode 100644
index 00000000000..d48972fb4dd
--- /dev/null
+++ b/addons/im_livechat/data/im_livechat_chatbot_data.xml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+ Welcome Bot
+
+
+
+
+ Welcome to CompanyName ! 👋
+ 1
+ text
+
+
+
+
+ What are you looking for?
+ 2
+ question_selection
+
+
+
+
+ I have a pricing question
+ 1
+
+
+
+
+ I am looking for your documentation
+ /
+ 2
+
+
+
+
+ I am just looking around
+ 3
+
+
+
+
+ Hmmm, let me check if I can find someone that could help you with that...
+ 3
+ text
+
+
+
+
+
+ 4
+ forward_operator
+
+
+
+
+
+ Hu-ho, it looks like none of our operators are available 🙁
+ 5
+ text
+
+
+
+
+
+ Would you mind leaving your email address so that we can reach you back?
+ 6
+ question_email
+
+
+
+
+
+ And tadaaaa here you go! 🌟
+ 7
+ text
+
+
+
+
+
+ If you need anything else, feel free to get back in touch
+ 8
+ text
+
+
+
+
+
+ Please do! If there is anything we can help with, let us know
+ 9
+ text
+
+
+
+
+
diff --git a/addons/im_livechat/models/__init__.py b/addons/im_livechat/models/__init__.py
index 6abd12e36f4..f42a56d173e 100644
--- a/addons/im_livechat/models/__init__.py
+++ b/addons/im_livechat/models/__init__.py
@@ -1,5 +1,9 @@
# -*- 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
diff --git a/addons/im_livechat/models/chatbot_message.py b/addons/im_livechat/models/chatbot_message.py
new file mode 100644
index 00000000000..721aa194915
--- /dev/null
+++ b/addons/im_livechat/models/chatbot_message.py
@@ -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"),
+ ]
diff --git a/addons/im_livechat/models/chatbot_script.py b/addons/im_livechat/models/chatbot_script.py
new file mode 100644
index 00000000000..c6c3bb19f6f
--- /dev/null
+++ b/addons/im_livechat/models/chatbot_script.py
@@ -0,0 +1,225 @@
+# -*- 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'
+
+ # 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
+
+ answers_map = {
+ original_answer: clone_answer
+ for clone_answer, original_answer
+ in zip(clone_chatbot_script.script_step_ids.answer_ids, self.script_step_ids.answer_ids)
+ }
+
+ for clone_step, original_step in zip(clone_chatbot_script.script_step_ids, self.script_step_ids):
+ 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 = [self._prepare_operator_partner_values(
+ vals['title'],
+ vals.get('image_1920', 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 _prepare_operator_partner_values(self, name, image):
+ return {
+ 'name': name,
+ 'image_1920': image,
+ 'active': False,
+ }
+
+ 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()
+
+ chatbot_avatar_url = '/mail/static/src/img/smiley/avatar.jpg'
+ if bool(self.image_128):
+ chatbot_avatar_url = f'/web/image/chatbot.script/{self.id}/image_128'
+
+ return {
+ 'chatbot_script_id': self.id,
+ 'chatbot_name': self.title,
+ 'chatbot_operator_partner_id': self.operator_partner_id.id,
+ 'chatbot_avatar_url': chatbot_avatar_url,
+ '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,
+ }
diff --git a/addons/im_livechat/models/chatbot_script_answer.py b/addons/im_livechat/models/chatbot_script_answer.py
new file mode 100644
index 00000000000..1d14db22cd5
--- /dev/null
+++ b/addons/im_livechat/models/chatbot_script_answer.py
@@ -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='Name', 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)
diff --git a/addons/im_livechat/models/chatbot_script_step.py b/addons/im_livechat/models/chatbot_script_step.py
new file mode 100644
index 00000000000..e6c31a27209
--- /dev/null
+++ b/addons/im_livechat/models/chatbot_script_step.py
@@ -0,0 +1,375 @@
+# -*- 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%s
') % (_('Please contact me on: '), input_email)
+ if input_phone:
+ description += Markup('%s%s
') % (_('Please call me on: '), input_phone)
+ if description:
+ description += Markup('
')
+
+ 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})
+ chatbot_message.flush()
+
+ 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()
+
+ if human_operator:
+ mail_channel.sudo().add_members(
+ human_operator.partner_id.ids,
+ open_chat_window=True,
+ post_joined_message=False)
+
+ 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('
%s
') % _('%s has joined', human_operator.partner_id.name))
+
+ mail_channel._broadcast(human_operator.partner_id.ids)
+ mail_channel.channel_pin(mail_channel.uuid, 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
+ }
diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py
index a21f5ba4f0c..47aad1ced19 100644
--- a/addons/im_livechat/models/im_livechat_channel.py
+++ b/addons/im_livechat/models/im_livechat_channel.py
@@ -51,12 +51,21 @@ class ImLivechatChannel(models.Model):
# 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,
@@ -100,6 +109,18 @@ class ImLivechatChannel(models.Model):
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
# --------------------------
@@ -110,31 +131,43 @@ class ImLivechatChannel(models.Model):
self.ensure_one()
return self.user_ids.filtered(lambda user: user.im_status == 'online')
- def _get_livechat_mail_channel_vals(self, anonymous_name, operator, user_id=None, country_id=None):
+ 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
+ operator_partner_id = operator.partner_id.id if operator else chatbot_script.operator_partner_id.id
channel_partner_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 visitor_user != operator: # valid session user (not public)
+ if visitor_user and visitor_user.active and operator and visitor_user != operator: # valid session user (not public)
channel_partner_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_last_seen_partner_ids': channel_partner_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': ' '.join([visitor_user.display_name if visitor_user else anonymous_name, operator.livechat_username if operator.livechat_username else operator.name]),
+ 'name': name,
'public': 'private',
}
- def _open_livechat_mail_channel(self, anonymous_name, previous_operator_id=None, user_id=None, country_id=None):
- """ Return a mail.channel given a livechat channel. It creates one with a connected operator, or return false otherwise
+ def _open_livechat_mail_channel(self, anonymous_name, previous_operator_id=None, chatbot_script=None, user_id=None, country_id=None):
+ """ Return a mail.channel given a livechat channel. It creates one 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 channel
: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 channel
:type anonymous_name : str
@@ -145,22 +178,27 @@ class ImLivechatChannel(models.Model):
the system will first try to assign that operator if he's available (to improve user experience).
"""
self.ensure_one()
- operator = False
- if previous_operator_id:
+ 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:
- operator = next(available_user for available_user in available_users if available_user.partner_id.id == previous_operator_id)
- if not operator:
- operator = self._get_random_operator()
- if not operator:
+ 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
# create the session, and add the link with the given channel
- mail_channel_vals = self._get_livechat_mail_channel_vals(anonymous_name, operator, user_id=user_id, country_id=country_id)
+ mail_channel_vals = self._get_livechat_mail_channel_vals(anonymous_name, user_operator, chatbot_script, user_id=user_id, country_id=country_id)
mail_channel = self.env["mail.channel"].with_context(mail_create_nosubscribe=False).sudo().create(mail_channel_vals)
- mail_channel._broadcast([operator.partner_id.id])
+ if user_operator:
+ mail_channel._broadcast([user_operator.partner_id.id])
return mail_channel.sudo().channel_info()[0]
def _get_random_operator(self):
@@ -224,7 +262,7 @@ class ImLivechatChannel(models.Model):
if username is None:
username = _('Visitor')
info = {}
- info['available'] = len(self._get_available_users()) > 0
+ 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()
@@ -245,13 +283,18 @@ class ImLivechatChannelRule(models.Model):
regex_url = fields.Char('URL Regex',
help="Regular expression specifying the web pages this rule will be applied on.")
- action = fields.Selection([('display_button', 'Display the button'), ('auto_popup', 'Auto popup'), ('hide_button', 'Hide the button')],
- string='Action', required=True, default='display_button',
+ action = fields.Selection([
+ ('display_button', 'Display the LiveChat Button'),
+ ('auto_popup', 'Autopop LiveChat'),
+ ('hide_button', 'Hide all buttons')], string='Action', required=True, default='display_button',
help="* 'Display the button' displays the chat button on the pages.\n"\
"* 'Auto popup' displays the button and automatically open the conversation pane.\n"\
- "* 'Hide the button' hides the chat button on the pages.")
+ "* 'Hide the button' hides the chat button on the pages.\n")
auto_popup_timer = fields.Integer('Auto popup timer', default=0,
help="Delay (in seconds) to automatically open the conversation window. Note: the selected action must be 'Auto popup' 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',
diff --git a/addons/im_livechat/models/mail_channel.py b/addons/im_livechat/models/mail_channel.py
index efd0e104ca4..c69fa3d7cab 100644
--- a/addons/im_livechat/models/mail_channel.py
+++ b/addons/im_livechat/models/mail_channel.py
@@ -2,7 +2,9 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
-from odoo.tools import html_escape
+from odoo.tools import email_normalize, html_escape, html2plaintext, plaintext2html
+
+from markupsafe import Markup
class MailChannel(models.Model):
@@ -19,6 +21,8 @@ class MailChannel(models.Model):
livechat_active = fields.Boolean('Is livechat ongoing?', help='Livechat session is active until visitor leave the conversation.')
livechat_channel_id = fields.Many2one('im_livechat.channel', 'Channel')
livechat_operator_id = fields.Many2one('res.partner', string='Operator', help="""Operator for this specific channel""")
+ 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'))",
@@ -173,3 +177,92 @@ class MailChannel(models.Model):
'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
') % (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,
+ '%s
' % _('Restarting conversation...'))
diff --git a/addons/im_livechat/models/mail_message.py b/addons/im_livechat/models/mail_message.py
index 3e1d1f27993..f0beb2d5735 100644
--- a/addons/im_livechat/models/mail_message.py
+++ b/addons/im_livechat/models/mail_message.py
@@ -10,12 +10,33 @@ class MailMessage(models.Model):
def _message_format(self, fnames, format_reply=True):
"""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."""
+ 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)
for vals in vals_list:
message_sudo = self.browse(vals['id']).sudo().with_prefetch(self.ids)
- if message_sudo.model == 'mail.channel' and self.env['mail.channel'].browse(message_sudo.res_id).channel_type == 'livechat':
+ 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':
vals.pop('email_from')
if message_sudo.author_id.user_livechat_username:
vals['author_id'] = (message_sudo.author_id.id, message_sudo.author_id.user_livechat_username, message_sudo.author_id.user_livechat_username)
+ if mail_channel.chatbot_current_step_id \
+ and message_sudo.author_id == mail_channel.chatbot_current_step_id.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
diff --git a/addons/im_livechat/security/ir.model.access.csv b/addons/im_livechat/security/ir.model.access.csv
index c8c52517b7a..a087111783b 100644
--- a/addons/im_livechat/security/ir.model.access.csv
+++ b/addons/im_livechat/security/ir.model.access.csv
@@ -7,3 +7,11 @@ access_livechat_support_report_operator,im_livechat.report.operator,model_im_liv
access_livechat_channel_rule,im_livechat.channel.rule,model_im_livechat_channel_rule,,1,0,0,0
access_livechat_channel_rule_user,im_livechat.channel.rule,model_im_livechat_channel_rule,im_livechat_group_user,1,1,1,0
access_livechat_channel_rule_manager,im_livechat.channel.rule,model_im_livechat_channel_rule,im_livechat_group_manager,1,1,1,1
+access_chatbot_script,chatbot.script,model_chatbot_script,,1,0,0,0
+access_chatbot_script_user,chatbot.script.user,model_chatbot_script,im_livechat_group_user,1,1,1,1
+access_chatbot_script_step,chatbot.script.step,model_chatbot_script_step,,0,0,0,0
+access_chatbot_script_step_user,chatbot.script.step.user,model_chatbot_script_step,im_livechat_group_user,1,1,1,1
+access_chatbot_script_answer,chatbot.script.answer,model_chatbot_script_answer,,0,0,0,0
+access_chatbot_script_answer_user,chatbot.script.answer.user,model_chatbot_script_answer,im_livechat_group_user,1,1,1,1
+access_chatbot_message_all,chatbot.message,model_chatbot_message,,0,0,0,0
+access_chatbot_message_user,chatbot.script.user,model_chatbot_message,im_livechat_group_user,1,1,1,1
diff --git a/addons/im_livechat/static/src/img/chatbot_is_typing.gif b/addons/im_livechat/static/src/img/chatbot_is_typing.gif
new file mode 100644
index 00000000000..7fe41e7b361
Binary files /dev/null and b/addons/im_livechat/static/src/img/chatbot_is_typing.gif differ
diff --git a/addons/im_livechat/static/src/js/chatbot_script_answers_m2m_tags.js b/addons/im_livechat/static/src/js/chatbot_script_answers_m2m_tags.js
new file mode 100644
index 00000000000..6740bc7aa28
--- /dev/null
+++ b/addons/im_livechat/static/src/js/chatbot_script_answers_m2m_tags.js
@@ -0,0 +1,54 @@
+/** @odoo-module **/
+
+import { FieldMany2ManyTags } from 'web.relational_fields';
+import FieldRegistry from 'web.field_registry';
+
+const ChatbotScriptTriggeringAnswersMany2Many = FieldMany2ManyTags.extend({
+
+ /**
+ * Small rendering override that adds additional context into the linked many2one element.
+ * This allows limiting the displayed result to the chatbot we are currently configuring.
+ * See chatbot.script.answer#_name_search() for more details.
+ *
+ * @override
+ * @private
+ */
+ _renderEdit: function () {
+ const renderResult = this._super(...arguments);
+
+ if (this.many2one) {
+ this.many2one.additionalContext.force_domain_chatbot_script_id = this._getChatbotId();
+ }
+ return renderResult;
+ },
+
+ /**
+ * Return the related chatbot.script ID record that we are currently configuring.
+ *
+ * @private
+ */
+ _getChatbotId: function () {
+ const chatbotForm = this._findChatbotForm();
+ return chatbotForm
+ ? chatbotForm.model.localData[chatbotForm.handle].res_id
+ : false;
+ },
+
+ /**
+ * Loop through parents to find our chatbot.script parent form.
+ * (Not very elegant but not sure how we could do otherwise).
+ *
+ * @private
+ */
+ _findChatbotForm: function (previousParent) {
+ const parent = previousParent ? previousParent.getParent() : this.getParent();
+ if (parent.modelName === "chatbot.script") {
+ return parent;
+ } else {
+ return this._findChatbotForm(parent);
+ }
+ }
+
+});
+
+FieldRegistry.add('chatbot_triggering_answers_widget', ChatbotScriptTriggeringAnswersMany2Many);
diff --git a/addons/im_livechat/static/src/js/chatbot_script_step_o2m.js b/addons/im_livechat/static/src/js/chatbot_script_step_o2m.js
new file mode 100644
index 00000000000..5a2a5b1cb28
--- /dev/null
+++ b/addons/im_livechat/static/src/js/chatbot_script_step_o2m.js
@@ -0,0 +1,25 @@
+/** @odoo-module **/
+
+import { FieldOne2Many } from 'web.relational_fields';
+import FieldRegistry from 'web.field_registry';
+
+const ChatbotScriptStepOne2Many = FieldOne2Many.extend({
+
+ /**
+ * _render override that removes the sortable options on all columns.
+ *
+ * Indeed, we want to force the sorting by the 'sequence' field (otherwise the script will
+ * make no sense).
+ *
+ * @override
+ * @private
+ */
+ _render: function () {
+ this._super(...arguments).then(() => {
+ this.$('.o_column_sortable').removeClass('o_column_sortable');
+ });
+ },
+
+});
+
+FieldRegistry.add('chatbot_script_step_widget', ChatbotScriptStepOne2Many);
diff --git a/addons/im_livechat/static/src/js/im_livechat_chatbot_form_controller.js b/addons/im_livechat/static/src/js/im_livechat_chatbot_form_controller.js
new file mode 100644
index 00000000000..48df862a490
--- /dev/null
+++ b/addons/im_livechat/static/src/js/im_livechat_chatbot_form_controller.js
@@ -0,0 +1,92 @@
+/** @odoo-module **/
+
+import { _t } from 'web.core';
+import FormController from 'web.FormController';
+import ChatbotStepFormViewDialog from 'im_livechat.ChatbotStepFormViewDialog';
+
+const ChatbotFormController = FormController.extend({
+ custom_events: Object.assign({}, FormController.prototype.custom_events, {
+ chatbot_save_form: '_chatbotSaveForm',
+ chatbot_add_step: '_chatbotAddStep',
+ }),
+
+ /**
+ * Force the form save when a step is added.
+ *
+ * This is done in order to allow selecting previous step's answers for the
+ * 'triggering_answer_ids' fields.
+ *
+ * @param {OdooEvent} ev
+ */
+ async _chatbotSaveForm(ev) {
+ await this.saveRecord(this.handle, {
+ stayInEdit: true,
+ });
+
+ if (ev && ev.data.callback) {
+ ev.data.callback();
+ }
+
+ const state = this.model.get(this.handle);
+ this.renderer.confirmChange(state, state.id, ['script_step_ids']);
+ },
+
+ /**
+ * Steps are added using the 'script_step_ids' list view button to ensure that the sequence is
+ * correctly incremented each time we add a step.
+ *
+ * This handler will trigger the click event on the "Add a line" button of the embedded list view.
+ *
+ * @param {OdooEvent} ev
+ */
+ async _chatbotAddStep(ev) {
+ this.$('.o_field_one2many[name="script_step_ids"] .o_field_x2many_list_row_add a').click();
+ if (ev && ev.data.callback) {
+ ev.data.callback();
+ }
+ },
+
+ /**
+ * Adapt the FormView to call our custom Dialog for chatbot steps.
+ * See 'ChatbotStepFormViewDialog' for more info.
+ *
+ * The rest is copy/pasted from the initial method.
+ *
+ * @param {OdooEvent} ev
+ */
+ async _onOpenOne2ManyRecord(ev) {
+ ev.stopPropagation();
+ const data = ev.data;
+
+ if (data.field.relation !== "chatbot.script.step") {
+ return this._super(...arguments);
+ }
+
+ const record = data.id ? this.model.get(data.id, {raw: true}) : null;
+
+ // Sync with the mutex to wait for potential onchanges
+ await this.model.mutex.getUnlockedDef();
+
+ new ChatbotStepFormViewDialog(this, {
+ context: data.context,
+ domain: data.domain,
+ fields_view: data.fields_view,
+ model: this.model,
+ on_saved: data.on_saved,
+ on_remove: data.on_remove,
+ parentID: data.parentID,
+ readonly: data.readonly,
+ editable: data.editable,
+ deletable: record ? data.deletable : false,
+ disable_multiple_selection: true,
+ recordID: record && record.id,
+ res_id: record && record.res_id,
+ res_model: data.field.relation,
+ shouldSaveLocally: true,
+ title: (record ? _t("Open: ") : _t("Create ")) + (ev.target.string || data.field.string),
+ }).open();
+ },
+
+});
+
+export default ChatbotFormController;
diff --git a/addons/im_livechat/static/src/js/im_livechat_chatbot_form_view.js b/addons/im_livechat/static/src/js/im_livechat_chatbot_form_view.js
new file mode 100644
index 00000000000..3c4d2590707
--- /dev/null
+++ b/addons/im_livechat/static/src/js/im_livechat_chatbot_form_view.js
@@ -0,0 +1,26 @@
+/** @odoo-module **/
+
+import ChatbotFormController from '@im_livechat/js/im_livechat_chatbot_form_controller';
+
+import FormRenderer from 'web.FormRenderer';
+import FormView from 'web.FormView';
+import viewRegistry from 'web.view_registry';
+
+/**
+ * FormView override for the chatbot.script model.
+ * This override will properly handle chatbot.script.step sequences while building your
+ * chatbot.script, which is critically important since steps order will define how the script runs.
+ *
+ * To be used on the main chatbot.script model form view, along with its "script_step_ids" field,
+ * and not elsewhere as it would not be compatible.
+ */
+const ChatbotFormView = FormView.extend({
+ config: Object.assign({}, FormView.prototype.config, {
+ Controller: ChatbotFormController,
+ Renderer: FormRenderer,
+ }),
+});
+
+viewRegistry.add('im_livechat_chatbot_form_view', ChatbotFormView);
+
+export default ChatbotFormView;
diff --git a/addons/im_livechat/static/src/js/im_livechat_chatbot_step_form_view_dialog.js b/addons/im_livechat/static/src/js/im_livechat_chatbot_step_form_view_dialog.js
new file mode 100644
index 00000000000..755b5f46341
--- /dev/null
+++ b/addons/im_livechat/static/src/js/im_livechat_chatbot_step_form_view_dialog.js
@@ -0,0 +1,63 @@
+/** @odoo-module alias=im_livechat.ChatbotStepFormViewDialog **/
+
+import { _t } from 'web.core';
+import FormViewDialog from 'web.view_dialogs';
+
+/**
+ * Override of the FormViewDialog.
+ * We essentially ensure to trigger a form save every time a step is added.
+ */
+const ChatbotStepFormViewDialog = FormViewDialog.FormViewDialog.extend({
+ /**
+ * Overload the default buttons to add our custom ones.
+ * The main goal is to trigger a full chatbot.script form save every time a step is added.
+ * See ChatbotFormController#_saveFormBeforeNewStep()
+ */
+ init: function (parent, options) {
+ this._super(...arguments);
+
+ const readonly = _.isNumber(this.res_id) && this.readonly;
+ this.buttons = [{
+ text: _t("Save & Close"),
+ classes: "btn-primary",
+ click: () => {
+ this._save().then(() => {
+ parent.trigger_up('chatbot_save_form', {
+ callback: () => {
+ this.close();
+ }
+ });
+ });
+ },
+ }, {
+ text: _t("Save & New"),
+ classes: "btn-primary",
+ click: () => {
+ this._save().then(() => {
+ parent.trigger_up('chatbot_save_form', {
+ callback: () => {
+ parent.trigger_up('chatbot_add_step', {
+ callback: () => {this.close();}
+ });
+ }
+ });
+ });
+ },
+ }, {
+ text: options.close_text || (readonly ? _t("Close") : _t("Discard")),
+ classes: "btn-secondary o_form_button_cancel",
+ close: true,
+ hotkey: 'j',
+ click: () => {
+ if (!readonly) {
+ this.form_view.model.discardChanges(this.form_view.handle, {
+ // no need to rollback locally as we save the steps in-between each creation
+ rollback: false,
+ });
+ }
+ },
+ }];
+ }
+});
+
+export default ChatbotStepFormViewDialog;
diff --git a/addons/im_livechat/static/src/legacy/public_livechat.js b/addons/im_livechat/static/src/legacy/public_livechat.js
index c31a9d4ba78..5003d549b28 100644
--- a/addons/im_livechat/static/src/legacy/public_livechat.js
+++ b/addons/im_livechat/static/src/legacy/public_livechat.js
@@ -249,11 +249,11 @@ var LivechatButton = Widget.extend({
def = Promise.resolve(JSON.parse(cookie));
} else {
this._messages = []; // re-initialize messages cache
- def = session.rpc('/im_livechat/get_session', {
- channel_id: this.options.channel_id,
- anonymous_name: this.options.default_username,
- previous_operator_id: this._get_previous_operator_id(),
- }, { shadow: true });
+ def = session.rpc(
+ '/im_livechat/get_session',
+ this._prepareGetSessionParameters(),
+ { shadow: true },
+ );
}
def.then(function (livechatData) {
if (!livechatData || !livechatData.operator_pid) {
@@ -340,6 +340,16 @@ var LivechatButton = Widget.extend({
self.$el.hide();
});
},
+ /**
+ * @private
+ */
+ _prepareGetSessionParameters: function () {
+ return {
+ channel_id: this.options.channel_id,
+ anonymous_name: this.options.default_username,
+ previous_operator_id: this._get_previous_operator_id(),
+ };
+ },
/**
* @private
*/
diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js
new file mode 100644
index 00000000000..6439fc05721
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js
@@ -0,0 +1,861 @@
+/** @odoo-module **/
+
+import core from 'web.core';
+import localStorage from 'web.local_storage';
+import session from 'web.session';
+import time from 'web.time';
+import utils from 'web.utils';
+
+import LivechatButton from 'im_livechat.legacy.im_livechat.im_livechat';
+
+const _t = core._t;
+const QWeb = core.qweb;
+
+/**
+ * Override of the LivechatButton to include chatbot capabilities.
+ * Main changes / hooking points are:
+ * - Show a custom welcome message that is in fact the first message of the chatbot script
+ * - When messages are rendered, add click handles to chatbot options
+ * - When the user picks an option or answers to the chatbot, display a "chatbot is typing..."
+ * message for a couple seconds and then trigger the next step of the script
+ */
+ LivechatButton.LivechatButton.include({
+ init: function () {
+ this._super(...arguments);
+
+ this._chatbotMessageDelay = 3500; // in milliseconds
+
+ // debounced to let the user type several sentences, see '_chatbotAwaitUserInput' for details
+ this._debouncedChatbotAwaitUserInput = _.debounce(
+ this._chatbotAwaitUserInput.bind(this),
+ 10000);
+ },
+
+ /**
+ * This override handles the following use cases:
+ *
+ * - If the chat is started for the first time (first visit of a visitor)
+ * We register the chatbot configuration and the rest of the behavior is triggered by various
+ * method overrides ('sendWelcomeMessage', 'sendMessage', ...)
+ *
+ * - If the chat has been started before, but the user did not interact with the bot
+ * The default behavior is to open an empty chat window, without any messages.
+ * In addition, we fetch the configuration (with a '/init' call), to see if we have a bot
+ * configured.
+ * Indeed we want to trigger the bot script on every page where the associated rule is matched.
+ *
+ * - If we have a non-empty chat history, resume the chat script where the end-user left it by
+ * fetching the necessary information from the local storage.
+ *
+ * @override
+ */
+ willStart: async function () {
+ const superResult = await this._super(...arguments);
+
+ this._isChatbot = false;
+ this.chatbotState = null;
+
+ if (this._rule && !!this._rule.chatbot) {
+ this._isChatbot = true;
+ this.chatbotState = 'init';
+ } else if (this._history !== null && this._history.length === 0) {
+ this._livechatInit = await session.rpc('/im_livechat/init', {channel_id: this.options.channel_id});
+
+ if (this._livechatInit.rule.chatbot) {
+ this._isChatbot = true;
+ this.chatbotState = 'welcome';
+ }
+ } else if (this._history !== null && this._history.length !== 0) {
+ const sessionCookie = utils.get_cookie('im_livechat_session');
+ if (sessionCookie) {
+ const sessionKey = 'im_livechat.chatbot.state.uuid_' + JSON.parse(sessionCookie).uuid;
+ if (localStorage.getItem(sessionKey)) {
+ this._isChatbot = true;
+ this.chatbotState = 'restore_session';
+ }
+ }
+ }
+
+ if (this._isChatbot) {
+ // void the default livechat placeholder in the user input
+ // as we use it for specific things (e.g: showing "please select an option above")
+ this.options.input_placeholder = '';
+ }
+
+ if (this.chatbotState === 'init') {
+ // we landed on a website page where a channel rule is configured to run a chatbot.script
+ // -> initialize necessary state
+ this._chatbot = this._rule.chatbot;
+ if (this._rule.chatbot_welcome_steps && this._rule.chatbot_welcome_steps.length !== 0) {
+ this._chatbotCurrentStep = this._chatbot.chatbot_welcome_steps[
+ this._chatbot.chatbot_welcome_steps.length - 1];
+ }
+ } else if (this.chatbotState === 'welcome') {
+ // we landed on a website page and a chatbot script was initialized on a previous one
+ // however the end-user did not interact with the bot ( :( )
+ // -> remove cookie to force opening the popup again
+ // -> initialize necessary state
+ // -> batch welcome message (see '_sendWelcomeChatbotMessage')
+ utils.set_cookie('im_livechat_auto_popup', '', -1);
+ this._history = null;
+ this._rule = this._livechatInit.rule;
+ this._chatbot = this._livechatInit.rule.chatbot;
+ this._isChatbot = true;
+ this._chatbotBatchWelcomeMessages = true;
+ } else if (this.chatbotState === 'restore_session') {
+ // we landed on a website page and a chatbot script is currently running
+ // -> restore the user's session (see '_chatbotRestoreSession')
+ this._chatbotRestoreSession(utils.get_cookie('im_livechat_session'));
+ }
+
+ return superResult;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private - Chatbot specifics
+ //--------------------------------------------------------------------------
+
+ /**
+ * Add message posted by the bot into the conversation.
+ * This allows not having to wait for the bus (since we run checks based on messages in the
+ * conversation, having the result be there immediately eases the process).
+ *
+ * It also helps while running test tours since those don't have the bus enabled.
+ */
+ _chatbotAddMessage: function (message, options) {
+ message.body = utils.Markup(message.body);
+ this._addMessage(message, options);
+ if (this._chatWindow.isFolded() || !this._chatWindow.isAtBottom()) {
+ this._livechat.incrementUnreadCounter();
+ }
+
+ if (!options || !options.skipRenderMessages) {
+ this._renderMessages();
+ }
+ },
+
+ /**
+ * This method will be transformed into a 'debounced' version (see init).
+ *
+ * The purpose is to handle steps of type 'free_input_multi', that will let the user type in
+ * multiple lines of text before the bot goes to the next step.
+ *
+ * Every time a 'keydown' is detected into the input, or every time a message is sent, we call
+ * this debounced method, which will give the user about 10 seconds to type more text before
+ * the next step is triggered.
+ *
+ * First we check if the last message was sent by the user, to make sure we always let him type
+ * at least one message before moving on.
+ */
+ _chatbotAwaitUserInput: function () {
+ if (this._isLastMessageFromCustomer()) {
+ if (this._chatbotShouldEndScript()) {
+ this._chatbotEndScript();
+ } else {
+ this._chatbotSetIsTyping();
+ this.nextStepTimeout = setTimeout(
+ this._chatbotTriggerNextStep.bind(this), this._chatbotMessageDelay);
+ }
+ }
+ },
+
+ /**
+ * When the user first interacts with the bot, we want to make sure to actually post the welcome
+ * messages into the conversation.
+ *
+ * Indeed, before that, they are 'virtual' messages that are not tied to mail.messages, see
+ * #_sendWelcomeChatbotMessage() for more information.
+ *
+ * Posting them as real messages allows to have a cleaner model and conversation, that will be
+ * kept intact when changing page on the website.
+ *
+ * It also allows tying any first response / question_selection choice to a chatbot.message
+ * that has a linked mail.message.
+ */
+ _chatbotPostWelcomeMessages: async function () {
+ const welcomeMessages = this._getWelcomeMessages();
+
+ if (welcomeMessages.length === 0) {
+ // we already posted the welcome messages, nothing to do
+ return;
+ }
+
+ const postedWelcomeMessages = await session.rpc('/chatbot/post_welcome_steps', {
+ channel_uuid: this._livechat.getUUID(),
+ chatbot_script_id: this._chatbot.chatbot_script_id,
+ });
+
+ const welcomeMessagesIds = welcomeMessages.map(welcomeMessage => welcomeMessage._id);
+ this._messages = this._messages.filter((message) => {
+ !welcomeMessagesIds.includes(message._id);
+ });
+
+ postedWelcomeMessages.reverse();
+ postedWelcomeMessages.forEach((message) => {
+ this._chatbotAddMessage(message, {
+ prepend: true,
+ skipRenderMessages: true,
+ });
+ });
+
+ this._renderMessages();
+ },
+
+ /**
+ * Disable the input allowing the user to type.
+ * This is typically used when we want to force him to click on one of the chatbot options.
+ *
+ * @private
+ */
+ _chatbotDisableInput: function (disableText) {
+ this._chatWindow.$('.o_composer_text_field')
+ .prop('disabled', true)
+ .addClass('text-center font-italic bg-200')
+ .val(disableText);
+ },
+
+ /**
+ * @private
+ */
+ _chatbotEnableInput: function () {
+ const $composerTextField = this._chatWindow.$('.o_composer_text_field');
+ $composerTextField
+ .prop('disabled', false)
+ .removeClass('text-center font-italic bg-200')
+ .val('')
+ .focus();
+
+ if (this.chatbotInputKeyDownHandler) {
+ $composerTextField.off('keydown', this.chatbotInputKeyDownHandler);
+ }
+
+ if (this._chatbotCurrentStep.chatbot_step_type === 'free_input_multi') {
+ this.chatbotInputKeyDownHandler = this._onChatbotInputKeyDown.bind(this);
+ $composerTextField.on('keydown', this.chatbotInputKeyDownHandler);
+ }
+ },
+ /**
+ * Once the script ends, adds a visual element at the end of the chat window allowing to restart
+ * the whole script.
+ *
+ * @private
+ */
+ _chatbotEndScript: function () {
+ this._chatWindow.$('.o_composer_text_field').addClass('d-none');
+ this._chatWindow.$('.o_livechat_chatbot_end').removeClass('d-none');
+ this._chatWindow.$('.o_livechat_chatbot_restart').one('click',
+ this._onChatbotRestartScript.bind(this));
+
+ },
+ /**
+ * Will display a "Restart script" button in the conversation toolbar.
+ *
+ * Side-case: if the conversation has been forwarded to a human operator, we don't want to
+ * display that restart button.
+ *
+ * @private
+ */
+ _chatbotDisplayRestartButton: function () {
+ return this._isChatbot && (!this._chatbotCurrentStep ||
+ (this._chatbotCurrentStep.chatbot_step_type !== 'forward_operator' ||
+ !this._chatbotCurrentStep.chatbot_operator_found));
+ },
+ /**
+ * Works as a hook since other modules can add their own step types.
+ *
+ * @private
+ */
+ _chatbotIsExpectingUserInput: function () {
+ return [
+ 'question_phone',
+ 'question_email',
+ 'free_input_single',
+ 'free_input_multi',
+ ].includes(this._chatbotCurrentStep.chatbot_step_type);
+ },
+ /**
+ * Processes the step, depending on the current state of the script and the author of the last
+ * message that was typed into the conversation.
+ *
+ * This is a rather complicated process since we have many potential states to handle.
+ * Here are the detailed possible outcomes:
+ *
+ * - Check if the script is finished, and if so end it.
+ *
+ * - If a human operator has taken over the conversation
+ * -> enable the input and let the operator handle the visitor.
+ *
+ * - If the received step is of type expecting an input from the user
+ * - the last message if from the user (he has already answered)
+ * -> trigger the next step
+ * - otherwise
+ * -> enable the input and let the user type
+ *
+ * - Otherwise
+ * - if the the step is of type 'question_selection' and we are still waiting for the user to
+ * select one of the options
+ * -> don't do anything, wait for the user to click one of the options
+ * - otherwise
+ * -> trigger the next step
+ *
+ * @private
+ */
+ _chatbotProcessStep: function () {
+ if (this._chatbotShouldEndScript()) {
+ this._chatbotEndScript();
+ } else if (this._chatbotCurrentStep.chatbot_step_type === 'forward_operator'
+ && this._chatbotCurrentStep.chatbot_operator_found) {
+ this._chatbotEnableInput();
+ } else if (this._chatbotIsExpectingUserInput()) {
+ if (this._isLastMessageFromCustomer()) {
+ // user has already typed a message in -> trigger next step
+ this._chatbotSetIsTyping();
+ this.nextStepTimeout = setTimeout(
+ this._chatbotTriggerNextStep.bind(this), this._chatbotMessageDelay);
+ } else {
+ this._chatbotEnableInput();
+ }
+ } else {
+ let triggerNextStep = true;
+ if (this._chatbotCurrentStep.chatbot_step_type === 'question_selection') {
+ if (!this._isLastMessageFromCustomer()) {
+ // if there is no last message or if the last message is from the bot
+ // -> don't trigger the next step, we are waiting for the user to pick an option
+ triggerNextStep = false;
+ }
+ }
+
+ if (triggerNextStep) {
+ let nextStepDelay = this._chatbotMessageDelay;
+ if (this._chatWindow.$('.o_livechat_chatbot_typing').length !== 0) {
+ // special case where we already have a "is typing" message displayed
+ // can happen when the previous step did not trigger any message posted from the bot
+ // e.g: previous step was "forward_operator" and no-one is available
+ // -> in that case, don't wait and trigger the next step immediately
+ nextStepDelay = 0;
+ } else {
+ this._chatbotSetIsTyping();
+ }
+
+ this.nextStepTimeout = setTimeout(
+ this._chatbotTriggerNextStep.bind(this), nextStepDelay);
+ }
+ }
+
+ if (!this._chatbotDisplayRestartButton()) {
+ this._chatWindow.$('.o_livechat_chatbot_main_restart').addClass('d-none');
+ }
+ },
+ /**
+ * See '_chatbotSaveSession'.
+ *
+ * We retrieve the livechat uuid from the session cookie since the livechat Widget is not yet
+ * initialized when we restore the chatbot state.
+ *
+ * We also clear any older keys that store a previously saved chatbot session.
+ * (In that case we clear the actual browser's local storage, we don't use the localStorage
+ * object as it does not allow browsing existing keys, see 'local_storage.js'.)
+ *
+ * @private
+ */
+ _chatbotRestoreSession: function (sessionCookie) {
+ const sessionKey = 'im_livechat.chatbot.state.uuid_' + JSON.parse(sessionCookie).uuid;
+ const browserLocalStorage = window.localStorage;
+ if (browserLocalStorage && browserLocalStorage.length) {
+ for (let i = 0; i < browserLocalStorage.length; i++) {
+ const key = browserLocalStorage.key(i);
+ if (key.startsWith('im_livechat.chatbot.state.uuid_') && key !== sessionKey) {
+ browserLocalStorage.removeItem(key);
+ }
+ }
+ }
+
+ let chatbotState = localStorage.getItem(sessionKey);
+
+ if (chatbotState) {
+ chatbotState = JSON.parse(chatbotState);
+ this._isChatbot = true;
+ this._chatbot = chatbotState._chatbot;
+ this._chatbotCurrentStep = chatbotState._chatbotCurrentStep;
+ }
+ },
+ /**
+ * Register current chatbot step state into localStorage to be able to resume if the visitor
+ * goes to another website page or if he refreshes his page.
+ *
+ * (Will not work if the visitor switches browser but his livechat session will not be restored
+ * anyway in that case, since it's stored into a cookie).
+ *
+ * @private
+ */
+ _chatbotSaveSession: function () {
+ const chatUuid = this._livechat.toData().uuid;
+ localStorage.setItem('im_livechat.chatbot.state.uuid_' + chatUuid, JSON.stringify({
+ '_chatbot': this._chatbot,
+ '_chatbotCurrentStep': this._chatbotCurrentStep,
+ }));
+ },
+ /**
+ * Adds a small "is typing" animation into the chat window.
+ *
+ * @private
+ */
+ _chatbotSetIsTyping: function (isWelcomeMessage=false) {
+ if (this.isTypingTimeout) {
+ clearTimeout(this.isTypingTimeout);
+ }
+
+ this._chatbotDisableInput('');
+
+ this.isTypingTimeout = setTimeout(() => {
+ this._chatWindow.$('.o_mail_thread_content').append(
+ $(QWeb.render('im_livechat.legacy.chatbot.is_typing_message', {
+ 'chatbotImageSrc': this._chatbot.chatbot_avatar_url,
+ 'chatbotName': this._chatbot.chatbot_name,
+ 'isWelcomeMessage': isWelcomeMessage,
+ }))
+ );
+
+ this._chatWindow.scrollToBottom();
+ }, this._chatbotMessageDelay / 3);
+ },
+ /**
+ * Helper method that checks if the script should be ended or not.
+ * There are 2 use cases where we want to end the script:
+ *
+ * If the current step is the last one AND the conversation was not taken over by a human operator
+ * 1. AND we expect a user input (or we are on a selection)
+ * AND the user has already answered
+ * 2. AND we don't expect a user input
+ *
+ * @returns {Boolean}
+ * @private
+ */
+ _chatbotShouldEndScript: function () {
+ if (this._chatbotCurrentStep.chatbot_step_is_last &&
+ (this._chatbotCurrentStep.chatbot_step_type !== 'forward_operator' ||
+ !this._chatbotCurrentStep.chatbot_operator_found)) {
+ if (this._chatbotCurrentStep.chatbot_step_type === 'question_email'
+ && !this._chatbotCurrentStep.is_email_valid) {
+ // email is not (yet) valid, let the user answer / try again
+ return false;
+ } else if ((this._chatbotIsExpectingUserInput() ||
+ this._chatbotCurrentStep.chatbot_step_type === 'question_selection') &&
+ this._messages.length !== 0) {
+ const lastMessage = this._messages[this._messages.length - 1];
+ if (lastMessage.getAuthorID() !== this._livechat.getOperatorPID()[0]) {
+ // we are on the last step of the script, expect a user input and the user has
+ // already answered
+ // -> end the script
+ return true;
+ }
+ } else if (!this._chatbotIsExpectingUserInput()) {
+ // we are on the last step of the script and we do not expect a user input
+ // -> end the script
+ return true;
+ }
+ }
+
+ return false;
+ },
+ /**
+ * A special case is handled for email steps, where we first validate the email (server side)
+ * and we allow the user to try again in case the format is incorrect.
+ *
+ * The validation is made server-side to have the same test when we validate here and when we
+ * register the answer, but also to easily post a message as the bot ("Sorry, try again...").
+ *
+ * Returns a boolean stating whether the email was valid or not.
+ *
+ * @private
+ */
+ _chatbotValidateEmail: async function () {
+ let emailValidResult = await session.rpc('/chatbot/step/validate_email', {
+ channel_uuid: this._livechat.getUUID(),
+ });
+
+ if (emailValidResult.success) {
+ this._chatbotCurrentStep.is_email_valid = true;
+ this._chatbotSaveSession();
+
+ return true;
+ } else {
+ // email is not valid, let the user try again
+ this._chatbotEnableInput();
+ if (emailValidResult.posted_message) {
+ this._chatbotAddMessage(emailValidResult.posted_message);
+ }
+
+ return false;
+ }
+ },
+ /**
+ * Triggers the next step of the script by calling the associated route.
+ * This will receive the next step and call step processing.
+ *
+ * @private
+ */
+ _chatbotTriggerNextStep: async function () {
+ let triggerNextStep = true;
+ if (this._chatbotCurrentStep && this._chatbotCurrentStep.chatbot_step_type === 'question_email') {
+ triggerNextStep = await this._chatbotValidateEmail();
+ }
+
+ if (!triggerNextStep) {
+ return;
+ }
+
+ const nextStep = await session.rpc('/chatbot/step/trigger', {
+ channel_uuid: this._livechat.getUUID(),
+ chatbot_script_id: this._chatbot.chatbot_script_id,
+ });
+
+ if (nextStep) {
+ if (nextStep.chatbot_posted_message) {
+ this._chatbotAddMessage(nextStep.chatbot_posted_message);
+ }
+
+ this._chatbotCurrentStep = nextStep.chatbot_step;
+
+ this._chatbotProcessStep();
+ } else {
+ // did not find next step -> end the script
+ this._chatbotCurrentStep['chatbot_step_is_last'] = true;
+ this._renderMessages();
+ this._chatbotEndScript();
+ }
+
+ this._chatbotSaveSession();
+
+ return nextStep;
+ },
+ /**
+ * Returns the 'this._messages' filtered on our special 'welcome' ones.
+ * See '_sendWelcomeChatbotMessage'.
+ *
+ * @private
+ */
+ _getWelcomeMessages: function () {
+ return this._messages.filter((message) => {
+ return message._id && typeof message._id === 'string' && message._id.startsWith('_welcome_');
+ });
+ },
+ /**
+ * Compares the last message of the conversation to this livechat's operator id.
+ *
+ * @private
+ */
+ _isLastMessageFromCustomer: function () {
+ const lastMessage = this._messages.length !== 0 ? this._messages[this._messages.length - 1] : null;
+ return lastMessage && lastMessage.getAuthorID() !== this._livechat.getOperatorPID()[0];
+ },
+
+ //--------------------------------------------------------------------------
+ // Private - LiveChat Overrides
+ //--------------------------------------------------------------------------
+
+ /**
+ * When we enter the "ask feedback" process of the chat, we hide some elements that become
+ * unnecessary and irrelevant (restart / end messages, any text field values, ...).
+ *
+ * @override
+ * @private
+ */
+ _askFeedback: function () {
+ this._super(...arguments);
+
+ this._chatWindow.$('.o_livechat_chatbot_main_restart').addClass('d-none');
+ this._chatWindow.$('.o_livechat_chatbot_end').addClass('d-none');
+ this._chatWindow.$('.o_composer_text_field')
+ .removeClass('d-none')
+ .val('');
+ },
+ /**
+ * Resuming the chatbot script if we are currently running one.
+ *
+ * In addition, we register a resize event on the window object to scroll messages to bottom.
+ * This is done especially for mobile (Android) where the keyboard opens upon focusing the input
+ * field and shrinks the whole window size.
+ * Scrolling to the bottom allows the user to see the last messages properly when that happens.
+ *
+ * @private
+ * @override
+ */
+ _openChatWindow: function () {
+ return this._super(...arguments).then(() => {
+ window.addEventListener('resize', () => {
+ if (this._chatWindow) {
+ this._chatWindow.scrollToBottom();
+ }
+ });
+
+ if (this._chatbotCurrentStep && this._messages && this._messages.length !== 0) {
+ this._chatbotProcessStep();
+ }
+ });
+ },
+ /**
+ * @private
+ * @override
+ */
+ _prepareGetSessionParameters: function () {
+ const parameters = this._super(...arguments);
+
+ if (this._isChatbot) {
+ parameters.chatbot_script_id = this._chatbot.chatbot_script_id;
+ }
+
+ return parameters;
+ },
+ /**
+ * @private
+ */
+ _renderMessages: function () {
+ this._super(...arguments);
+
+ var self = this;
+
+ this._chatWindow.$('.o_thread_message:last .o_livechat_chatbot_options li').each(function () {
+ $(this).on('click', self._onChatbotOptionClicked.bind(self));
+ });
+
+ this._chatWindow.$('.o_livechat_chatbot_main_restart').on('click',
+ this._onChatbotRestartScript.bind(this));
+
+ if (this._messages.length !== 0) {
+ const lastMessage = this._messages[this._messages.length - 1];
+ const stepAnswers = lastMessage.getChatbotStepAnswers();
+ if (stepAnswers && stepAnswers.length !== 0 && !lastMessage.getChatbotStepAnswerId()) {
+ this._chatbotDisableInput(_t('Select an option above'));
+ }
+ }
+ },
+ /**
+ * When the Customer sends a message, we need to act depending on our current state:
+ * - If the conversation has been forwarded to an operator
+ * Then there is nothing to do, we let them speak
+ * - If we are currently on a 'free_input_multi' step
+ * Await more user input (see #_chatbotAwaitUserInput() for details)
+ * - Otherwise we continue the script or end it if it's the last step
+ *
+ * We also save the current session state.
+ * Important as this may be the very first interaction with the bot, we need to save right away
+ * to correctly handle any page redirection / page refresh.
+ *
+ * Special side case: if we are currently redirecting to another page (see '_onChatbotOptionClicked')
+ * we shortcut the process as we are currently moving to a different URL.
+ * The script will be resumed on the new page (if in the same website domain).
+ *
+ * @private
+ */
+ _sendMessage: async function (message) {
+ const superArguments = arguments;
+ const superMethod = this._super;
+
+ if (this._isChatbot && this._chatbotCurrentStep) {
+ await this._chatbotPostWelcomeMessages();
+ }
+
+ return superMethod.apply(this, superArguments).then(() => {
+ if (this._isChatbotRedirecting) {
+ return;
+ }
+
+ if (this._isChatbot && this._chatbotCurrentStep) {
+ if (this._chatbotCurrentStep.chatbot_step_type === 'forward_operator' &&
+ this._chatbotCurrentStep.chatbot_operator_found) {
+ return; // operator has taken over the conversation, let them speak
+ } else if (this._chatbotCurrentStep.chatbot_step_type === 'free_input_multi') {
+ this._debouncedChatbotAwaitUserInput();
+ } else if (!this._chatbotShouldEndScript()) {
+ this._chatbotSetIsTyping();
+ this.nextStepTimeout = setTimeout(
+ this._chatbotTriggerNextStep.bind(this), this._chatbotMessageDelay);
+ } else {
+ this._chatbotEndScript();
+ }
+
+ this._chatbotSaveSession();
+ }
+ });
+ },
+ /**
+ * Small override to handle chatbot welcome message(s).
+ * @private
+ */
+ _sendWelcomeMessage: function () {
+ if (this._isChatbot) {
+ this._sendWelcomeChatbotMessage(
+ 0,
+ this._chatbotBatchWelcomeMessages ? 0 : this._chatbotMessageDelay,
+ );
+ } else {
+ this._super(...arguments);
+ }
+ },
+ /**
+ * The bot can say multiple messages in quick succession as "welcome messages".
+ * (See chatbot.script#_get_welcome_steps() for more details).
+ *
+ * It is important that those messages are sent as "welcome messages", meaning manually added
+ * within the template, without creating actual mail.messages in the mail.channel.
+ *
+ * Indeed, if the end-user never interacts with the bot, those empty mail.channels are deleted
+ * by a garbage collector mechanism.
+ *
+ * About "welcomeMessageDelay":
+ *
+ * The first time we open the chat, we want to bot to slowly input those messages in one at a
+ * time, with pauses during which the end-user sees ("The bot is typing...").
+ *
+ * However, if the user navigates within the website (meaning he has an opened mail.channel),
+ * then we input all the welcome messages at once without pauses, to avoid having that annoying
+ * slow effect on every page / refresh.
+ *
+ * @private
+ */
+ _sendWelcomeChatbotMessage: function (stepIndex, welcomeMessageDelay) {
+ const chatbotStep = this._chatbot.chatbot_welcome_steps[stepIndex];
+ this._chatbotCurrentStep = chatbotStep;
+
+ if (chatbotStep.chatbot_step_message) {
+ this._addMessage({
+ id: '_welcome_' + stepIndex,
+ is_discussion: true, // important for css style -> we only want white background for chatbot
+ attachment_ids: [],
+ author_id: this._livechat.getOperatorPID(),
+ body: utils.Markup(chatbotStep.chatbot_step_message),
+ chatbot_script_step_id: chatbotStep.chatbot_script_step_id,
+ chatbot_step_answers: chatbotStep.chatbot_step_answers,
+ date: time.datetime_to_str(new Date()),
+ model: "mail.channel",
+ message_type: "comment",
+ res_id: this._livechat.getID(),
+ });
+ }
+
+ if (stepIndex + 1 < this._chatbot.chatbot_welcome_steps.length) {
+ if (welcomeMessageDelay !== 0) {
+ this._chatbotSetIsTyping(true);
+ }
+
+ this.welcomeMessageTimeout = setTimeout(() => {
+ this._sendWelcomeChatbotMessage(stepIndex + 1, welcomeMessageDelay);
+ this._renderMessages();
+ }, welcomeMessageDelay);
+ } else {
+ if (this._chatbotCurrentStep.chatbot_step_type === 'forward_operator') {
+ // special case when the last welcome message is a forward to an operator
+ // we need to save the welcome messages before continuing the script
+ // indeed, if there are no operator available, the script will continue
+ // with steps that are NOT included in the welcome messages
+ // (hence why we need to have those welcome messages posted BEFORE that)
+ this._chatbotPostWelcomeMessages();
+ }
+
+ // we are done posting welcome messages, let's start the actual script
+ this._chatbotProcessStep();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Restart the script and then trigger the "next step" (which will be the first of the script
+ * in this case).
+ *
+ * @private
+ */
+ _onChatbotRestartScript: async function (ev) {
+ this._chatWindow.$('.o_composer_text_field').removeClass('d-none');
+ this._chatWindow.$('.o_livechat_chatbot_end').addClass('d-none');
+
+ if (this.nextStepTimeout) {
+ clearTimeout(this.nextStepTimeout);
+ }
+
+ if (this.welcomeMessageTimeout) {
+ clearTimeout(this.welcomeMessageTimeout);
+ }
+
+ const postedMessage = await session.rpc('/chatbot/restart', {
+ channel_uuid: this._livechat.getUUID(),
+ chatbot_script_id: this._chatbot.chatbot_script_id,
+ });
+
+ if (postedMessage) {
+ this._chatbotAddMessage(postedMessage);
+ }
+
+ this._chatbotCurrentStep = null;
+ this._chatbotSetIsTyping();
+ this.nextStepTimeout = setTimeout(
+ this._chatbotTriggerNextStep.bind(this), this._chatbotMessageDelay);
+ },
+
+ _onChatbotInputKeyDown: function () {
+ if (this._chatbotCurrentStep &&
+ this._chatbotCurrentStep.chatbot_step_type === 'free_input_multi') {
+ this._debouncedChatbotAwaitUserInput();
+ }
+ },
+
+ /**
+ * Saves the selected chatbot.script.answer onto our chatbot.message.
+ * Will update the state of the related message (in this._messages) to set the selected option
+ * as well which will in turn adapt the display to not show options anymore.
+ *
+ * This method also handles an optional redirection link placed on the chatbot.script.answer and
+ * will make sure to properly save the selected choice before redirecting.
+ *
+ * @param {MouseEvent} ev
+ * @private
+ */
+ _onChatbotOptionClicked: async function (ev) {
+ ev.stopPropagation();
+
+ const $target = $(ev.currentTarget);
+ const stepId = $target.closest('ul').data('chatbotStepId');
+ const selectedAnswer = $target.data('chatbotStepAnswerId');
+
+ const redirectLink = $target.data('chatbotStepRedirectLink');
+ this._isChatbotRedirecting = !!redirectLink;
+
+ await this._sendMessage({
+ content: $target.text().trim(),
+ });
+
+ let stepMessage = null;
+ this._messages.forEach((message) => {
+ // we do NOT want to use a 'find' here because we want the LAST message that respects
+ // this condition.
+ // indeed, if you restart the script, you can have multiple messages with the same step id,
+ // but here we only care about the very last one (the current step of the script)
+ // reversing the this.messages variable seems like a bad idea because it could have
+ // bad implications for other flows (as the reverse is in-place, not in a copy)
+ if (message.getChatbotStepId() === stepId) {
+ stepMessage = message;
+ }
+ });
+ const messageId = stepMessage.getID();
+ stepMessage.setChatbotStepAnswerId(selectedAnswer);
+ this._chatbotCurrentStep['chatbot_selected_answer_id'] = selectedAnswer;
+ this._renderMessages();
+ this._chatbotSaveSession();
+
+ const saveAnswerPromise = session.rpc('/chatbot/answer/save', {
+ channel_uuid: this._livechat.getUUID(),
+ message_id: messageId,
+ selected_answer_id: selectedAnswer,
+ });
+
+ if (redirectLink) {
+ await saveAnswerPromise; // ensure answer is saved before redirecting
+ window.location = redirectLink;
+ }
+ },
+});
+
+export default LivechatButton;
diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.scss b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.scss
new file mode 100644
index 00000000000..c254543214a
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.scss
@@ -0,0 +1,33 @@
+.o_thread_window {
+ .o_livechat_chatbot_main_restart {
+ color: white;
+ @include o-hover-opacity(0.7, 1);
+
+ @include media-breakpoint-down(sm) {
+ // copied from 'o_thread_window_close'
+ $o-close-font-size: 17px;
+ padding: (($o-mail-chat-header-height - $o-close-font-size) / 2);
+ font-size: $o-close-font-size;
+ opacity: 1;
+ }
+ }
+
+ .o_livechat_chatbot_options {
+ list-style: none;
+ margin-block-start: 0px;
+ margin-block-end: 0px;
+ padding-inline-start: 0px;
+
+ li {
+ user-select: none;
+ &:not(.disabled) {
+ cursor: pointer;
+
+ &:hover {
+ color: white;
+ background-color: $primary;
+ }
+ }
+ }
+ }
+}
diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml
new file mode 100644
index 00000000000..bfda7281e4c
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
Conversation ended...
+
Restart
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+
+
+
diff --git a/addons/im_livechat/static/src/legacy/website_livechat_message_chatbot.js b/addons/im_livechat/static/src/legacy/website_livechat_message_chatbot.js
new file mode 100644
index 00000000000..fc723b6bb95
--- /dev/null
+++ b/addons/im_livechat/static/src/legacy/website_livechat_message_chatbot.js
@@ -0,0 +1,81 @@
+/** @odoo-module **/
+
+import WebsiteLivechatMessage from 'im_livechat.legacy.im_livechat.model.WebsiteLivechatMessage';
+
+/**
+ * Override of the WebsiteLivechatMessage that includes chatbot capabilities.
+ * The main changes are:
+ * - Allow to display options for the end-user to click on ("_chatbotStepAnswers")
+ * - Show a different name/icon for the chatbot (instead of the 'OdooBot' operator name/icon)
+ */
+WebsiteLivechatMessage.include({
+ /**
+ * @param {im_livechat.legacy.im_livechat.im_livechat.LivechatButton} parent
+ * @param {Object} data
+ * @param {Object} options
+ * @param {string} options.default_username
+ * @param {string} options.serverURL
+ */
+ init: function (parent, data, options) {
+ this._super(...arguments);
+
+ if (parent._isChatbot) {
+ this._chatbotAvatarUrl = parent._chatbot.chatbot_avatar_url;
+ this._chatbotId = parent._chatbot.chatbot_script_id;
+ this._chatbotName = parent._chatbot.chatbot_name;
+ this._chatbotOperatorPartnerId = parent._chatbot.chatbot_operator_partner_id;
+
+ this._chatbotStepId = data.chatbot_script_step_id;
+ this._chatbotStepAnswers = data.chatbot_step_answers;
+ this._chatbotStepAnswerId = data.chatbot_selected_answer_id;
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Small override to return a placeholder in case there is no image configured on the chatbot.
+ */
+ getAvatarSource: function () {
+ if (this._chatbotAvatarUrl && this.getAuthorID() === this._chatbotOperatorPartnerId) {
+ return this._chatbotAvatarUrl;
+ } else {
+ return this._super(...arguments);
+ }
+ },
+
+ /**
+ * Get chat bot script step ID
+ *
+ * @return {string}
+ */
+ getChatbotStepId: function () {
+ return this._chatbotStepId;
+ },
+ /**
+ * Get chat bot script answers
+ *
+ * @return {string}
+ */
+ getChatbotStepAnswers: function () {
+ return this._chatbotStepAnswers;
+ },
+ /**
+ * Get chat bot script answer ID
+ *
+ * @return {string}
+ */
+ getChatbotStepAnswerId: function () {
+ return this._chatbotStepAnswerId;
+ },
+ /**
+ * @override
+ */
+ setChatbotStepAnswerId: function (chatbotStepAnswerId) {
+ this._chatbotStepAnswerId = chatbotStepAnswerId;
+ }
+});
+
+export default WebsiteLivechatMessage;
diff --git a/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js b/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js
new file mode 100644
index 00000000000..01afde50762
--- /dev/null
+++ b/addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js
@@ -0,0 +1,138 @@
+/** @odoo-module */
+
+import tour from "web_tour.tour";
+
+const commonSteps = [tour.stepUtils.showAppsMenuItem(), {
+ trigger: '.o_app[data-menu-xmlid="im_livechat.menu_livechat_root"]',
+}, {
+ trigger: 'button[data-menu-xmlid="im_livechat.livechat_config"]',
+}, {
+ trigger: 'a[data-menu-xmlid="im_livechat.chatbot_config"]',
+}, {
+ trigger: '.o_list_button_add',
+}, {
+ trigger: 'input[name="title"]',
+ run: 'text Test Chatbot Sequence'
+}, {
+ trigger: 'div[name="script_step_ids"] .o_field_x2many_list_row_add a'
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 1'
+}, {
+ trigger: 'button:contains("Save & New")'
+}, {
+ trigger: 'tr:contains("Step 1")',
+ in_modal: false,
+ run: () => {}
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 2'
+}, {
+ trigger: 'button:contains("Save & New")'
+}, {
+ trigger: 'tr:contains("Step 2")',
+ in_modal: false,
+ run: () => {}
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 3'
+}];
+
+
+/**
+ * Simply create a few steps in order to check the sequences.
+ */
+ tour.register('im_livechat_chatbot_steps_sequence_tour', {
+ test: true,
+ url: '/web',
+}, [].concat(commonSteps, [{
+ trigger: 'button:contains("Save & Close")'
+}, {
+ trigger: 'body.o_web_client:not(.modal-open)',
+ run: () => {}
+}]));
+
+/**
+ * Same as above, with an extra drag&drop at the end.
+ */
+tour.register('im_livechat_chatbot_steps_sequence_with_move_tour', {
+ test: true,
+ url: '/web',
+}, [].concat(commonSteps, [{
+ trigger: 'button:contains("Save & New")'
+}, {
+ trigger: 'tr:contains("Step 3")',
+ in_modal: false,
+ run: () => {}
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 4'
+}, {
+ trigger: 'button:contains("Save & New")'
+}, {
+ trigger: 'tr:contains("Step 4")',
+ in_modal: false,
+ run: () => {}
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 5'
+}, {
+ trigger: 'button:contains("Save & Close")'
+}, {
+ trigger: 'body.o_web_client:not(.modal-open)',
+ run: () => {}
+}, {
+ trigger: 'tr:contains("Step 5") .o_row_handle',
+ run: () => {
+ // move 'step 5' between 'step 1' and 'step 2'
+ // tried to use to built-in 'drag_and_drop' action but it doesn't work here
+ // somehow it only registers the move if 2 separate 'mousemove' events are triggered
+ const $element = $('tr:contains("Step 5") .o_row_handle');
+ const elementCenter = $element.offset();
+ elementCenter.left += $element.outerWidth() / 2;
+ elementCenter.top += $element.outerHeight() / 2;
+ const $to = $('tr:eq(1)');
+ const toCenter = $to.offset();
+ toCenter.left += $to.outerWidth() / 2;
+ toCenter.top += $to.outerHeight() / 2;
+
+ $element.trigger($.Event("mouseenter"));
+ $element.trigger($.Event("mousedown", {
+ which: 1,
+ pageX: elementCenter.left,
+ pageY: elementCenter.top
+ }));
+
+ $element.trigger($.Event("mousemove", {
+ which: 1,
+ pageX: toCenter.left - 1,
+ pageY: toCenter.top - 1
+ }));
+
+ $element.trigger($.Event("mousemove", {
+ which: 1,
+ pageX: toCenter.left,
+ pageY: toCenter.top
+ }));
+
+ $element.trigger($.Event("mouseup", {
+ which: 1,
+ pageX: toCenter.left,
+ pageY: toCenter.top
+ }));
+ }
+}, {
+ trigger: 'div[name="script_step_ids"] .o_field_x2many_list_row_add a'
+}, {
+ trigger: 'textarea[name="message"]',
+ run: 'text Step 6'
+}, {
+ trigger: 'button:contains("Save & Close")'
+}, {
+ trigger: 'body.o_web_client:not(.modal-open)',
+ run: () => {}
+}, {
+ trigger: 'tr:contains("Step 6")',
+ in_modal: false,
+ run: () => {}
+}]));
diff --git a/addons/im_livechat/tests/__init__.py b/addons/im_livechat/tests/__init__.py
index f8b445619bb..f9259d670bb 100644
--- a/addons/im_livechat/tests/__init__.py
+++ b/addons/im_livechat/tests/__init__.py
@@ -1,5 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import chatbot_common
+from . import test_chatbot_form_ui
+from . import test_chatbot_internals
from . import test_get_mail_channel
from . import test_im_livechat_support_page
diff --git a/addons/im_livechat/tests/chatbot_common.py b/addons/im_livechat/tests/chatbot_common.py
new file mode 100644
index 00000000000..105ee0428a8
--- /dev/null
+++ b/addons/im_livechat/tests/chatbot_common.py
@@ -0,0 +1,145 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import common
+
+
+class ChatbotCase(common.TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super(ChatbotCase, cls).setUpClass()
+
+ cls.chatbot_script = cls.env['chatbot.script'].create({
+ 'title': 'Testing Bot',
+ })
+
+ ChatbotScriptStep = cls.env['chatbot.script.step'].sudo()
+
+ [
+ cls.step_hello,
+ cls.step_welcome,
+ cls.step_dispatch,
+ ] = ChatbotScriptStep.create([{
+ 'step_type': 'text',
+ 'message': "Hello! I'm a bot!",
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'text',
+ 'message': "I help lost visitors find their way.",
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'question_selection',
+ 'message': "How can I help you?",
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }])
+
+ [
+ cls.step_dispatch_buy_software,
+ cls.step_dispatch_pricing,
+ cls.step_dispatch_operator,
+ ] = cls.env['chatbot.script.answer'].sudo().create([{
+ 'name': 'I want to buy the software',
+ 'script_step_id': cls.step_dispatch.id,
+ }, {
+ 'name': 'Pricing Question',
+ 'script_step_id': cls.step_dispatch.id,
+ }, {
+ 'name': "I want to speak with an operator",
+ 'script_step_id': cls.step_dispatch.id,
+ }])
+
+ [
+ cls.step_pricing_contact_us,
+ cls.step_email,
+ cls.step_email_validated,
+ cls.step_forward_operator,
+ cls.step_no_one_available,
+ cls.step_no_operator_dispatch,
+ ] = ChatbotScriptStep.create([{
+ 'step_type': 'text',
+ 'message': 'For any pricing question, feel free ton contact us at pricing@mycompany.com',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_pricing.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'question_email',
+ 'message': 'Can you give us your email please?',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_buy_software.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'text',
+ 'message': 'Your email is validated, thank you!',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_buy_software.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'forward_operator',
+ 'message': 'I will transfer you to a human.',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_operator.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'text',
+ 'message': 'Sorry, you will have to stay with me for a while',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_operator.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'question_selection',
+ 'message': 'So... What can I do to help you?',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_operator.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }])
+
+ cls.step_no_operator_just_leaving = cls.env['chatbot.script.answer'].sudo().create({
+ 'name': 'I will be leaving then',
+ 'script_step_id': cls.step_no_operator_dispatch.id,
+ })
+
+ [
+ cls.step_just_leaving,
+ cls.step_pricing_thank_you,
+ cls.step_ask_website,
+ cls.step_ask_feedback,
+ cls.step_goodbye,
+ ] = ChatbotScriptStep.create([{
+ 'step_type': 'text',
+ 'message': "Ok, I'm sorry I was not able to help you",
+ 'triggering_answer_ids': [(4, cls.step_no_operator_just_leaving.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'text',
+ 'message': 'We will reach back to you as soon as we can!',
+ 'triggering_answer_ids': [(4, cls.step_dispatch_pricing.id)],
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'free_input_single',
+ 'message': 'Would you mind providing your website address?',
+ 'sequence': 97, # makes it easier for other modules to add steps before this one
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'free_input_multi',
+ 'message': 'Great, do you want to leave any feedback for us to improve?',
+ 'sequence': 98, # makes it easier for other modules to add steps before this one
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }, {
+ 'step_type': 'text',
+ 'message': "Ok bye!",
+ 'sequence': 99, # makes it easier for other modules to add steps before this one
+ 'chatbot_script_id': cls.chatbot_script.id,
+ }])
+
+ cls.livechat_channel = cls.env['im_livechat.channel'].create({
+ 'name': 'Test Channel',
+ 'rule_ids': [(0, 0, {
+ 'chatbot_script_id': cls.chatbot_script.id,
+ })]
+ })
+
+ @classmethod
+ def _post_answer_and_trigger_next_step(cls, mail_channel, answer, chatbot_script_answer=False):
+ mail_message = mail_channel.message_post(body=answer)
+ if chatbot_script_answer:
+ cls.env['chatbot.message'].search([
+ ('mail_message_id', '=', mail_message.id)
+ ], limit=1).user_script_answer_id = chatbot_script_answer.id
+
+ next_step = mail_channel.chatbot_current_step_id._process_answer(mail_channel, mail_message.body)
+ next_step._process_step(mail_channel)
diff --git a/addons/im_livechat/tests/test_chatbot_form_ui.py b/addons/im_livechat/tests/test_chatbot_form_ui.py
new file mode 100644
index 00000000000..34088457f8d
--- /dev/null
+++ b/addons/im_livechat/tests/test_chatbot_form_ui.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import tests
+
+
+@tests.tagged('post_install', '-at_install')
+class TestLivechatChatbotFormUI(tests.HttpCase):
+ def test_chatbot_steps_sequence_ui(self):
+ """ As sequences are *critical* for the chatbot_script script, let us a run a little tour that
+ creates a few steps, then verify sequences are properly applied. """
+
+ self.start_tour(
+ '/web',
+ 'im_livechat_chatbot_steps_sequence_tour',
+ login='admin',
+ step_delay=1000
+ )
+
+ chatbot_script = self.env['chatbot.script'].search([('title', '=', 'Test Chatbot Sequence')])
+
+ self.assertEqual(len(chatbot_script.script_step_ids), 3)
+
+ self.assertEqual(chatbot_script.script_step_ids[0].message, 'Step 1')
+ self.assertEqual(chatbot_script.script_step_ids[0].sequence, 0)
+ self.assertEqual(chatbot_script.script_step_ids[1].message, 'Step 2')
+ self.assertEqual(chatbot_script.script_step_ids[1].sequence, 1)
+ self.assertEqual(chatbot_script.script_step_ids[2].message, 'Step 3')
+ self.assertEqual(chatbot_script.script_step_ids[2].sequence, 2)
+
+ def test_chatbot_steps_sequence_with_move_ui(self):
+ """ Same as above, with more steps and a drag&drop within the tour.
+
+ It is important to test those separately, as we want proper sequences even if we don't
+ move records around. """
+
+ self.start_tour(
+ '/web',
+ 'im_livechat_chatbot_steps_sequence_with_move_tour',
+ login='admin',
+ step_delay=1000
+ )
+
+ chatbot_script = self.env['chatbot.script'].search([('title', '=', 'Test Chatbot Sequence')])
+
+ self.assertEqual(len(chatbot_script.script_step_ids), 6)
+
+ # during the test, we create the steps normally and then move 'Step 5'
+ # in second position -> check order is correct
+
+ self.assertEqual(chatbot_script.script_step_ids[0].message, 'Step 1')
+ self.assertEqual(chatbot_script.script_step_ids[0].sequence, 0)
+ self.assertEqual(chatbot_script.script_step_ids[1].message, 'Step 5')
+ self.assertEqual(chatbot_script.script_step_ids[1].sequence, 1)
+ self.assertEqual(chatbot_script.script_step_ids[2].message, 'Step 2')
+ self.assertEqual(chatbot_script.script_step_ids[2].sequence, 2)
+ self.assertEqual(chatbot_script.script_step_ids[3].message, 'Step 3')
+ self.assertEqual(chatbot_script.script_step_ids[3].sequence, 3)
+ self.assertEqual(chatbot_script.script_step_ids[4].message, 'Step 4')
+ self.assertEqual(chatbot_script.script_step_ids[4].sequence, 4)
+ self.assertEqual(chatbot_script.script_step_ids[5].message, 'Step 6')
+ self.assertEqual(chatbot_script.script_step_ids[5].sequence, 5)
diff --git a/addons/im_livechat/tests/test_chatbot_internals.py b/addons/im_livechat/tests/test_chatbot_internals.py
new file mode 100644
index 00000000000..3050f664730
--- /dev/null
+++ b/addons/im_livechat/tests/test_chatbot_internals.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.im_livechat.tests import chatbot_common
+from odoo.exceptions import ValidationError
+
+
+class ChatbotCase(chatbot_common.ChatbotCase):
+
+ def test_chatbot_duplicate(self):
+ """ In this test we make sure that 'triggering_answer_ids' are correctly duplicated and
+ reference the answers from the copied script steps.
+ See chatbot.script#copy for more details. """
+
+ chatbot_copy = self.chatbot_script.copy()
+
+ step_pricing_contact_us_copy = chatbot_copy.script_step_ids.filtered(
+ lambda step: 'For any pricing question, feel free ton contact us at pricing@mycompany.com' in step.message)
+
+ self.assertNotEqual(step_pricing_contact_us_copy, self.step_pricing_contact_us)
+ self.assertEqual(len(step_pricing_contact_us_copy.triggering_answer_ids), 1)
+ self.assertEqual(step_pricing_contact_us_copy.triggering_answer_ids.name, 'Pricing Question')
+ self.assertNotEqual(step_pricing_contact_us_copy.triggering_answer_ids, self.step_dispatch_pricing)
+
+ step_email_copy = chatbot_copy.script_step_ids.filtered(
+ lambda step: 'Can you give us your email please' in step.message)
+
+ self.assertNotEqual(step_email_copy, self.step_email)
+ self.assertEqual(len(step_email_copy.triggering_answer_ids), 1)
+ self.assertEqual(step_email_copy.triggering_answer_ids.name, 'I want to buy the software')
+ self.assertNotEqual(step_email_copy.triggering_answer_ids, self.step_dispatch_buy_software)
+
+ def test_chatbot_is_forward_operator_child(self):
+ self.assertEqual([step.is_forward_operator_child for step in self.chatbot_script.script_step_ids],
+ [False, False, False, False, False, False, False, True, True, True, False, False, False, False],
+ "Steps 'step_no_one_available', 'step_no_operator_dispatch', 'step_just_leaving'"
+ "should be flagged as forward operator child.")
+
+ self.step_no_operator_dispatch.write({'triggering_answer_ids': [(6, 0, [self.step_dispatch_pricing.id])]})
+ self.chatbot_script.script_step_ids.invalidate_cache(['is_forward_operator_child'])
+
+ self.assertEqual([step.is_forward_operator_child for step in self.chatbot_script.script_step_ids],
+ [False, False, False, False, False, False, False, True, False, False, False, False, False, False],
+ "Only step 'step_no_one_available' should be flagged as forward operator child.")
+
+ def test_chatbot_steps(self):
+ channel_info = self.livechat_channel._open_livechat_mail_channel(
+ anonymous_name='Test Visitor', chatbot_script=self.chatbot_script)
+ mail_channel = self.env['mail.channel'].browse(channel_info['id'])
+
+ self.assertEqual(mail_channel.chatbot_current_step_id, self.step_dispatch)
+
+ self._post_answer_and_trigger_next_step(
+ mail_channel,
+ self.step_dispatch_buy_software.name,
+ chatbot_script_answer=self.step_dispatch_buy_software
+ )
+ self.assertEqual(mail_channel.chatbot_current_step_id, self.step_email)
+
+ with self.assertRaises(ValidationError, msg="Should raise an error since it's not a valid email"):
+ self._post_answer_and_trigger_next_step(mail_channel, 'test')
+
+ self._post_answer_and_trigger_next_step(mail_channel, 'test@example.com')
+ self.assertEqual(mail_channel.chatbot_current_step_id, self.step_email_validated)
+
+ def test_chatbot_steps_sequence(self):
+ """ Ensure sequence is correct when creating chatbots and adding steps to an existing one.
+ See chatbot.script.step#create for more details. """
+
+ chatbot_1, chatbot_2 = self.env['chatbot.script'].create([{
+ 'title': 'Chatbot 1',
+ 'script_step_ids': [
+ (0, 0, {'step_type': 'text', 'message': '1'}),
+ (0, 0, {'step_type': 'text', 'message': '2'}),
+ (0, 0, {'step_type': 'text', 'message': '3'}),
+ (0, 0, {'step_type': 'text', 'message': '4'}),
+ (0, 0, {'step_type': 'text', 'message': '5'}),
+ ]
+ }, {
+ 'title': 'Chatbot 2',
+ 'script_step_ids': [
+ (0, 0, {'step_type': 'text', 'message': '1'}),
+ (0, 0, {'step_type': 'text', 'message': '2'}),
+ (0, 0, {'step_type': 'text', 'message': '3'}),
+ ]
+ }])
+
+ self.assertEqual([0, 1, 2, 3, 4], chatbot_1.script_step_ids.mapped('sequence'))
+ self.assertEqual([0, 1, 2], chatbot_2.script_step_ids.mapped('sequence'))
+
+ chatbot_1.write({'script_step_ids': [
+ (0, 0, {'step_type': 'text', 'message': '6'}),
+ (0, 0, {'step_type': 'text', 'message': '7'}),
+ ]})
+ self.assertEqual([0, 1, 2, 3, 4, 5, 6], chatbot_1.script_step_ids.mapped('sequence'))
+
+ def test_chatbot_welcome_steps(self):
+ """ see '_get_welcome_steps' for more details. """
+
+ welcome_steps = self.chatbot_script._get_welcome_steps()
+ self.assertEqual(len(welcome_steps), 3)
+ self.assertEqual(welcome_steps, self.chatbot_script.script_step_ids[:3])
+
+ self.chatbot_script.script_step_ids[:2].unlink()
+ welcome_steps = self.chatbot_script._get_welcome_steps()
+ self.assertEqual(len(welcome_steps), 1)
+ self.assertEqual(welcome_steps, self.chatbot_script.script_step_ids[0])
diff --git a/addons/im_livechat/views/chatbot_script_answer_views.xml b/addons/im_livechat/views/chatbot_script_answer_views.xml
new file mode 100644
index 00000000000..31caf10492e
--- /dev/null
+++ b/addons/im_livechat/views/chatbot_script_answer_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ chatbot.script.answer.view.form
+ chatbot.script.answer
+
+
+
+
+
+
+ chatbot.script.answer.view.tree
+ chatbot.script.answer
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/im_livechat/views/chatbot_script_step_views.xml b/addons/im_livechat/views/chatbot_script_step_views.xml
new file mode 100644
index 00000000000..f9ea61f0cf6
--- /dev/null
+++ b/addons/im_livechat/views/chatbot_script_step_views.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ chatbot.script.step.view.form
+ chatbot.script.step
+
+
+
+
+
+
+ chatbot.script.step.view.tree
+ chatbot.script.step
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/im_livechat/views/chatbot_script_views.xml b/addons/im_livechat/views/chatbot_script_views.xml
new file mode 100644
index 00000000000..29f0e002552
--- /dev/null
+++ b/addons/im_livechat/views/chatbot_script_views.xml
@@ -0,0 +1,80 @@
+
+
+
+
+ chatbot.script.view.form
+ chatbot.script
+
+
+
+
+
+
+ chatbot.script.view.tree
+ chatbot.script
+
+
+
+
+
+
+
+
+ chatbot.script.view.search
+ chatbot.script
+
+
+
+
+
+
+
+
+
+ Chatbot
+ chatbot.script
+ tree,form
+
+
+ Create a Chatbot
+
+ You can create a new Chatbot with a defined script to speak to your website visitors.
+
+
+
+
+
diff --git a/addons/im_livechat/views/im_livechat_channel_views.xml b/addons/im_livechat/views/im_livechat_channel_views.xml
index fc291e398bc..4b97830f5e8 100644
--- a/addons/im_livechat/views/im_livechat_channel_views.xml
+++ b/addons/im_livechat/views/im_livechat_channel_views.xml
@@ -78,6 +78,10 @@