[MERGE] im_livechat: introduce chatbot scripts

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".

"create_lead"

Special step_type that allows creating a crm.lead when reaching it.
Usually used in addition to 'question_email' and 'question_phone' to create
interesting leads.

LINKS

Task-2030386

closes odoo/odoo#84000

Related: odoo/enterprise#24894
Signed-off-by: Thibault Delavallee (tde) <tde@openerp.com>
Co-authored-by: Patrick Hoste <pko@odoo.com>
Co-authored-by: Aurélien Warnon <awa@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-04-01 18:49:58 +02:00
committed by GitHub
co-authored by Patrick Hoste Aurélien Warnon
66 changed files with 4008 additions and 82 deletions
+3
View File
@@ -7,6 +7,9 @@
'summary': 'Create lead from livechat conversation',
'data': [
'data/utm_data.xml',
'data/crm_livechat_chatbot_data.xml',
'views/chatbot_script_views.xml',
'views/chatbot_script_step_views.xml',
],
'depends': [
'crm',
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data noupdate="1">
<!--
This provides a working lead generation chatbot for people to work with.
It's placed into 'data' to give them a starting point.
From that record, they can duplicate / adapt / delete / ...
-->
<record id="chatbot_script_lead_generation_bot" model="chatbot.script">
<field name="title">Lead Generation Bot</field>
<field name="image_1920" type="base64" file="mail/static/src/img/odoobot_transparent.png"/>
</record>
<record id="chatbot_script_lead_generation_step_welcome" model="chatbot.script.step">
<field name="message">Hi there, what brings you to our website today? 👋</field>
<field name="sequence">1</field>
<field name="step_type">free_input_multi</field>
<field name="chatbot_script_id" ref="chatbot_script_lead_generation_bot"/>
</record>
<record id="chatbot_script_lead_generation_step_forward_operator" model="chatbot.script.step">
<field name="sequence">2</field>
<field name="step_type">forward_operator</field>
<field name="chatbot_script_id" ref="chatbot_script_lead_generation_bot"/>
</record>
<record id="chatbot_script_lead_generation_step_noone_available" model="chatbot.script.step">
<field name="message">Hu-ho, it looks like none of our operators are available 🙁</field>
<field name="sequence">3</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_lead_generation_bot"/>
</record>
<record id="chatbot_script_welcome_step_pricing_email" model="chatbot.script.step">
<field name="message">Would you mind leaving your email address so that we can reach you back?</field>
<field name="sequence">4</field>
<field name="step_type">question_email</field>
<field name="chatbot_script_id" ref="chatbot_script_lead_generation_bot"/>
</record>
<record id="chatbot_script_welcome_step_just_looking" model="chatbot.script.step">
<field name="message">Thank you, you should hear back from us very soon!</field>
<field name="sequence">5</field>
<field name="step_type">create_lead</field>
<field name="chatbot_script_id" ref="chatbot_script_lead_generation_bot"/>
</record>
</data></odoo>
+2
View File
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import chatbot_script
from . import chatbot_script_step
from . import mail_channel
@@ -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 ChatbotScript(models.Model):
_inherit = 'chatbot.script'
lead_count = fields.Integer(
string='Generated Lead Count', compute='_compute_lead_count')
def _compute_lead_count(self):
mapped_leads = {}
if self.ids:
leads_data = self.env['crm.lead'].with_context(active_test=False).sudo()._read_group(
[('source_id', 'in', self.mapped('source_id').ids)], ['source_id'], ['source_id'])
mapped_leads = {lead['source_id'][0]: lead['source_id_count'] for lead in leads_data}
for script in self:
script.lead_count = mapped_leads.get(script.source_id.id, 0)
def action_view_leads(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id('crm.crm_lead_all_leads')
action['domain'] = [('source_id', '=', self.source_id.id)]
action['context'] = {'create': False}
return action
@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models, fields
class ChatbotScriptStep(models.Model):
_inherit = 'chatbot.script.step'
step_type = fields.Selection(
selection_add=[('create_lead', 'Create Lead')], ondelete={'create_lead': 'cascade'})
crm_team_id = fields.Many2one(
'crm.team', string='Sales Team', ondelete='set null',
help="Used in combination with 'create_lead' step type in order to automatically "
"assign the created lead/opportunity to the defined team")
def _chatbot_crm_prepare_lead_values(self, mail_channel, description):
return {
'description': description + mail_channel._get_channel_history(),
'name': _("%s's New Lead", self.chatbot_script_id.title),
'source_id': self.chatbot_script_id.source_id.id,
'team_id': self.crm_team_id.id,
'type': 'lead' if self.crm_team_id.use_leads else 'opportunity',
'user_id': False,
}
def _process_step(self, mail_channel):
self.ensure_one()
posted_message = super()._process_step(mail_channel)
if self.step_type == 'create_lead':
self._process_step_create_lead(mail_channel)
return posted_message
def _process_step_create_lead(self, mail_channel):
""" When reaching a 'create_lead' step, we extract the relevant information: visitor's
email, phone and conversation history to create a crm.lead.
We use the email and phone to update the environment partner's information (if not a public
user) if they differ from the current values.
The whole conversation history will be saved into the lead's description for reference.
This also allows having a question of type 'free_input_multi' to let the visitor explain
his interest / needs before creating the lead. """
customer_values = self._chatbot_prepare_customer_values(
mail_channel, create_partner=False, update_partner=True)
if self.env.user._is_public():
create_values = {
'email_from': customer_values['email'],
'phone': customer_values['phone'],
}
else:
partner = self.env.user.partner_id
create_values = {
'partner_id': partner.id,
'company_id': partner.company_id.id,
}
create_values.update(self._chatbot_crm_prepare_lead_values(
mail_channel, customer_values['description']))
self.env['crm.lead'].create(create_values)
+1 -5
View File
@@ -26,10 +26,6 @@ class MailChannel(models.Model):
:param partner: internal user partner (operator) that created the lead;
:param key: operator input in chat ('/lead Lead about Product')
"""
description = ''.join(
'%s: %s<br/>' % (message.author_id.name or self.anonymous_name, html2plaintext(message.body))
for message in self.message_ids.sorted('id')
) # converting message body back to plaintext for correct data formating in description
# if public user is part of the chat: consider lead to be linked to an
# anonymous user whatever the participants. Otherwise keep only share
# partners (no user or portal user) to link to the lead.
@@ -47,7 +43,7 @@ class MailChannel(models.Model):
'partner_id': customers[0].id if customers else False,
'user_id': False,
'team_id': False,
'description': description,
'description': self._get_channel_history(),
'referred': partner.name,
'source_id': utm_source and utm_source.id,
})
+2
View File
@@ -1,4 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import chatbot_common
from . import test_chatbot_lead
from . import test_crm_lead
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.im_livechat.tests.chatbot_common import ChatbotCase
from odoo.addons.mail.tests.common import mail_new_test_user
class CrmChatbotCase(ChatbotCase):
@classmethod
def setUpClass(cls):
super(CrmChatbotCase, cls).setUpClass()
cls.company_id = cls.env['res.company'].create({
'name': 'Test Company',
'country_id': cls.env.ref('base.be').id,
})
cls.user_public = mail_new_test_user(
cls.env, login='user_public', groups='base.group_public', name='Public User')
cls.user_portal = mail_new_test_user(
cls.env, login='user_portal', groups='base.group_portal', name='Portal User',
company_id=cls.company_id.id, email='portal@example.com')
# update company_id on partner since the user's company is not propagated
cls.user_portal.partner_id.write({'company_id': cls.company_id.id})
cls.sale_team = cls.env['crm.team'].create({
'name': 'Test Sale Team 1',
'company_id': cls.company_id.id,
})
cls.sale_team_with_lead = cls.env['crm.team'].create({
'name': 'Test Sale Team 2',
'use_leads': True,
'company_id': cls.company_id.id,
})
cls.step_dispatch_create_lead = cls.env['chatbot.script.answer'].sudo().create({
'name': 'Create a lead',
'script_step_id': cls.step_dispatch.id,
})
[
cls.step_create_lead_email,
cls.step_create_lead_phone,
cls.step_create_lead,
] = cls.env['chatbot.script.step'].sudo().create([{
'step_type': 'question_email',
'message': 'Could you provide us your email please.',
'sequence': 20,
'triggering_answer_ids': [(4, cls.step_dispatch_create_lead.id)],
'chatbot_script_id': cls.chatbot_script.id,
}, {
'step_type': 'question_phone',
'message': 'Could you also provide your phone please.',
'sequence': 21,
'triggering_answer_ids': [(4, cls.step_dispatch_create_lead.id)],
'chatbot_script_id': cls.chatbot_script.id,
}, {
'step_type': 'create_lead',
'message': 'Thank you! A lead has been created.',
'sequence': 22,
'triggering_answer_ids': [(4, cls.step_dispatch_create_lead.id)],
'crm_team_id': cls.sale_team.id,
'chatbot_script_id': cls.chatbot_script.id,
}])
@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.crm_livechat.tests import chatbot_common
from odoo.tests.common import users
class CrmChatbotCase(chatbot_common.CrmChatbotCase):
@users('user_public')
def test_chatbot_lead_public_user(self):
self._chatbot_create_lead(self.user_public)
created_lead = self.env['crm.lead'].sudo().search([], limit=1, order='id desc')
self.assertEqual(created_lead.name, "Testing Bot's New Lead")
self.assertEqual(created_lead.email_from, 'test2@example.com')
self.assertEqual(created_lead.phone, '123456')
self.assertEqual(created_lead.team_id, self.sale_team)
self.assertEqual(created_lead.type, 'opportunity')
@users('user_portal')
def test_chatbot_lead_portal_user(self):
self.step_create_lead.write({'crm_team_id': self.sale_team_with_lead})
self._chatbot_create_lead(self.user_portal)
created_lead = self.env['crm.lead'].sudo().search([], limit=1, order='id desc')
self.assertEqual(created_lead.name, "Testing Bot's New Lead")
self.assertNotEqual(created_lead.email_from, 'test2@example.com', "User's email should'nt have been overridden")
self.assertEqual(created_lead.phone, '123456', "User's phone should have been updated")
self.assertEqual(created_lead.team_id, self.sale_team_with_lead)
self.assertEqual(created_lead.type, 'lead')
def _chatbot_create_lead(self, user):
channel_info = self.livechat_channel._open_livechat_mail_channel(
anonymous_name='Test Visitor', chatbot_script=self.chatbot_script, user_id=user.id)
mail_channel = self.env['mail.channel'].sudo().browse(channel_info['id'])
self._post_answer_and_trigger_next_step(
mail_channel,
self.step_dispatch_create_lead.name,
chatbot_script_answer=self.step_dispatch_create_lead
)
self.assertEqual(mail_channel.chatbot_current_step_id, self.step_create_lead_email)
self._post_answer_and_trigger_next_step(mail_channel, 'test2@example.com')
self.assertEqual(mail_channel.chatbot_current_step_id, self.step_create_lead_phone)
self._post_answer_and_trigger_next_step(mail_channel, '123456')
self.assertEqual(mail_channel.chatbot_current_step_id, self.step_create_lead)
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_step_view_form" model="ir.ui.view">
<field name="name">chatbot.script.step.view.form.inherit.crm.livechat</field>
<field name="model">chatbot.script.step</field>
<field name="inherit_id" ref="im_livechat.chatbot_script_step_view_form"/>
<field name="arch" type="xml">
<field name="step_type" position="after">
<field name="crm_team_id" attrs="{'invisible': [('step_type', '!=', 'create_lead')]}"/>
</field>
</field>
</record>
</data></odoo>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_view_form" model="ir.ui.view">
<field name="name">chatbot.script.view.form.inherit.crm.livechat</field>
<field name="model">chatbot.script</field>
<field name="inherit_id" ref="im_livechat.chatbot_script_view_form"/>
<field name="arch" type="xml">
<div name="button_box" position="inside">
<button name="action_view_leads" type="object" class="oe_stat_button"
icon="fa-star" attrs="{'invisible': [('lead_count', '=', 0)]}">
<field string="Leads" name="lead_count" widget="statinfo" />
</button>
</div>
</field>
</record>
</data></odoo>
+19 -1
View File
@@ -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',
@@ -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
+104
View File
@@ -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
+35 -24
View File
@@ -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):
@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data noupdate="1">
<!--
This provides a working chatbot for people to work with.
It's placed into 'data' to give them a starting point.
From that record, they can duplicate / adapt / delete / ...
-->
<record id="chatbot_script_welcome_bot" model="chatbot.script">
<field name="title">Welcome Bot</field>
<field name="image_1920" type="base64" file="mail/static/src/img/odoobot_transparent.png"/>
</record>
<record id="chatbot_script_welcome_step_welcome" model="chatbot.script.step">
<field name="message">Welcome to CompanyName ! 👋</field>
<field name="sequence">1</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
</record>
<record id="chatbot_script_welcome_step_dispatch" model="chatbot.script.step">
<field name="message">What are you looking for?</field>
<field name="sequence">2</field>
<field name="step_type">question_selection</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
</record>
<record id="chatbot_script_welcome_step_dispatch_answer_pricing" model="chatbot.script.answer">
<field name="name">I have a pricing question</field>
<field name="sequence">1</field>
<field name="script_step_id" ref="chatbot_script_welcome_step_dispatch"/>
</record>
<record id="chatbot_script_welcome_step_dispatch_answer_documentation" model="chatbot.script.answer">
<field name="name">I am looking for your documentation</field>
<field name="redirect_link">/</field>
<field name="sequence">2</field>
<field name="script_step_id" ref="chatbot_script_welcome_step_dispatch"/>
</record>
<record id="chatbot_script_welcome_step_dispatch_answer_just_looking" model="chatbot.script.answer">
<field name="name">I am just looking around</field>
<field name="sequence">3</field>
<field name="script_step_id" ref="chatbot_script_welcome_step_dispatch"/>
</record>
<record id="chatbot_script_welcome_step_pricing" model="chatbot.script.step">
<field name="message">Hmmm, let me check if I can find someone that could help you with that...</field>
<field name="sequence">3</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_pricing'))]"/>
</record>
<record id="chatbot_script_welcome_step_pricing_forward_operator" model="chatbot.script.step">
<field name="sequence">4</field>
<field name="step_type">forward_operator</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_pricing'))]"/>
</record>
<record id="chatbot_script_welcome_step_pricing_noone_available" model="chatbot.script.step">
<field name="message">Hu-ho, it looks like none of our operators are available 🙁</field>
<field name="sequence">5</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_pricing'))]"/>
</record>
<record id="chatbot_script_welcome_step_pricing_email" model="chatbot.script.step">
<field name="message">Would you mind leaving your email address so that we can reach you back?</field>
<field name="sequence">6</field>
<field name="step_type">question_email</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_pricing'))]"/>
</record>
<record id="chatbot_script_welcome_step_documentation_redirect" model="chatbot.script.step">
<field name="message">And tadaaaa here you go! 🌟</field>
<field name="sequence">7</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_documentation'))]"/>
</record>
<record id="chatbot_script_welcome_step_documentation_exit" model="chatbot.script.step">
<field name="message">If you need anything else, feel free to get back in touch</field>
<field name="sequence">8</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_documentation'))]"/>
</record>
<record id="chatbot_script_welcome_step_just_looking" model="chatbot.script.step">
<field name="message">Please do! If there is anything we can help with, let us know</field>
<field name="sequence">9</field>
<field name="step_type">text</field>
<field name="chatbot_script_id" ref="chatbot_script_welcome_bot"/>
<field name="triggering_answer_ids" eval="[(4, ref('chatbot_script_welcome_step_dispatch_answer_just_looking'))]"/>
</record>
</data></odoo>
+4
View File
@@ -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
@@ -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"),
]
+225
View File
@@ -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,
}
@@ -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)
@@ -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<strong>%s</strong><br>') % (_('Please contact me on: '), input_email)
if input_phone:
description += Markup('%s<strong>%s</strong><br>') % (_('Please call me on: '), input_phone)
if description:
description += Markup('<br>')
return {
'partner': partner,
'email': input_email,
'phone': input_phone,
'description': description,
}
def _fetch_next_step(self, selected_answer_ids):
""" Fetch the next step depending on the user's selected answers.
If a step contains multiple triggering answers from the same step the condition between
them must be a 'OR'. If is contains multiple triggering answers from different steps the
condition between them must be a 'AND'.
e.g:
STEP 1 : A B
STEP 2 : C D
STEP 3 : E
STEP 4 ONLY IF A B C E
Scenario 1 (A C E):
A in (A B) -> OK
C in (C) -> OK
E in (E) -> OK
-> OK
Scenario 2 (B D E):
B in (A B) -> OK
D in (C) -> NOK
E in (E) -> OK
-> NOK
"""
self.ensure_one()
domain = [('chatbot_script_id', '=', self.chatbot_script_id.id), ('sequence', '>', self.sequence)]
if selected_answer_ids:
domain = expression.AND([domain, [
'|',
('triggering_answer_ids', '=', False),
('triggering_answer_ids', 'in', selected_answer_ids.ids)]])
steps = self.env['chatbot.script.step'].search(domain)
for step in steps:
if not step.triggering_answer_ids:
return step
answers_by_step = defaultdict(list)
for answer in step.triggering_answer_ids:
answers_by_step[answer.script_step_id.id].append(answer)
if all(any(answer in step_triggering_answers for answer in selected_answer_ids)
for step_triggering_answers in answers_by_step.values()):
return step
return self.env['chatbot.script.step']
def _get_parent_step(self, all_parent_steps):
""" Returns the first preceding step that matches either the triggering answers
or the possible answers the user can select """
self.ensure_one()
if not self.chatbot_script_id.ids:
return self.env['chatbot.script.step']
for step in all_parent_steps:
if step.sequence >= self.sequence:
continue
if self.triggering_answer_ids:
if not (all(answer in self.triggering_answer_ids for answer in step.triggering_answer_ids) or
any(answer in self.triggering_answer_ids for answer in step.answer_ids)):
continue
elif step.triggering_answer_ids:
continue
return step
return self.env['chatbot.script.step']
def _is_last_step(self, mail_channel=False):
self.ensure_one()
mail_channel = mail_channel or self.env['mail.channel']
# if it's not a question and if there is no next step, then we end the script
if self.step_type != 'question_selection' and not self._fetch_next_step(
mail_channel.chatbot_message_ids.user_script_answer_id):
return True
return False
def _process_answer(self, mail_channel, message_body):
""" Method called when the user reacts to the current chatbot.script step.
For most chatbot.script.step#step_types it simply returns the next chatbot.script.step of
the script (see '_fetch_next_step').
Some extra processing is done for steps of type 'question_email' and 'question_phone' where
we store the user raw answer (the mail message HTML body) into the chatbot.message in order
to be able to recover it later (see '_chatbot_prepare_customer_values').
:param mail_channel:
:param message_body:
:return: script step to display next
:rtype: 'chatbot.script.step' """
self.ensure_one()
user_text_answer = html2plaintext(message_body)
if self.step_type == 'question_email' and not email_normalize(user_text_answer):
# if this error is raised, display an error message but do not go to next step
raise ValidationError(_('"%s" is not a valid email.', user_text_answer))
if self.step_type in ['question_email', 'question_phone']:
chatbot_message = self.env['chatbot.message'].search([
('mail_channel_id', '=', mail_channel.id),
('script_step_id', '=', self.id),
], limit=1)
if chatbot_message:
chatbot_message.write({'user_raw_answer': message_body})
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('<div class="o_mail_notification">%s</div>') % _('%s has joined', human_operator.partner_id.name))
mail_channel._broadcast(human_operator.partner_id.ids)
mail_channel.channel_pin(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
}
@@ -35,7 +35,7 @@ class ImLivechatChannel(models.Model):
input_placeholder = fields.Char('Chat Input Placeholder', help='Text that prompts the user to initiate the chat.')
header_background_color = fields.Char(default="#875A7B", help="Default background color of the channel header once open")
title_color = fields.Char(default="#FFFFFF", help="Default title color of the channel once open")
button_background_color = fields.Char(default="#878787", help="Default background color of the Livechat button")
button_background_color = fields.Char(default="#875A7B", help="Default background color of the Livechat button")
button_text_color = fields.Char(default="#FFFFFF", help="Default text color of the Livechat button")
# computed fields
@@ -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',
+94 -1
View File
@@ -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<br/>') % (message.author_id.name or self.anonymous_name, html2plaintext(message.body))
for message in self.message_ids.sorted('id')
)
# =======================
# Chatbot
# =======================
def _chatbot_find_customer_values_in_messages(self, step_type_to_field):
"""
Look for user's input in the channel's messages based on a dictionary
mapping the step_type to the field name of the model it will be used on.
:param dict step_type_to_field: a dict of step types to customer fields
to fill, like : {'question_email': 'email_from', 'question_phone': 'mobile'}
"""
values = {}
filtered_message_ids = self.chatbot_message_ids.filtered(
lambda m: m.script_step_id.step_type in step_type_to_field.keys()
)
for message_id in filtered_message_ids:
field_name = step_type_to_field[message_id.script_step_id.step_type]
if not values.get(field_name):
values[field_name] = html2plaintext(message_id.user_raw_answer or '')
return values
def _chatbot_post_message(self, chatbot_script, body):
""" Small helper to post a message as the chatbot operator
:param record chatbot_script
:param string body: message HTML body """
return self.with_context(mail_create_nosubscribe=True).message_post(
author_id=chatbot_script.operator_partner_id.id,
body=body,
message_type='comment',
subtype_xmlid='mail.mt_comment',
)
def _chatbot_validate_email(self, email_address, chatbot_script):
email_address = html2plaintext(email_address)
email_normalized = email_normalize(email_address)
posted_message = False
error_message = False
if not email_normalized:
error_message = _(
"'%(input_email)s' does not look like a valid email. Can you please try again?",
input_email=email_address
)
posted_message = self._chatbot_post_message(chatbot_script, plaintext2html(error_message))
return {
'success': bool(email_normalized),
'posted_message': posted_message,
'error_message': error_message,
}
def _message_post_after_hook(self, message, msg_vals):
"""
This method is called just before _notify_thread() method which is calling the _message_format()
method. We need a 'chatbot.message' record before it happens to correctly display the message.
It's created only if the mail channel is linked to a chatbot step.
"""
if self.chatbot_current_step_id:
self.env['chatbot.message'].sudo().create({
'mail_message_id': message.id,
'mail_channel_id': self.id,
'script_step_id': self.chatbot_current_step_id.id,
})
return super(MailChannel, self)._message_post_after_hook(message, msg_vals)
def _chatbot_restart(self, chatbot_script):
self.write({
'chatbot_current_step_id': False
})
self.chatbot_message_ids.unlink()
return self._chatbot_post_message(
chatbot_script,
'<div class="o_mail_notification">%s</div>' % _('Restarting conversation...'))
+23 -2
View File
@@ -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
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_livechat_channel_rule im_livechat.channel.rule model_im_livechat_channel_rule 1 0 0 0
8 access_livechat_channel_rule_user im_livechat.channel.rule model_im_livechat_channel_rule im_livechat_group_user 1 1 1 0
9 access_livechat_channel_rule_manager im_livechat.channel.rule model_im_livechat_channel_rule im_livechat_group_manager 1 1 1 1
10 access_chatbot_script chatbot.script model_chatbot_script 1 0 0 0
11 access_chatbot_script_user chatbot.script.user model_chatbot_script im_livechat_group_user 1 1 1 1
12 access_chatbot_script_step chatbot.script.step model_chatbot_script_step 0 0 0 0
13 access_chatbot_script_step_user chatbot.script.step.user model_chatbot_script_step im_livechat_group_user 1 1 1 1
14 access_chatbot_script_answer chatbot.script.answer model_chatbot_script_answer 0 0 0 0
15 access_chatbot_script_answer_user chatbot.script.answer.user model_chatbot_script_answer im_livechat_group_user 1 1 1 1
16 access_chatbot_message_all chatbot.message model_chatbot_message 0 0 0 0
17 access_chatbot_message_user chatbot.script.user model_chatbot_message im_livechat_group_user 1 1 1 1
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -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);
@@ -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);
@@ -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;
@@ -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;
@@ -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;
@@ -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
*/
@@ -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;
@@ -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;
}
}
}
}
}
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<!--
=============================================
Chatbot overrides to base livechat templates
=============================================
-->
<!-- Extend the base livechat window to include a div allowing to restart the script at the end -->
<t t-extend="im_livechat.legacy.mail.AbstractThreadWindow">
<t t-jquery='div.o_chat_mini_composer' t-operation="after">
<div class="d-none o_livechat_chatbot_end bg-200 font-italic text-center border">
<span>Conversation ended...</span>
<a href="#" class="o_livechat_chatbot_restart">Restart</a>
</div>
</t>
</t>
<!-- Extend the base livechat window header to include a button allowing to restart the script -->
<t t-extend="im_livechat.legacy.mail.AbstractThreadWindow.HeaderContent">
<t t-jquery="span.o_thread_window_title" t-operation="after">
<a t-if="widget.isMobile() &amp;&amp; widget.getParent() &amp;&amp; widget.getParent()._chatbotDisplayRestartButton()"
href="#" class="o_livechat_chatbot_main_restart fa fa-1x fa-refresh"
title="Restart Conversation"/>
</t>
<t t-jquery="span.o_thread_window_buttons a.o_thread_window_close" t-operation="before">
<a t-if="widget.getParent() &amp;&amp; widget.getParent()._chatbotDisplayRestartButton()"
href="#" class="o_livechat_chatbot_main_restart fa fa-refresh"
title="Restart Conversation"/>
</t>
</t>
<!-- Extend the base livechat message to allow displaying options for the user to select -->
<t t-extend="im_livechat.legacy.mail.widget.Thread.Message">
<t t-jquery='div.o_thread_message_content' t-operation="append">
<ul t-if="message.getChatbotStepAnswers() &amp;&amp; message.getChatbotStepAnswers().length !== 0 &amp;&amp; !message.getChatbotStepAnswerId()"
class="o_livechat_chatbot_options"
t-att-data-message-id="message.getID()"
t-att-data-chatbot-step-id="message.getChatbotStepId()">
<t t-foreach="message.getChatbotStepAnswers()" t-as="stepAnswer">
<li t-att-data-chatbot-step-answer-id="stepAnswer.id"
t-att-data-chatbot-step-redirect-link="stepAnswer.redirect_link"
class="d-inline-block border border-primary rounded p-2 mr-3 mb-1 font-weight-bold">
<t t-out="stepAnswer.label"/>
</li>
<br/>
</t>
</ul>
</t>
</t>
<!--
=============================================
Tooling / Utils
=============================================
-->
<!--
This small template simulates a fake message from the bot.
The goal is to have something like:
[image] The bot
. . .
With a small animation on the dots to make them bounce.
This fake message is then removed when the chat window is refreshed with the real message.
-->
<t t-name="im_livechat.legacy.chatbot.is_typing_message">
<div class="o_thread_message">
<div class="o_thread_message_sidebar">
<img alt="chatbot_image" t-att-src="chatbotImageSrc"
class="o_thread_message_avatar rounded-circle"/>
</div>
<div class="o_thread_message_core">
<p class="o_mail_info text-muted">
<strong class="o_thread_author" t-out="chatbotName"/>
</p>
<div class="o_thread_message_content">
<img class="o_livechat_chatbot_typing"
src="/im_livechat/static/src/img/chatbot_is_typing.gif"
width="30" alt="is typing"/>
</div>
</div>
</div>
</t>
</templates>
@@ -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;
@@ -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: () => {}
}]));
+3
View File
@@ -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
+145
View File
@@ -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)
@@ -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)
@@ -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])
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_answer_view_form" model="ir.ui.view">
<field name="name">chatbot.script.answer.view.form</field>
<field name="model">chatbot.script.answer</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="script_step_id" invisible="1"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="chatbot_script_answer_view_tree" model="ir.ui.view">
<field name="name">chatbot.script.answer.view.tree</field>
<field name="model">chatbot.script.answer</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="script_step_id"/>
<field name="name"/>
<field name="redirect_link" string="Optional Link"/>
</tree>
</field>
</record>
</data></odoo>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_step_view_form" model="ir.ui.view">
<field name="name">chatbot.script.step.view.form</field>
<field name="model">chatbot.script.step</field>
<field name="arch" type="xml">
<form>
<field name="is_forward_operator_child" invisible="1"/>
<div class="alert alert-info text-center mb-0" role="alert" attrs="{'invisible': [('is_forward_operator_child', '=', False)]}">
<span>Reminder: This step will only be played if no operator is available.</span>
</div>
<div class="alert alert-info text-center mb-0" role="alert" attrs="{'invisible': [('step_type', '!=', 'forward_operator')]}">
<span>Tip: Plan further steps for the Bot in case no operator is available.</span>
</div>
<sheet>
<group>
<field name="sequence" invisible="1"/>
<field name="message" widget="text_emojis" placeholder="e.g. 'How can I help you?'"
attrs="{'required': [('step_type', '!=', 'forward_operator')]}"/>
<field name="chatbot_script_id" invisible="1"/>
<field name="step_type"/>
<field name="triggering_answer_ids" widget="chatbot_triggering_answers_widget"
options="{'no_create': True}"/>
</group>
<group>
<field name="answer_ids" attrs="{'invisible': [('step_type', '!=', 'question_selection')]}" nolabel="1">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="display_name" invisible="1"/>
<field name="name"/>
<field name="redirect_link" string="Optional Link"/>
</tree>
</field>
</group>
</sheet>
</form>
</field>
</record>
<record id="chatbot_script_step_view_tree" model="ir.ui.view">
<field name="name">chatbot.script.step.view.tree</field>
<field name="model">chatbot.script.step</field>
<field name="arch" type="xml">
<tree default_order="sequence asc">
<field name="sequence" widget="handle"/>
<field name="message"/>
<field name="step_type"/>
<field name="answer_ids" widget="many2many_tags"/>
<field name="triggering_answer_ids" widget="many2many_tags"/>
</tree>
</field>
</record>
</data></odoo>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_view_form" model="ir.ui.view">
<field name="name">chatbot.script.view.form</field>
<field name="model">chatbot.script</field>
<field name="arch" type="xml">
<form js_class="im_livechat_chatbot_form_view">
<header>
</header>
<field name="first_step_warning" invisible="1"/>
<div class="alert alert-info text-center" role="alert"
attrs="{'invisible': [('first_step_warning', '!=', 'first_step_operator')]}">
<span>Tip: At least one interaction (Question, Email, ...) is needed before the Bot can perform more complex actions (Forward to an Operator, ...). </span>
<span>Use Channel Rules if you want the Bot to interact with visitors only when no operator is available.</span>
</div>
<div class="alert alert-info text-center" role="alert"
attrs="{'invisible': [('first_step_warning', '!=', 'first_step_invalid')]}">
<span>Tip: At least one interaction (Question, Email, ...) is needed before the Bot can perform more complex actions (Forward to an Operator, ...).</span>
</div>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_livechat_channels" type="object" class="oe_stat_button"
icon="fa-comments" attrs="{'invisible': [('livechat_channel_count', '=', 0)]}">
<field name="livechat_channel_count" string="Channels" widget="statinfo"/>
</button>
</div>
<field name="active" invisible="1"/>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_128'}"/>
<div class="oe_title">
<label for="title" string="Chatbot Name"/>
<h1><field name="title" default_focus="1" placeholder='e.g. "Meeting Scheduler Bot"'/></h1>
</div>
<notebook>
<page string="Script">
<field name="script_step_ids" widget="chatbot_script_step_widget" nolabel="1"
context="{'chatbot_script_answer_display_short_name': 1}"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="chatbot_script_view_tree" model="ir.ui.view">
<field name="name">chatbot.script.view.tree</field>
<field name="model">chatbot.script</field>
<field name="arch" type="xml">
<tree sample="1">
<field name="title"/>
</tree>
</field>
</record>
<record id="chatbot_script_view_search" model="ir.ui.view">
<field name="name">chatbot.script.view.search</field>
<field name="model">chatbot.script</field>
<field name="arch" type="xml">
<search>
<field name="title" string="Name" filter_domain="[('title', 'ilike', self)]"/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="chatbot_script_action" model="ir.actions.act_window">
<field name="name">Chatbot</field>
<field name="res_model">chatbot.script</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a Chatbot
</p><p>
You can create a new Chatbot with a defined script to speak to your website visitors.
</p>
</field>
</record>
</data></odoo>
@@ -78,6 +78,10 @@
<sheet>
<field name="rating_count" invisible="1"/>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="object" name="action_view_chatbot_scripts" icon="fa-android"
attrs="{'invisible': [('chatbot_script_count', '=', 0)]}">
<field string="Chatbots" name="chatbot_script_count" widget="statinfo"/>
</button>
<button class="oe_stat_button" type="action" attrs="{'invisible':[('nbr_channel','=', 0)]}" name="%(mail_channel_action_from_livechat_channel)d" icon="fa-comments">
<field string="Sessions" name="nbr_channel" widget="statinfo"/>
</button>
@@ -232,6 +236,15 @@
<sheet>
<group>
<field name="action" widget="radio"/>
<label for="chatbot_script_id" string="Chatbot" attrs="{'invisible': [('action', '=', 'hide_button')]}"/>
<div attrs="{'invisible': [('action', '=', 'hide_button')]}">
<field name="chatbot_script_id" class="oe_inline" style="width: 60% !important;"
options="{'no_create': True, 'no_open': True}"/>
<span attrs="{'invisible': [('chatbot_script_id', '=', False)]}" class="oe_inline ml-3">
<label for="chatbot_only_if_no_operator" string="Enabled only if no operator"/>
<field name="chatbot_only_if_no_operator"/>
</span>
</div>
<field name="regex_url" placeholder="e.g. /contactus"/>
<label for="auto_popup_timer" class="oe_inline" attrs="{'invisible': [('action', '!=', 'auto_popup')]}"/>
<div class="oe_inline" attrs="{'invisible': [('action', '!=', 'auto_popup')]}">
@@ -326,5 +339,13 @@
groups="im_livechat_group_user"
sequence="15"/>
<menuitem
id="chatbot_config"
name="Chatbots"
parent="livechat_config"
action="chatbot_script_action"
groups="im_livechat_group_user"
sequence="20"/>
</data>
</odoo>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="chatbot_test_script_page">
<t t-call="web.frontend_layout">
<t t-set="no_livechat" t-value="True"/>
<t t-set="title" t-value="chatbot_data['chatbot_name']"/>
<div id="wrap" class="o_livechat_js_chatbot_test_script"
t-att-data-chatbot="json.dumps(chatbot_data)"
t-att-data-channel="json.dumps(channel_data)"
t-att-data-server-url="server_url">
<div groups="im_livechat.im_livechat_group_user" t-ignore="true"
class="alert alert-info alert-dismissible rounded-0 fade show d-print-none css_editable_mode_hidden mb-0">
<div t-ignore="true" class="text-center">
<a t-attf-href="/web#view_type=form&amp;model=chatbot.script&amp;id=#{chatbot_data['chatbot_script_id']}&amp;action=im_livechat.chatbot_script_action">
<span>You are currently testing</span>
<span t-out="chatbot_data['chatbot_name']"/>
<i class="fa fa-fw fa-arrow-right"/>Back to edit mode
</a>
</div>
<button type="button" class="close" data-dismiss="alert" aria-label="Close"> &#215; </button>
</div>
</div>
</t>
</template>
</data>
</odoo>
+21 -17
View File
@@ -326,7 +326,7 @@ class Channel(models.Model):
})
return result
def add_members(self, partner_ids=None, guest_ids=None, invite_to_rtc_call=False):
def add_members(self, partner_ids=None, guest_ids=None, invite_to_rtc_call=False, open_chat_window=False, post_joined_message=True):
""" Adds the given partner_ids and guest_ids as member of self channels. """
self.check_access_rights('write')
self.check_access_rule('write')
@@ -365,27 +365,31 @@ class Channel(models.Model):
members_data = []
guest_members_data = []
for channel_partner in new_members.filtered(lambda channel_partner: channel_partner.partner_id):
user = channel_partner.partner_id.user_ids[0] if channel_partner.partner_id.user_ids else self.env['res.users']
# notify invited members through the bus
if user:
notifications.append((channel_partner.partner_id, 'mail.channel/joined', {
'channel': channel_partner.channel_id.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).sudo().channel_info()[0],
'invited_by_user_id': self.env.user.id,
}))
# notify existing members with a new message in the channel
if channel_partner.partner_id == self.env.user.partner_id:
notification = _('<div class="o_mail_notification">joined the channel</div>')
else:
notification = _(
'<div class="o_mail_notification">invited %s to the channel</div>',
channel_partner.partner_id._get_html_link(),
)
channel_partner.channel_id.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment")
members_data.append({
'id': channel_partner.partner_id.id,
'im_status': channel_partner.partner_id.im_status,
'name': channel_partner.partner_id.name,
})
# notify invited members through the bus
user = channel_partner.partner_id.user_ids[0] if channel_partner.partner_id.user_ids else self.env['res.users']
if user:
notifications.append((channel_partner.partner_id, 'mail.channel/joined', {
'channel': channel_partner.channel_id.with_user(user).with_context(allowed_company_ids=user.company_ids.ids).sudo().channel_info()[0],
'invited_by_user_id': self.env.user.id,
'open_chat_window': open_chat_window,
}))
if post_joined_message:
# notify existing members with a new message in the channel
if channel_partner.partner_id == self.env.user.partner_id:
notification = _('<div class="o_mail_notification">joined the channel</div>')
else:
notification = _(
'<div class="o_mail_notification">invited %s to the channel</div>',
channel_partner.partner_id._get_html_link(),
)
channel_partner.channel_id.message_post(body=notification, message_type="notification", subtype_xmlid="mail.mt_comment")
for channel_partner in new_members.filtered(lambda channel_partner: channel_partner.guest_id):
channel_partner.channel_id.message_post(body=_('<div class="o_mail_notification">joined the channel</div>'), message_type="notification", subtype_xmlid="mail.mt_comment")
guest_members_data.append({
@@ -183,8 +183,9 @@ registerModel({
* @param {Object} payload
* @param {Thread} payload.channel
* @param {integer} [payload.invited_by_user_id]
* @param {boolean} [payload.open_chat_window] if true, will pin the channel
*/
_handleNotificationChannelJoined({ channel: channelData, invited_by_user_id: invitedByUserId }) {
_handleNotificationChannelJoined({ channel: channelData, invited_by_user_id: invitedByUserId, open_chat_window: openChatWindow }) {
const channel = this.messaging.models['Thread'].insert(this.messaging.models['Thread'].convertData(channelData));
if (this.messaging.currentUser && invitedByUserId !== this.messaging.currentUser.id) {
// Current user was invited by someone else.
@@ -196,6 +197,13 @@ registerModel({
type: 'info',
});
}
if (openChatWindow) {
// open chat upon being invited (if it was not already opened or folded)
if (channel.channel_type !== 'channel' && !this.messaging.device.isMobile && !channel.chatWindow) {
this.messaging.chatWindowManager.openThread(channel);
}
}
},
/**
* @private
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import chatbot_script_step
from . import crm_lead
from . import mail_channel
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models
class ChatbotScriptStep(models.Model):
_inherit = 'chatbot.script.step'
def _chatbot_crm_prepare_lead_values(self, mail_channel, description):
values = super()._chatbot_crm_prepare_lead_values(mail_channel, description)
if mail_channel.livechat_visitor_id:
values['name'] = _("%s's New Lead", mail_channel.livechat_visitor_id.display_name)
values['visitor_ids'] = [(4, mail_channel.livechat_visitor_id.id)]
return values
+12
View File
@@ -1,3 +1,15 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
from odoo import api, SUPERUSER_ID
def _post_init_website_livechat(cr, registry):
""" Chatbot operator partners must be 'website_published' to ensure
their avatar is visible for end users. """
env = api.Environment(cr, SUPERUSER_ID, {})
env['chatbot.script'].search([]).operator_partner_id.write({
'is_published': True
})
+9
View File
@@ -11,15 +11,20 @@ Allow website visitors to chat with the collaborators. This module also brings a
'installable': True,
'application': False,
'auto_install': True,
'post_init_hook': '_post_init_website_livechat',
'data': [
'views/website_livechat.xml',
'views/res_config_settings_views.xml',
'views/im_livechat_chatbot_script_view.xml',
'views/website_livechat_view.xml',
'views/website_visitor_views.xml',
'security/ir.model.access.csv',
'security/website_livechat.xml',
'data/website_livechat_data.xml',
],
'demo': [
'data/website_livechat_chatbot_demo.xml',
],
'assets': {
'mail.assets_discuss_public': [
'website_livechat/static/src/components/*/*',
@@ -30,8 +35,12 @@ Allow website visitors to chat with the collaborators. This module also brings a
'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',
'website_livechat/static/src/legacy/public_livechat.js',
'website_livechat/static/src/legacy/website_livechat_chatbot_test_script.js',
'im_livechat/static/src/legacy/public_livechat.scss',
'im_livechat/static/src/legacy/public_livechat_chatbot.scss',
'website_livechat/static/src/legacy/public_livechat.scss',
],
'website.assets_editor': [
@@ -1,3 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import chatbot
from . import main
from . import test
@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo.http import request
class WebsiteLivechatChatbotScriptController(http.Controller):
@http.route('/chatbot/<model("chatbot.script"):chatbot_script>/test',
type="http", auth="user", website=True)
def chatbot_test_script(self, chatbot_script):
""" Custom route allowing to test a chatbot script.
As we don't have a im_livechat.channel linked to it, we pre-emptively create a mail.channel
that will hold the conversation between the bot and the user testing the script. """
mail_channel_values = {
'channel_last_seen_partner_ids': [(0, 0, {
'partner_id': chatbot_script.operator_partner_id.id,
'is_pinned': False
}, {
'partner_id': request.env.user.partner_id.id
})],
'livechat_active': True,
'livechat_operator_id': chatbot_script.operator_partner_id.id,
'chatbot_current_step_id': chatbot_script._get_welcome_steps()[-1].id,
'anonymous_name': False,
'channel_type': 'livechat',
'name': chatbot_script.title,
'public': 'private',
}
visitor_sudo = request.env['website.visitor']._get_visitor_from_request()
if visitor_sudo:
mail_channel_values['livechat_visitor_id'] = visitor_sudo.id
mail_channel = request.env['mail.channel'].create(mail_channel_values)
return request.render("im_livechat.chatbot_test_script_page", {
'server_url': chatbot_script.get_base_url(),
'channel_data': mail_channel.channel_info()[0],
'chatbot_data': chatbot_script._format_for_frontend()
})
+2 -2
View File
@@ -60,9 +60,9 @@ class WebsiteLivechat(LivechatController):
return request.render("website_livechat.channel_page", values)
@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):
""" Override to use visitor name instead of 'Visitor' whenever a visitor start a livechat session. """
visitor_sudo = request.env['website.visitor']._get_visitor_from_request()
if visitor_sudo:
anonymous_name = visitor_sudo.with_context(lang=visitor_sudo.lang_id.code).display_name
return super(WebsiteLivechat, self).get_session(channel_id, anonymous_name, previous_operator_id=previous_operator_id, **kwargs)
return super(WebsiteLivechat, self).get_session(channel_id, anonymous_name, previous_operator_id=previous_operator_id, chatbot_script_id=chatbot_script_id, **kwargs)
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data noupdate="1">
<!-- Add a Channel Rule to demonstrate our bot on the '/contactus' page -->
<record id="website_livechat_channel_rule_chatbot" model="im_livechat.channel.rule">
<field name="regex_url">/contactus</field>
<field name="sequence">5</field>
<field name="action">auto_popup</field>
<field name="auto_popup_timer">2</field>
<field name="chatbot_script_id" ref="im_livechat.chatbot_script_welcome_bot"/>
<field name="channel_id" ref="im_livechat.im_livechat_channel_data"/>
</record>
</data></odoo>
@@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
from . import chatbot_script
from . import chatbot_script_step
from . import im_livechat
from . import im_livechat_channel
from . import ir_http
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ChatbotScript(models.Model):
_inherit = 'chatbot.script'
def action_test_script(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': '/chatbot/%s/test' % self.id,
'target': 'self',
}
def _prepare_operator_partner_values(self, name, image):
res = super()._prepare_operator_partner_values(name, image)
res['is_published'] = True
return res
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ChatbotScriptStep(models.Model):
_inherit = 'chatbot.script.step'
def _chatbot_prepare_customer_values(self, mail_channel, create_partner=True, update_partner=True):
values = super()._chatbot_prepare_customer_values(mail_channel, create_partner, update_partner)
visitor_id = mail_channel.livechat_visitor_id
if visitor_id:
if not values.get('email') and visitor_id.email:
values['email'] = visitor_id.email
if not values.get('phone') and visitor_id.mobile:
values['phone'] = visitor_id.mobile
values['country_id'] = visitor_id.country_id
return values
@@ -7,8 +7,8 @@ from odoo import models, _
class ImLivechatChannel(models.Model):
_inherit = 'im_livechat.channel'
def _get_livechat_mail_channel_vals(self, anonymous_name, operator, user_id=None, country_id=None):
mail_channel_vals = super(ImLivechatChannel, self)._get_livechat_mail_channel_vals(anonymous_name, operator, user_id=user_id, country_id=country_id)
def _get_livechat_mail_channel_vals(self, anonymous_name, operator=None, chatbot=None, user_id=None, country_id=None):
mail_channel_vals = super(ImLivechatChannel, self)._get_livechat_mail_channel_vals(anonymous_name, operator, chatbot, user_id=user_id, country_id=country_id)
visitor_sudo = self.env['website.visitor']._get_visitor_from_request()
if visitor_sudo:
mail_channel_vals['livechat_visitor_id'] = visitor_sudo.id
@@ -16,6 +16,7 @@ class ImLivechatChannel(models.Model):
# TODO DBE : Move this into the proper method (open or init mail channel)
chat_request_channel = self.env['mail.channel'].sudo().search([('livechat_visitor_id', '=', visitor_sudo.id), ('livechat_active', '=', True)])
for mail_channel in chat_request_channel:
mail_channel._close_livechat_session(cancel=True, operator=operator.name)
operator_name = operator.name if operator else chatbot.operator_partner_id.name
mail_channel._close_livechat_session(cancel=True, operator=operator_name)
return mail_channel_vals
@@ -0,0 +1,91 @@
/** @odoo-module alias=website_livechat.chatbot_test_script **/
import publicWidget from 'web.public.widget';
import utils from 'web.utils';
import { LivechatButton } from 'im_livechat.legacy.im_livechat.im_livechat';
import WebsiteLivechat from 'im_livechat.legacy.im_livechat.model.WebsiteLivechat';
/**
* Override of the LivechatButton to create a testing environment for the chatbot script.
*
* The biggest difference here is that we don't have a 'im_livechat.channel' to work with.
* The 'mail.channel' holding the conversation between the bot and the testing user has been created
* by the 'chatbot/<model("chatbot.script"):chatbot>/test' endpoint.
*/
const LivechatButtonTestChatbot = LivechatButton.extend({
/**
* Initialize various data received from the 'chatbot_test_script_page' template.
*/
init: function (parent, chatbotData) {
this._super(...arguments);
this._rule = {
'action': 'auto_popup',
'auto_popup_timer': 0,
};
this._chatbot = chatbotData.chatbot;
this._chatbotCurrentStep = this._chatbot.chatbot_welcome_steps[
this._chatbot.chatbot_welcome_steps.length - 1];
this._channelData = chatbotData.channel;
this._isChatbot = true;
this._serverURL = chatbotData.serverUrl;
this.options.input_placeholder = '';
},
/**
* Overridden to avoid calling the "init" endpoint as it requires a im_livechat.channel linked
* to work properly.
*
* @override
*/
willStart: function () {
return this._loadQWebTemplate();
},
/**
* Overridden to avoid calling the "get_session" endpoint as it requires a im_livechat.channel
* linked to work properly.
*
* Here, we already have a mail.channel created (see 'website_livechat_chatbot_test_script') so we
* use its configuration to create the 'WebsiteLivechat' Widget.
*
* @private
* @override
*/
_openChat: function () {
this._livechat = new WebsiteLivechat({
parent: this,
data: this._channelData,
});
return this._openChatWindow().then(() => {
this._sendWelcomeMessage();
this._renderMessages();
this.call('bus_service', 'addChannel', this._livechat.getUUID());
this.call('bus_service', 'startPolling');
utils.set_cookie('im_livechat_session', utils.unaccent(JSON.stringify(this._livechat.toData()), true), 60 * 60);
this._openingChat = false;
});
},
});
publicWidget.registry.livechatChatbotTestScript = publicWidget.Widget.extend({
selector: '.o_livechat_js_chatbot_test_script',
/**
* Remove any existing session cookie to start fresh
*/
start: function () {
utils.set_cookie('im_livechat_session', '', -1);
utils.set_cookie('im_livechat_auto_popup', '', -1);
utils.set_cookie('im_livechat_history', '', -1);
utils.set_cookie('im_livechat_previous_operator_pid', '', -1);
return this._super(...arguments).then(() => {
this.livechatButton = new LivechatButtonTestChatbot(this, this.$el.data());
this.livechatButton.appendTo(document.body);
});
}
});
@@ -0,0 +1,169 @@
/** @odoo-module */
import tour from "web_tour.tour";
import LivechatButton from "im_livechat.legacy.im_livechat.im_livechat";
LivechatButton.LivechatButton.include({
/**
* Let us make it a bit faster than the default delay (3500ms).
* Let us also debounce waiting for more user inputs for only 1000ms.
*/
start: function () {
this._chatbotMessageDelay = 1000;
this._debouncedChatbotAwaitUserInput = _.debounce(
this._chatbotAwaitUserInput.bind(this),
1000);
return this._super(...arguments);
},
/**
* Small override that removes current messages when restarting.
* This allows to easily check for new posted messages without having the old ones "polluting"
* the thread and making it hard to write proper jQuery selectors in the tour.
*/
_onChatbotRestartScript: function () {
this._messages = [];
this._renderMessages();
this._super(...arguments);
}
});
const messagesContain = (text) => `div.o_thread_message_content:contains("${text}")`;
tour.register('website_livechat_chatbot_flow_tour', {
test: true,
url: '/',
}, [{
trigger: messagesContain("Hello! I'm a bot!"),
run: () => {} // check first welcome message is posted
}, {
trigger: messagesContain("I help lost visitors find their way."),
run: () => {} // check second welcome message is posted
}, {
trigger: messagesContain("How can I help you?"),
run: () => {} // check question_selection message is posted
}, {
trigger: '.o_livechat_chatbot_options li:contains("I want to buy the software")',
run: 'click'
}, {
trigger: messagesContain("I want to buy the software"),
run: () => {} // check selected option is posted
}, {
trigger: messagesContain("Can you give us your email please?"),
run: () => {} // check ask email step following selecting option A
}, {
trigger: "input.o_composer_text_field",
run: "text No, you won't get my email!"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("'No, you won't get my email!' does not look like a valid email. Can you please try again?"),
run: () => {} // check invalid email detected and the bot asks for a retry
}, {
trigger: "input.o_composer_text_field",
run: "text okfine@fakeemail.com"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("Your email is validated, thank you!"),
run: () => {} // check that this time the email goes through and we proceed to next step
}, {
trigger: messagesContain("Would you mind providing your website address?"),
run: () => {} // should ask for website now
}, {
trigger: "input.o_composer_text_field",
run: "text https://www.fakeaddress.com"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("Great, do you want to leave any feedback for us to improve?"),
run: () => {} // should ask for feedback now
}, {
trigger: "input.o_composer_text_field",
run: "text Yes, actually, I'm glad you asked!"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: "input.o_composer_text_field",
run: "text I think it's outrageous that you ask for all my personal information!"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: "input.o_composer_text_field",
run: "text I will be sure to take this to your manager!"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("Ok bye!"),
run: () => {} // last step is displayed
}, {
trigger: '.o_livechat_chatbot_main_restart',
run: 'click'
}, {
trigger: messagesContain("Restarting conversation..."),
run: () => {} // check that conversation is properly restarting
}, {
trigger: messagesContain("Hello! I'm a bot!"),
run: () => {} // check first welcome message is posted
}, {
trigger: messagesContain("I help lost visitors find their way."),
run: () => {} // check second welcome message is posted
}, {
trigger: messagesContain("How can I help you?"),
run: () => {} // check question_selection message is posted
}, {
trigger: '.o_livechat_chatbot_options li:contains("Pricing Question")',
run: 'click'
}, {
trigger: messagesContain("For any pricing question, feel free ton contact us at pricing@mycompany.com"),
run: () => {} // the path should now go towards 'Pricing Question (first part)'
}, {
trigger: messagesContain("We will reach back to you as soon as we can!"),
run: () => {} // the path should now go towards 'Pricing Question (second part)'
}, {
trigger: messagesContain("Would you mind providing your website address?"),
run: () => {} // should ask for website now
}, {
trigger: "input.o_composer_text_field",
run: "text no"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("Great, do you want to leave any feedback for us to improve?"),
run: () => {} // should ask for feedback now
}, {
trigger: "input.o_composer_text_field",
run: "text no, nothing so say"
}, {
trigger: "input.o_composer_text_field",
run: () => {
$('input.o_composer_text_field').trigger($.Event('keydown', {which: $.ui.keyCode.ENTER}));
}
}, {
trigger: messagesContain("Ok bye!"),
run: () => {} // last step is displayed
}]);
@@ -3,6 +3,7 @@ odoo.define('website_livechat.tour_common', function(require) {
var session = require('web.session');
var LivechatButton = require('im_livechat.legacy.im_livechat.im_livechat').LivechatButton;
var utils = require('web.utils');
/**
* Alter this method for test purposes.
@@ -31,10 +32,10 @@ LivechatButton.include({
payload: {
id: self._livechat._id,
message: {
id: -1,
id: self._messages.length + 1,
author_id: [0, 'Website Visitor Test'],
email_from: 'Website Visitor Test',
body: '<p>' + message.content + '</p>',
body: utils.Markup('<p>' + message.content + '</p>'),
is_discussion: true,
subtype_id: [1, "Discussions"],
date: moment().format('YYYY-MM-DD HH:mm:ss'),
@@ -2,6 +2,7 @@
from . import common
from . import test_ui
from . import test_chatbot_ui
from . import test_livechat_basic_flow
from . import test_livechat_request
from . import test_website_visitor
@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import tests
from odoo.addons.im_livechat.tests.chatbot_common import ChatbotCase
from odoo.addons.website_livechat.tests.common import TestLivechatCommon
@tests.tagged('post_install', '-at_install')
class TestLivechatChatbotUI(tests.HttpCase, TestLivechatCommon, ChatbotCase):
def setUp(self):
super().setUp()
self.env['im_livechat.channel'].search([
('id', '!=', self.livechat_channel.id)
]).unlink() # delete other channels to avoid them messing with the URL rules
self.livechat_channel.write({
'is_published': True,
'rule_ids': [(5, 0), (0, 0, {
'action': 'auto_popup',
'regex_url': '/',
'chatbot_script_id': self.chatbot_script.id,
})]
})
self.env.ref('website.default_website').channel_id = self.livechat_channel.id
def test_complete_chatbot_flow_ui(self):
self.start_tour('/', 'website_livechat_chatbot_flow_tour', step_delay=100)
operator = self.chatbot_script.operator_partner_id
livechat_mail_channel = self.env['mail.channel'].search([
('livechat_channel_id', '=', self.livechat_channel.id),
('livechat_operator_id', '=', operator.id),
('message_ids', '!=', False),
])
self.assertTrue(bool(livechat_mail_channel))
self.assertEqual(len(livechat_mail_channel), 1)
conversation_messages = livechat_mail_channel.message_ids.sorted('id')
expected_messages = [
("Hello! I'm a bot!", operator, False),
("I help lost visitors find their way.", operator, False),
# next message would normally have 'self.step_dispatch_buy_software' as answer
# but it's wiped when restarting the script
("How can I help you?", operator, False),
("I want to buy the software", False, False),
("Can you give us your email please?", operator, False),
("No, you won't get my email!", False, False),
("'No, you won't get my email!' does not look like a valid email. Can you please try again?", operator, False),
("okfine@fakeemail.com", False, False),
("Your email is validated, thank you!", operator, False),
("Would you mind providing your website address?", operator, False),
("https://www.fakeaddress.com", False, False),
("Great, do you want to leave any feedback for us to improve?", operator, False),
("Yes, actually, I'm glad you asked!", False, False),
("I think it's outrageous that you ask for all my personal information!", False, False),
("I will be sure to take this to your manager!", False, False),
("Ok bye!", operator, False),
("Restarting conversation...", operator, False),
("Hello! I'm a bot!", operator, False),
("I help lost visitors find their way.", operator, False),
("How can I help you?", operator, self.step_dispatch_pricing),
("Pricing Question", False, False),
("For any pricing question, feel free ton contact us at pricing@mycompany.com", operator, False),
("We will reach back to you as soon as we can!", operator, False),
("Would you mind providing your website address?", operator, False),
("no", False, False),
("Great, do you want to leave any feedback for us to improve?", operator, False),
("no, nothing so say", False, False),
("Ok bye!", operator, False),
]
self.assertEqual(len(conversation_messages), len(expected_messages))
# check that the whole conversation is correctly saved
# including welcome steps: see chatbot.script#_post_welcome_steps
for conversation_message, expected_message in zip(conversation_messages, expected_messages):
[body, operator, user_script_answer_id] = expected_message
self.assertIn(body, conversation_message.body)
if operator:
self.assertEqual(conversation_message.author_id, operator)
else:
self.assertNotEqual(conversation_message.author_id, operator)
if user_script_answer_id:
self.assertEqual(
user_script_answer_id,
self.env['chatbot.message'].search([
('mail_message_id', '=', conversation_message.id)
], limit=1).user_script_answer_id
)
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo><data>
<record id="chatbot_script_view_form" model="ir.ui.view">
<field name="name">chatbot.script.view.form.inherit.website.livechat</field>
<field name="model">chatbot.script</field>
<field name="inherit_id" ref="im_livechat.chatbot_script_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button name="action_test_script" type="object" class="btn btn-secondary" string="Test"
attrs="{'invisible': ['|', ('active', '!=', True), ('script_step_ids', '=', [])]}"/>
</xpath>
</field>
</record>
</data></odoo>