From ea680d85958a050b3e072c8530d791a22a8b8a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Fri, 1 Apr 2022 14:42:57 +0000 Subject: [PATCH] [IMP] im_livechat: introduce chatbot scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PURPOSE This commit introduces a chatbot operator that works based on a user-defined script with various steps. SPECS A im_livechat.chatbot.script can be defined on a livechat rule. When a end-user reaches a website page that matches the rule, the chat window opens and the script of the bot starts iterating through its steps. The chatbot code is currently directly integrated with the existing livechat Javascript code. It defines extra conditions and layout elements to be able to automate the conversation and register user answers. AVAILABLE STEPS A script is defined with several steps that can currently be one of the following types: "text" A simple text step where the bot posts a message without expecting an answer e.g: "Hello! I'm a friendly robot!" "question_selection" The bot will ask a question and suggest answers, the end-user will have to click on the answer he chooses e.g: "How can I help you? -> Create a Ticket -> Create a Lead -> Speak with a human" "question_email" That step will ask the end user's email address (and validate it) The result is saved on the linked im_livechat.im_livechatchatbot.mail.message "question_phone" Same logic as the 'question_email' for a phone number We don't validate the input this time as it's a complicated process (requires country, ...) "forward_operator" 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. The operator will be chosen among the available operators on the livechat.channel. If there is no operator available, the script continues normally which allows to automate an "answering machine" that will redirect the user in case no operator is available. e.g: "I'm sorry, no operator is available right now, please contact us by email at 'info@company.com', we will try to respond as soon as possible!". (Or even something more complex with multiple questions / paths). "free_input_single" Will ask the visitor for a single line of text. This text is not saved anywhere else than in the conversation, but it's still useful when combined with steps that create leads / tickets since those print the whole conversation into the description. "free_input_multi" Same as "free_input_single" but lets the user input multiple lines of text. The frontend implementation is made by waiting a few seconds (currently 10) for either the next submitted message or the next character typed into the input. This lets visitors explain their issue / question with multiple messages. Which is very useful since new messages are sent every time you press "Enter". LINKS Task-2030386 Part-of: odoo/odoo#84000 Co-authored-by: Patrick Hoste Co-authored-by: Aurélien Warnon --- addons/im_livechat/__manifest__.py | 20 +- addons/im_livechat/controllers/__init__.py | 3 + addons/im_livechat/controllers/chatbot.py | 104 +++ addons/im_livechat/controllers/main.py | 59 +- .../data/im_livechat_chatbot_data.xml | 103 +++ addons/im_livechat/models/__init__.py | 4 + addons/im_livechat/models/chatbot_message.py | 28 + addons/im_livechat/models/chatbot_script.py | 225 +++++ .../models/chatbot_script_answer.py | 65 ++ .../im_livechat/models/chatbot_script_step.py | 375 ++++++++ .../im_livechat/models/im_livechat_channel.py | 79 +- addons/im_livechat/models/mail_channel.py | 95 +- addons/im_livechat/models/mail_message.py | 25 +- .../im_livechat/security/ir.model.access.csv | 8 + .../static/src/img/chatbot_is_typing.gif | Bin 0 -> 16263 bytes .../src/js/chatbot_script_answers_m2m_tags.js | 54 ++ .../static/src/js/chatbot_script_step_o2m.js | 25 + .../js/im_livechat_chatbot_form_controller.js | 92 ++ .../src/js/im_livechat_chatbot_form_view.js | 26 + ..._livechat_chatbot_step_form_view_dialog.js | 63 ++ .../static/src/legacy/public_livechat.js | 20 +- .../src/legacy/public_livechat_chatbot.js | 861 ++++++++++++++++++ .../src/legacy/public_livechat_chatbot.scss | 33 + .../src/legacy/public_livechat_chatbot.xml | 86 ++ .../website_livechat_message_chatbot.js | 81 ++ ...im_livechat_chatbot_steps_sequence_tour.js | 138 +++ addons/im_livechat/tests/__init__.py | 3 + addons/im_livechat/tests/chatbot_common.py | 145 +++ .../im_livechat/tests/test_chatbot_form_ui.py | 62 ++ .../tests/test_chatbot_internals.py | 107 +++ .../views/chatbot_script_answer_views.xml | 32 + .../views/chatbot_script_step_views.xml | 55 ++ .../views/chatbot_script_views.xml | 80 ++ .../views/im_livechat_channel_views.xml | 21 + .../views/im_livechat_chatbot_templates.xml | 28 + addons/website_livechat/__init__.py | 12 + addons/website_livechat/__manifest__.py | 9 + .../website_livechat/controllers/__init__.py | 3 + .../website_livechat/controllers/chatbot.py | 42 + addons/website_livechat/controllers/main.py | 4 +- .../data/website_livechat_chatbot_demo.xml | 15 + addons/website_livechat/models/__init__.py | 2 + .../website_livechat/models/chatbot_script.py | 21 + .../models/chatbot_script_step.py | 20 + .../models/im_livechat_channel.py | 7 +- .../website_livechat_chatbot_test_script.js | 91 ++ .../tours/website_livechat_chatbot_flow.js | 169 ++++ .../tests/tours/website_livechat_common.js | 5 +- addons/website_livechat/tests/__init__.py | 1 + .../website_livechat/tests/test_chatbot_ui.py | 96 ++ .../views/im_livechat_chatbot_script_view.xml | 16 + 51 files changed, 3660 insertions(+), 58 deletions(-) create mode 100644 addons/im_livechat/controllers/chatbot.py create mode 100644 addons/im_livechat/data/im_livechat_chatbot_data.xml create mode 100644 addons/im_livechat/models/chatbot_message.py create mode 100644 addons/im_livechat/models/chatbot_script.py create mode 100644 addons/im_livechat/models/chatbot_script_answer.py create mode 100644 addons/im_livechat/models/chatbot_script_step.py create mode 100644 addons/im_livechat/static/src/img/chatbot_is_typing.gif create mode 100644 addons/im_livechat/static/src/js/chatbot_script_answers_m2m_tags.js create mode 100644 addons/im_livechat/static/src/js/chatbot_script_step_o2m.js create mode 100644 addons/im_livechat/static/src/js/im_livechat_chatbot_form_controller.js create mode 100644 addons/im_livechat/static/src/js/im_livechat_chatbot_form_view.js create mode 100644 addons/im_livechat/static/src/js/im_livechat_chatbot_step_form_view_dialog.js create mode 100644 addons/im_livechat/static/src/legacy/public_livechat_chatbot.js create mode 100644 addons/im_livechat/static/src/legacy/public_livechat_chatbot.scss create mode 100644 addons/im_livechat/static/src/legacy/public_livechat_chatbot.xml create mode 100644 addons/im_livechat/static/src/legacy/website_livechat_message_chatbot.js create mode 100644 addons/im_livechat/static/tests/tours/im_livechat_chatbot_steps_sequence_tour.js create mode 100644 addons/im_livechat/tests/chatbot_common.py create mode 100644 addons/im_livechat/tests/test_chatbot_form_ui.py create mode 100644 addons/im_livechat/tests/test_chatbot_internals.py create mode 100644 addons/im_livechat/views/chatbot_script_answer_views.xml create mode 100644 addons/im_livechat/views/chatbot_script_step_views.xml create mode 100644 addons/im_livechat/views/chatbot_script_views.xml create mode 100644 addons/im_livechat/views/im_livechat_chatbot_templates.xml create mode 100644 addons/website_livechat/controllers/chatbot.py create mode 100644 addons/website_livechat/data/website_livechat_chatbot_demo.xml create mode 100644 addons/website_livechat/models/chatbot_script.py create mode 100644 addons/website_livechat/models/chatbot_script_step.py create mode 100644 addons/website_livechat/static/src/legacy/website_livechat_chatbot_test_script.js create mode 100644 addons/website_livechat/static/tests/tours/website_livechat_chatbot_flow.js create mode 100644 addons/website_livechat/tests/test_chatbot_ui.py create mode 100644 addons/website_livechat/views/im_livechat_chatbot_script_view.xml 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 0000000000000000000000000000000000000000..7fe41e7b361a65ca559cd6b524dadbc5451f5b0c GIT binary patch literal 16263 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 literal 0 HcmV?d00001 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 @@ +