From 131948aca5dc14cc0236c0b9b36bf4b4cf76dd7e Mon Sep 17 00:00:00 2001 From: tsm-odoo Date: Tue, 13 Dec 2022 16:05:43 +0000 Subject: [PATCH] [FIX] im_livechat, *: fix channel created before user interaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit *: 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) Signed-off-by: Sébastien Theys (seb) --- addons/im_livechat/controllers/main.py | 6 ++- addons/im_livechat/i18n/im_livechat.pot | 7 +++ .../im_livechat/models/im_livechat_channel.py | 31 ++++++++----- .../src/legacy/models/public_livechat.js | 6 +++ .../src/legacy/public_livechat_chatbot.js | 7 ++- .../public_livechat_window.js | 34 +++++++++++--- .../public_livechat_window.xml | 15 ++++--- .../static/src/public_models/chatbot.js | 7 +++ .../src/public_models/livechat_button_view.js | 28 ++++++------ .../src/public_models/public_livechat.js | 45 ++++++++++++++++++- .../public_models/public_livechat_global.js | 20 +++++++-- .../im_livechat_channel_creation_tour.js | 32 +++++++++++++ addons/website_livechat/controllers/main.py | 4 +- .../tests/test_livechat_basic_flow.py | 8 ++++ 14 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 addons/im_livechat/static/tests/tours/im_livechat_channel_creation_tour.js diff --git a/addons/im_livechat/controllers/main.py b/addons/im_livechat/controllers/main.py index 1524a4dc8d4..7ee18094ce3 100644 --- a/addons/im_livechat/controllers/main.py +++ b/addons/im_livechat/controllers/main.py @@ -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): diff --git a/addons/im_livechat/i18n/im_livechat.pot b/addons/im_livechat/i18n/im_livechat.pot index 22466060e1a..17d3fcb924d 100644 --- a/addons/im_livechat/i18n/im_livechat.pot +++ b/addons/im_livechat/i18n/im_livechat.pot @@ -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 diff --git a/addons/im_livechat/models/im_livechat_channel.py b/addons/im_livechat/models/im_livechat_channel.py index 5ed749c10f5..9e8fa540626 100644 --- a/addons/im_livechat/models/im_livechat_channel.py +++ b/addons/im_livechat/models/im_livechat_channel.py @@ -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. diff --git a/addons/im_livechat/static/src/legacy/models/public_livechat.js b/addons/im_livechat/static/src/legacy/models/public_livechat.js index fc2349c413c..aaeb6abc95b 100644 --- a/addons/im_livechat/static/src/legacy/models/public_livechat.js +++ b/addons/im_livechat/static/src/legacy/models/public_livechat.js @@ -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, diff --git a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js index 7bb6e80181d..bc0e9df8477 100644 --- a/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js +++ b/addons/im_livechat/static/src/legacy/public_livechat_chatbot.js @@ -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; } diff --git a/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.js b/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.js index b1bae5d5f27..10a27b45aa9 100644 --- a/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.js +++ b/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.js @@ -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(); diff --git a/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.xml b/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.xml index 335101276b6..9beadceb47e 100644 --- a/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.xml +++ b/addons/im_livechat/static/src/legacy/widgets/public_livechat_window/public_livechat_window.xml @@ -16,14 +16,19 @@ -
-
-
- + +
+
+
+ +
+
+
+

No operator available

- + diff --git a/addons/im_livechat/static/src/public_models/chatbot.js b/addons/im_livechat/static/src/public_models/chatbot.js index a20311bc955..55ca07f48e6 100644 --- a/addons/im_livechat/static/src/public_models/chatbot.js +++ b/addons/im_livechat/static/src/public_models/chatbot.js @@ -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 || ( diff --git a/addons/im_livechat/static/src/public_models/livechat_button_view.js b/addons/im_livechat/static/src/public_models/livechat_button_view.js index cfc62190bcf..a7f0551827a 100644 --- a/addons/im_livechat/static/src/public_models/livechat_button_view.js +++ b/addons/im_livechat/static/src/public_models/livechat_button_view.js @@ -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(() => { diff --git a/addons/im_livechat/static/src/public_models/public_livechat.js b/addons/im_livechat/static/src/public_models/public_livechat.js index bdb4578be97..55aa3aa0080 100644 --- a/addons/im_livechat/static/src/public_models/public_livechat.js +++ b/addons/im_livechat/static/src/public_models/public_livechat.js @@ -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; diff --git a/addons/im_livechat/static/src/public_models/public_livechat_global.js b/addons/im_livechat/static/src/public_models/public_livechat_global.js index f1161b27b81..21f3a86c090 100644 --- a/addons/im_livechat/static/src/public_models/public_livechat_global.js +++ b/addons/im_livechat/static/src/public_models/public_livechat_global.js @@ -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: {}, diff --git a/addons/im_livechat/static/tests/tours/im_livechat_channel_creation_tour.js b/addons/im_livechat/static/tests/tours/im_livechat_channel_creation_tour.js new file mode 100644 index 00000000000..0537a739eda --- /dev/null +++ b/addons/im_livechat/static/tests/tours/im_livechat_channel_creation_tour.js @@ -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')", + }, +]); diff --git a/addons/website_livechat/controllers/main.py b/addons/website_livechat/controllers/main.py index d554e5e2a42..8678526e6b9 100644 --- a/addons/website_livechat/controllers/main.py +++ b/addons/website_livechat/controllers/main.py @@ -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() + [ diff --git a/addons/website_livechat/tests/test_livechat_basic_flow.py b/addons/website_livechat/tests/test_livechat_basic_flow.py index 5b6182c56c7..35da46e2e36 100644 --- a/addons/website_livechat/tests/test_livechat_basic_flow.py +++ b/addons/website_livechat/tests/test_livechat_basic_flow.py @@ -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([{