[FIX] im_livechat, *: fix channel created before user interaction

*: website_livechat.

Before this PR, a `mail.channel` record would have been created before
any user interaction. Since the introduction of the welcome bot, this
issue has gotten worse. Indeed, any user accessing a page with the bot
enabled created a useless channel.

Before 15.3, around 15k channels were created each month, after 15.3,
300-500k channels are created each month, most of them empty channels
whom creation could have been avoided.

This PR fixes the issue by waiting the first user interaction before
creating the channel.

closes odoo/odoo#108566

X-original-commit: b2eaf32ba92e537c7eefba6489533613be1b8c91
Signed-off-by: Stockbauer Matthieu (tsm) <tsm@odoo.com>
Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
tsm-odoo
2022-12-28 10:41:05 +01:00
parent ec2bc5e47c
commit 131948aca5
14 changed files with 205 additions and 45 deletions
+4 -2
View File
@@ -113,7 +113,7 @@ class LivechatController(http.Controller):
).get_response()
@http.route('/im_livechat/get_session', type="json", auth='public', cors="*")
def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, **kwargs):
def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, persisted=True, **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.
@@ -141,7 +141,9 @@ class LivechatController(http.Controller):
previous_operator_id=previous_operator_id,
chatbot_script=chatbot_script,
user_id=user_id,
country_id=country_id)
country_id=country_id,
persisted=persisted
)
@http.route('/im_livechat/feedback', type='json', auth='public', cors="*")
def feedback(self, uuid, rate, reason=None, **kwargs):
+7
View File
@@ -1177,6 +1177,13 @@ msgstr ""
msgid "No history found"
msgstr ""
#. module: im_livechat
#. odoo-javascript
#: code:addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.xml:0
#, python-format
msgid "No operator available"
msgstr ""
#. module: im_livechat
#. openerp-web
#: code:addons/im_livechat/static/src/legacy/widgets/public_livechat_view/public_livechat_view.xml:0
@@ -161,14 +161,15 @@ class ImLivechatChannel(models.Model):
'name': name,
}
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
def _open_livechat_mail_channel(self, anonymous_name, previous_operator_id=None, chatbot_script=None, user_id=None, country_id=None, persisted=True):
""" Return a livechat session. If the session is persisted, creates a mail.channel record 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 anonymous_name : the name of the anonymous person of the session
: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
:param country_code : the country of the anonymous person of the session
:param persisted: whether or not the session should be persisted
:type anonymous_name : str
:return : channel header
:rtype : dict
@@ -192,13 +193,23 @@ class ImLivechatChannel(models.Model):
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, 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)
if user_operator:
mail_channel._broadcast([user_operator.partner_id.id])
return mail_channel.sudo().channel_info()[0]
if persisted:
# create the session, and add the link with the given channel
mail_channel = self.env["mail.channel"].with_context(mail_create_nosubscribe=False).sudo().create(mail_channel_vals)
if user_operator:
mail_channel._broadcast([user_operator.partner_id.id])
return mail_channel.sudo().channel_info()[0]
else:
operator_partner_id = user_operator.partner_id if user_operator else chatbot_script.operator_partner_id
display_name = operator_partner_id.user_livechat_username or operator_partner_id.display_name
return {
'name': mail_channel_vals['name'],
'chatbot_current_step_id': mail_channel_vals['chatbot_current_step_id'],
'state': 'open',
'operator_pid': (operator_partner_id.id, display_name.replace(',', '')),
'chatbot_script_id': chatbot_script.id if chatbot_script else None
}
def _get_random_operator(self):
""" Return a random operator from the available users of the channel that have the lowest number of active livechats.
@@ -289,6 +289,7 @@ const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
*/
toData() {
return {
chatbot_script_id: this.messaging.publicLivechatGlobal.publicLivechat.data.chatbot_script_id,
folded: this.messaging.publicLivechatGlobal.publicLivechat.isFolded,
id: this.messaging.publicLivechatGlobal.publicLivechat.id,
message_unread_counter: this.messaging.publicLivechatGlobal.publicLivechat.unreadCounter,
@@ -338,6 +339,11 @@ const PublicLivechat = Class.extend(Mixins.EventDispatcherMixin, {
* otherwise
*/
_notifyMyselfTyping(params) {
if (this.messaging.publicLivechatGlobal.publicLivechat.isTemporary) {
// channel is not created yet, it will be when first message is
// sent. Until then, do not notify visitor is typing.
return;
}
return session.rpc('/im_livechat/notify_typing', {
uuid: this.messaging.publicLivechatGlobal.publicLivechat.uuid,
is_typing: params.typing,
@@ -27,7 +27,12 @@ import LivechatButton from '@im_livechat/legacy/widgets/livechat_button';
_prepareGetSessionParameters() {
const parameters = this._super(...arguments);
if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
const { publicLivechat } = this.messaging.publicLivechatGlobal;
if (publicLivechat && publicLivechat.isTemporary && !publicLivechat.data.chatbot_script_id) {
return parameters;
} else if (publicLivechat && publicLivechat.data.chatbot_script_id) {
parameters.chatbot_script_id = publicLivechat.data.chatbot_script_id;
} else if (this.messaging.publicLivechatGlobal.chatbot.isActive) {
parameters.chatbot_script_id = this.messaging.publicLivechatGlobal.chatbot.scriptId;
}
@@ -52,10 +52,6 @@ const PublicLivechatWindow = Widget.extend({
} else {
this._focusInput();
}
if (!config.device.isMobile) {
const margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
this.$el.css(margin_dir, $.position.scrollbarWidth());
}
const def = this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.replace(this.$('.o_thread_window_content')).then(() => {
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.$el.on('scroll', this, this._debouncedOnScroll);
});
@@ -115,6 +111,29 @@ const PublicLivechatWindow = Widget.extend({
renderHeader() {
this.$header.html(qweb.render('im_livechat.legacy.PublicLivechatWindow.HeaderContent', { widget: this }));
},
/**
* Render the chat window itself.
*/
renderChatWindow() {
this.renderElement();
this.adjustPosition();
},
/**
* Compute position of this chat window and apply corresponding styles to
* the underlying widget.
*/
adjustPosition() {
const cssProps = { bottom: 0 };
cssProps[this.messaging.locale.textDirection === 'rtl' ? 'left' : 'right'] = 0;
if (!config.device.isMobile) {
const margin_dir = _t.database.parameters.direction === "rtl" ? "margin-left" : "margin-right";
cssProps[margin_dir] = $.position.scrollbarWidth();
}
this.$el.css(cssProps);
},
/**
* Replace the thread content with provided new content
*
@@ -138,7 +157,9 @@ const PublicLivechatWindow = Widget.extend({
folded = !this.messaging.publicLivechatGlobal.publicLivechat.isFolded;
}
this.messaging.publicLivechatGlobal.publicLivechat.update({ isFolded: folded });
setCookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.widget.toData()), true), 60 * 60, 'required');
if (this.messaging.publicLivechatGlobal.publicLivechat.operator) {
setCookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.widget.toData()), true), 60 * 60, 'required');
}
this.updateVisualFoldState();
},
/**
@@ -198,6 +219,9 @@ const PublicLivechatWindow = Widget.extend({
} catch {
await this.messaging.publicLivechatGlobal.livechatButtonView.sendMessage(messageData); // try again just in case
}
if (!this.messaging.publicLivechatGlobal.publicLivechat.operator) {
return;
}
this.messaging.publicLivechatGlobal.publicLivechat.widget.postMessage(messageData)
.then(() => {
this.messaging.publicLivechatGlobal.chatWindow.publicLivechatView.widget.scrollToBottom();
@@ -16,14 +16,19 @@
<t t-set="thread" t-value="widget.messaging.publicLivechatGlobal.publicLivechat.widget"/>
</t>
</div>
<div class="o_thread_window_content">
</div>
<div class="o_thread_composer o_chat_mini_composer">
<input class="o_composer_text_field o_PublicLivechatWindow_composer" t-att-placeholder="widget.messaging.publicLivechatGlobal.chatWindow.inputPlaceholder"/>
<t t-if="widget.messaging.publicLivechatGlobal.publicLivechat.operator">
<div class="o_thread_window_content">
</div>
<div class="o_thread_composer o_chat_mini_composer">
<input class="o_composer_text_field o_PublicLivechatWindow_composer" t-att-placeholder="widget.messaging.publicLivechatGlobal.chatWindow.inputPlaceholder"/>
</div>
</t>
<div t-else="" class="d-flex justify-content-center align-items-center flex-grow-1">
<p class="text-500">No operator available</p>
</div>
</div>
</t>
<!--
@param {im_livechat/legacy/widgets/public_livechat_window/public_livechat_window} widget
-->
@@ -402,6 +402,13 @@ Model({
* display that restart button.
*/
compute() {
const { publicLivechat } = this.messaging.publicLivechatGlobal;
if (publicLivechat && !publicLivechat.operator) {
return false;
}
if (publicLivechat && !publicLivechat.data.chatbot_script_id) {
return false;
}
return Boolean(
!this.currentStep ||
(
@@ -2,8 +2,7 @@
import { attr, clear, one, Model } from '@mail/model';
import {unaccent} from 'web.utils';
import {getCookie, setCookie, deleteCookie} from 'web.utils.cookies';
import {getCookie, deleteCookie} from 'web.utils.cookies';
Model({
name: 'LivechatButtonView',
@@ -129,9 +128,7 @@ Model({
async openChatWindow() {
this.messaging.publicLivechatGlobal.update({ chatWindow: {} });
await this.messaging.publicLivechatGlobal.chatWindow.widget.appendTo($('body'));
const cssProps = { bottom: 0 };
cssProps[this.messaging.locale.textDirection === 'rtl' ? 'left' : 'right'] = 0;
this.messaging.publicLivechatGlobal.chatWindow.widget.$el.css(cssProps);
this.messaging.publicLivechatGlobal.chatWindow.widget.adjustPosition();
this.widget.$el.hide();
this._openChatWindowChatbot();
},
@@ -139,6 +136,12 @@ Model({
* @param {Object} message
*/
async sendMessage(message) {
if (this.messaging.publicLivechatGlobal.publicLivechat.isTemporary) {
await this.messaging.publicLivechatGlobal.publicLivechat.createLivechatChannel();
if (!this.messaging.publicLivechatGlobal.publicLivechat.operator) {
return;
}
}
await this._sendMessageChatbotBefore();
await this._sendMessage(message);
this._sendMessageChatbotAfter();
@@ -194,7 +197,10 @@ Model({
this.messaging.publicLivechatGlobal.update({ messages: clear() });
def = this.messaging.rpc({
route: '/im_livechat/get_session',
params: this.widget._prepareGetSessionParameters(),
params: {
...this.widget._prepareGetSessionParameters(),
persisted: false,
},
}, { silent: true });
}
def.then((livechatData) => {
@@ -223,15 +229,7 @@ Model({
this.widget._sendWelcomeMessage();
}
this.messaging.publicLivechatGlobal.chatWindow.renderMessages();
this.messaging.publicLivechatGlobal.update({ notificationHandler: {} });
setCookie('im_livechat_session', unaccent(JSON.stringify(this.messaging.publicLivechatGlobal.publicLivechat.widget.toData()), true), 60 * 60, 'required');
setCookie('im_livechat_auto_popup', JSON.stringify(false), 60 * 60, 'optional');
if (this.messaging.publicLivechatGlobal.publicLivechat.operator) {
const operatorPidId = this.messaging.publicLivechatGlobal.publicLivechat.operator.id;
const oneWeek = 7 * 24 * 60 * 60;
setCookie('im_livechat_previous_operator_pid', operatorPidId, oneWeek, 'optional');
}
this.messaging.publicLivechatGlobal.publicLivechat.updateSessionCookie();
});
}
}).then(() => {
@@ -4,6 +4,9 @@ import PublicLivechat from '@im_livechat/legacy/models/public_livechat';
import { attr, clear, one, Model } from '@mail/model';
import { unaccent } from 'web.utils';
import { deleteCookie, setCookie } from 'web.utils.cookies';
Model({
name: 'PublicLivechat',
lifecycleHooks: {
@@ -19,6 +22,38 @@ Model({
this.widget.destroy();
},
},
recordMethods: {
async createLivechatChannel() {
const livechatData = await this.messaging.rpc({
route: "/im_livechat/get_session",
params: this.messaging.publicLivechatGlobal.livechatButtonView.widget._prepareGetSessionParameters(),
});
if (!livechatData || !livechatData.operator_pid) {
this.update({ data: clear() });
deleteCookie("im_livechat_session");
this.messaging.publicLivechatGlobal.chatWindow.widget.renderChatWindow();
} else {
this.update({ data: livechatData });
this.widget.data = livechatData;
this.updateSessionCookie();
}
},
updateSessionCookie() {
deleteCookie("im_livechat_session");
setCookie(
"im_livechat_session",
unaccent(JSON.stringify(this.widget.toData()), true),
60 * 60,
"required"
);
setCookie("im_livechat_auto_popup", JSON.stringify(false), 60 * 60, "optional");
if (this.operator) {
const operatorPidId = this.operator.id;
const oneWeek = 7 * 24 * 60 * 60;
setCookie("im_livechat_previous_operator_pid", operatorPidId, oneWeek, "optional");
}
},
},
fields: {
data: attr(),
id: attr({
@@ -32,13 +67,21 @@ Model({
isFolded: attr({
default: false,
}),
isTemporary: attr({
compute() {
if (!this.data || !this.data.id) {
return true;
}
return false;
},
}),
publicLivechatGlobalOwner: one('PublicLivechatGlobal', {
identifying: true,
inverse: 'publicLivechat',
}),
name: attr({
compute() {
if (!this.data) {
if (!this.data || !this.operator) {
return clear();
}
return this.data.name;
@@ -42,12 +42,13 @@ Model({
await this._willStartChatbot();
},
async _willStart() {
const cookie = getCookie('im_livechat_session');
if (cookie) {
const channel = JSON.parse(cookie);
const strCookie = getCookie('im_livechat_session');
const isSessionCookieAvailable = Boolean(strCookie);
const cookie = JSON.parse(strCookie || '{}');
if (cookie.id) {
const history = await this.messaging.rpc({
route: '/mail/chat_history',
params: { uuid: channel.uuid, limit: 100 },
params: { uuid: cookie.uuid, limit: 100 },
});
history.reverse();
this.update({ history });
@@ -55,6 +56,8 @@ Model({
message.body = Markup(message.body);
}
this.update({ isAvailableForMe: true });
} else if (isSessionCookieAvailable) {
this.update({ history: [], isAvailableForMe: true });
} else {
const result = await this.messaging.rpc({
route: '/im_livechat/init',
@@ -185,6 +188,9 @@ Model({
if (!this.publicLivechat) {
return clear();
}
if (!this.publicLivechat.operator) {
return clear();
}
return this.lastMessage.authorId !== this.publicLivechat.operator.id;
},
default: false,
@@ -219,6 +225,12 @@ Model({
messages: many('PublicLivechatMessage'),
notificationHandler: one('PublicLivechatGlobalNotificationHandler', {
inverse: 'publicLivechatGlobalOwner',
compute() {
if (this.publicLivechat && !this.publicLivechat.isTemporary) {
return {};
}
return clear();
}
}),
options: attr({
default: {},
@@ -0,0 +1,32 @@
/** @odoo-module */
import tour from "web_tour.tour";
const requestChatSteps = [
{
trigger: ".o_livechat_button",
run: "click",
},
{
trigger: ".o_thread_window",
},
];
tour.register("im_livechat_request_chat", { test: true }, requestChatSteps);
tour.register("im_livechat_request_chat_and_send_message", { test: true }, [
...requestChatSteps,
{
trigger: ".o_composer_text_field",
run: "text Hello, I need help please !",
},
{
trigger: '.o_composer_text_field',
run() {
$(".o_composer_text_field").trigger($.Event("keydown", { which: 13 }));
},
},
{
trigger: ".o_thread_message:contains('Hello, I need help')",
},
]);
+2 -2
View File
@@ -60,12 +60,12 @@ 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, chatbot_script_id=None, **kwargs):
def get_session(self, channel_id, anonymous_name, previous_operator_id=None, chatbot_script_id=None, persisted=True, **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, chatbot_script_id=chatbot_script_id, **kwargs)
return super(WebsiteLivechat, self).get_session(channel_id, anonymous_name, previous_operator_id=previous_operator_id, chatbot_script_id=chatbot_script_id, persisted=persisted, **kwargs)
def _livechat_templates_get(self):
return super(WebsiteLivechat, self)._livechat_templates_get() + [
@@ -9,6 +9,14 @@ from odoo.addons.website_livechat.tests.common import TestLivechatCommon
@tests.tagged('post_install', '-at_install')
class TestLivechatBasicFlowHttpCase(tests.HttpCase, TestLivechatCommon):
def test_channel_created_on_user_interaction(self):
self.start_tour('/', 'im_livechat_request_chat', login=None)
channel = self.env['mail.channel'].search([['livechat_active', '=', True], ['livechat_visitor_id', '=', self.visitor.id]])
self.assertFalse(channel, 'Channel should not be created until user sends a message')
self.start_tour('/', 'im_livechat_request_chat_and_send_message', login=None)
channel = self.env['mail.channel'].search([['livechat_active', '=', True], ['livechat_visitor_id', '=', self.visitor.id]])
self.assertTrue(channel, 'Channel should be created after sending the first message')
def test_visitor_banner_history(self):
# create visitor history
self.env['website.track'].create([{