[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:
co-authored by
Patrick Hoste
Aurélien Warnon
commit
cbc9279eaa
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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...'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
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() && widget.getParent() && 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() && 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() && message.getChatbotStepAnswers().length !== 0 && !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: () => {}
|
||||
}]));
|
||||
@@ -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
|
||||
|
||||
@@ -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&model=chatbot.script&id=#{chatbot_data['chatbot_script_id']}&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"> × </button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -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
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user