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 +
+
+
+ + + + + + + + + + + + + + +
    + +
  • + +
  • +
    +
    +
+
+
+ + + + + +
+
+ chatbot_image +
+
+

+ +

+
+ is typing +
+
+
+
+
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 @@
+ @@ -232,6 +236,15 @@ +