diff --git a/addons/im_support/__init__.py b/addons/im_support/__init__.py new file mode 100644 index 00000000000..a0fdc10fe11 --- /dev/null +++ b/addons/im_support/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/addons/im_support/__manifest__.py b/addons/im_support/__manifest__.py new file mode 100644 index 00000000000..f4cf8726d48 --- /dev/null +++ b/addons/im_support/__manifest__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Livechat Support', + 'version': '1.0', + 'category': 'Hidden', + 'description': """ + This module allows employee users to communicate with livechat operators + of another database on which a counterpart addon is installed. + """, + 'author': 'Odoo SA', + 'website': 'https://www.odoo.com', + 'depends': [ + 'mail', + ], + 'data': [ + 'views/assets.xml', + ], + 'qweb': [ + "static/src/xml/discuss.xml", + "static/src/xml/systray.xml", + ], + 'installable': True, + 'auto_install': False, +} diff --git a/addons/im_support/models/__init__.py b/addons/im_support/models/__init__.py new file mode 100644 index 00000000000..75127bbcbc3 --- /dev/null +++ b/addons/im_support/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import ir_http diff --git a/addons/im_support/models/ir_http.py b/addons/im_support/models/ir_http.py new file mode 100644 index 00000000000..39ff7081862 --- /dev/null +++ b/addons/im_support/models/ir_http.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import hmac +from hashlib import sha256 + +from odoo import models +from odoo.http import request + + +class Http(models.AbstractModel): + _inherit = 'ir.http' + + def session_info(self): + result = super(Http, self).session_info() + + if self.env.user.has_group('base.group_user'): + icp = request.env['ir.config_parameter'].sudo() + db_uuid = icp.get_param('database.uuid') + db_secret = icp.get_param('database.secret') + message = db_uuid + str(request.uid) + token = hmac.new(message.encode('utf-8'), db_secret.encode('utf-8'), sha256).hexdigest() + + result['db_uuid'] = db_uuid + result['support_token'] = token + result['support_origin'] = False # must be overridden to specify the correct origin + + return result diff --git a/addons/im_support/static/src/js/chat_manager.js b/addons/im_support/static/src/js/chat_manager.js new file mode 100644 index 00000000000..4fabcd5deb9 --- /dev/null +++ b/addons/im_support/static/src/js/chat_manager.js @@ -0,0 +1,455 @@ +odoo.define('im_support.ChatManager', function (require) { +"use strict"; + +/** + * This module includes the ChatManager to handle the case of the Support + * channel, allowing the users of the current database to communicate with + * livechat operators from another database (the Support database). + */ + +var ChatManager = require('mail.ChatManager'); + +var core = require('web.core'); +var session = require('web.session'); +var WebClient = require('web.WebClient'); + +var supportSession = require('im_support.SupportSession'); +var supportBus = require('im_support.SupportBus'); + +var _t = core._t; +var CHANNEL_STATES = { + CLOSED: 'closed', + FOLDED: 'folded', + OPEN: 'open', +}; +var ODOOBOT_ID = "ODOOBOT"; // FIXME: should be exported by the ChatManager +var POLL_TIMEOUT_DELAY = 1000 * 60 * 30; // 30 minutes +var POLL_TIMEOUT_KEY = 'im_support.poll_timeout'; +var SUPPORT_CHANNEL_LIMIT = 30; // limit of messages to fetch +var SUPPORT_CHANNEL_STATE_KEY = 'im_support.channel_state'; + +ChatManager.include({ + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + closeChatSession: function (channelID) { + if (channelID === this.supportChannelUUID) { + this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.CLOSED); + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + detachChannel: function (channelID) { + if (channelID === this.supportChannelUUID) { + this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, CHANNEL_STATES.OPEN); + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + foldChannel: function (channelID, folded) { + if (channelID === this.supportChannelUUID) { + var value = folded ? CHANNEL_STATES.FOLDED : CHANNEL_STATES.OPEN; + this.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, value); + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to filter out the Support channel from the previews. + * + * @override + */ + getChannelsPreview: function () { + var self = this; + return this._super.apply(this, arguments).then(function (channelsPreview) { + return _.reject(channelsPreview, {id: self.supportChannelUUID}); + }); + }, + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + getMentionPartnerSuggestions: function (channel) { + if (channel && channel.id === this.supportChannelUUID) { + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Initialises the Support: checks if there is a pending chat session + * between the user and Support, and if so, re-opens it. + * Note: we can't directly override init(), because it is already called + * when the include is applied, so we use this hook instead (called at + * webclient startup) + */ + initSupport: function () { + var self = this; + this.supportChannelDef = null; + this.supportChannelUUID = null; + this.pollTimeout = null; + this.pollingSupport = false; + + // listen to notifications coming from Support longpolling + supportBus.on('notification', this, this._onSupportNotification); + + // check if there is a pending chat session with the Support + var timeoutTimestamp = this.call('local_storage', 'getItem', POLL_TIMEOUT_KEY); + var pollingDelay = timeoutTimestamp && (JSON.parse(timeoutTimestamp) - Date.now()); + if (pollingDelay && pollingDelay > 0) { + var channelState = this.call('local_storage', 'getItem', SUPPORT_CHANNEL_STATE_KEY); + this.startSupportLivechat(channelState).then(function () { + var supportChannel = self.getChannel(self.supportChannelUUID); + if (supportChannel.available) { + self._startPollingSupport(pollingDelay); + } + }); + } + }, + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + markChannelAsSeen: function (channel) { + if (channel.id === this.supportChannelUUID) { + this._updateChannelUnreadCounter(channel, 0); + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to call a specific route for messages posted in the Support + * channel. + * + * @override + */ + postMessage: function (data, options) { + if (options.channelID && options.channelID === this.supportChannelUUID) { + // ensure that the poll is active before posting the message + if (!this.pollingSupport) { + this._startPollingSupport(); + } + return supportSession.rpc("/odoo_im_support/chat_post", { + uuid: this.supportChannelUUID, + message_content: data.content, + }); + } + return this._super.apply(this, arguments); + }, + /** + * Opens the support channel between a livechat operator from the Support + * database and the current user (if there is an available operator). + * Ensures to perform only once the request to create/retrieve the Support + * channel. + * + * @param {string} [channelState=CHANNEL_STATES.OPEN] state of the Support + * channel (see CHANNEL_STATES for accepted values) + * @returns {Deferred} + */ + startSupportLivechat: function (channelState) { + var self = this; + if (!this.supportChannelDef) { + // retrieve or create the channel + this.supportChannelDef = supportSession.rpc('/odoo_im_support/get_support_channel', { + channel_uuid: session.support_token, + db_uuid: session.db_uuid, + user_name: session.name, + }); + } + return this.supportChannelDef.then(function (channel) { + if (!channel) { + // there is no channel (because there is no online operator, and + // no support channel has been created yet), so create one to + // open in a chat window + channel = { + available: false, + support_channel: true, + type: 'livechat', + uuid: "support_unavailable", + }; + } + if (!channelState) { + channelState = self.discussOpen ? CHANNEL_STATES.CLOSED : CHANNEL_STATES.OPEN; + self.call('local_storage', 'setItem', SUPPORT_CHANNEL_STATE_KEY, channelState); + } + if (!self.supportChannelUUID) { + // this part is only executed the first time the RPC is resolved + self.supportChannelUUID = channel.uuid; + + // add the channel to the ChatManager + self._addChannel(_.extend(channel, { + id: channel.uuid, + is_minimized: _.contains([CHANNEL_STATES.OPEN, CHANNEL_STATES.FOLDED], channelState), + state: channelState, + })); + + // display automatic messages in the channel + if (!channel.available) { + self._addSupportNotAvailableMessage(); + } else { + self._addSupportWelcomeMessage(); + } + } else { + // the channel has already been added to the ChatManager, so + // simply re-open it + channel = self.getChannel(self.supportChannelUUID); + if (self.discussOpen) { + self.openChannel(channel); + } else { + channel.is_folded = channelState === CHANNEL_STATES.FOLDED; + self.call('chat_window_manager', 'openChat', channel); + } + } + }).fail(function () { + self.do_warn(_t("The Support server can't be reached.")); + }); + }, + /** + * Overrides to prevent from calling the server for messages of the Support + * channel (which are records on the Support database). + * + * @override + */ + toggleStarStatus: function (msgID) { + var message = this.getMessage(msgID); + if (_.contains(message.channel_ids, this.supportChannelUUID)) { + return $.when(); + } + return this._super.apply(this, arguments); + }, + /** + * Overrides to prevent from calling the server with the Support channel + * (which is a record on the Support database). + * + * @override + */ + unsubscribe: function (channel) { + if (channel.id === this.supportChannelUUID) { + return $.when(); + } + return this._super.apply(this, arguments); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Adds a message in the Support channel indicating that this channel is + * not available for now. + * + * @private + */ + _addSupportNotAvailableMessage: function () { + var msg = { + author_id: ODOOBOT_ID, + body: _t("None of our operators are available. Submit a ticket to ask your question now."), + channel_ids: [this.supportChannelUUID], + id: Number.MAX_SAFE_INTEGER, // last message in the channel + }; + this._addMessage(msg, {silent: true}); + }, + /** + * Adds a welcome message as first message of the Support channel. + * + * @private + */ + _addSupportWelcomeMessage: function () { + var channel = this.getChannel(this.supportChannelUUID); + if (channel.welcome_message) { + var msg = { + author_id: channel.operator, + body: channel.welcome_message, + channel_ids: [this.supportChannelUUID], + id: -1, // first message of the channel + }; + this._addMessage(msg, {silent: true}); + } + }, + /** + * Overrides to handle the Support channel case: fetches the messages from + * the Support database in that case. + * + * @override + * @private + */ + _fetchFromChannel: function (channel, options) { + var self = this; + if (channel.id === this.supportChannelUUID) { + var domain = options && options.domain || []; + var cache = this._getChannelCache(channel, domain); + if (options && options.loadMore) { + var minMessageID = cache.messages[0].id; + domain = [['id', '<', minMessageID]].concat(domain); + } + + return supportSession.rpc('/odoo_im_support/fetch_messages', { + domain: domain, + channel_uuid: session.support_token, + limit: SUPPORT_CHANNEL_LIMIT, + }).then(function (msgs) { + if (!cache.all_history_loaded) { + cache.all_history_loaded = msgs.length < SUPPORT_CHANNEL_LIMIT; + } + cache.loaded = true; + + _.each(msgs, function (msg) { + _.extend(msg, {channel_ids: [self.supportChannelUUID]}); + self._addMessage(msg, { + channel_id: self.supportChannelUUID, + silent: true, + domain: domain, + }); + }); + var channelCache = self._getChannelCache(channel, domain); + return channelCache.messages; + }); + } + return this._super.apply(this, arguments); + }, + /** + * @override + * @private + */ + _makeChannel: function (data) { + var channel = this._super.apply(this, arguments); + if (channel.id === this.supportChannelUUID) { + channel.supportChannel = true; + channel.name = _t('Support'); + channel.available = data.available; + if (!channel.available) { + channel.name += _t(' (offline)'); + } + channel.welcome_message = data.welcome_message; + channel.operator = data.operator; + } + return channel; + }, + /** + * @override + * @private + */ + _makeMessage: function (data) { + if (_.contains(data.channel_ids, this.supportChannelUUID) && data.author_id !== ODOOBOT_ID) { + if (!data.author_id[0]) { // the author is the client + data.author_id = [session.partner_id, session.name]; + } else { // the author is the operator + data.author_id[0] = -1; // prevent from conflicting with partners of this instance + var msg = this._super.apply(this, arguments); + msg.author_redirect = false; + msg.avatar_src = "/mail/static/src/img/odoo_o.png"; + return msg; + } + } + return this._super.apply(this, arguments); + }, + /** + * Automatically stop polling for Support messages after a given delay of + * inactivity. + * + * @private + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + _setPollTimeout: function (pollingDelay) { + pollingDelay = pollingDelay || POLL_TIMEOUT_DELAY; + clearTimeout(this.pollTimeout); + this.pollTimeout = setTimeout(this._stopPollingSupport.bind(this), pollingDelay); + // save the timeout expiration datetime into the LocalStorage so that + // we can re-open the Support channel if necessary on F5 + var timeoutTimestamp = Date.now() + pollingDelay; + this.call('local_storage', 'setItem', POLL_TIMEOUT_KEY, timeoutTimestamp); + }, + /** + * Initiates a longpoll with the server hosting the Support channel. + * + * @private + * @param {integer} [pollingDelay=POLL_TIMEOUT_DELAY] the longpolling + * timeout delay to set + */ + _startPollingSupport: function (pollingDelay) { + this.pollingSupport = true; + supportBus.add_channel(this.supportChannelUUID); + supportBus.start_polling(); + this._setPollTimeout(pollingDelay); + }, + /** + * Stops the longpoll with the server hosting the Support channel. + * + * @private + */ + _stopPollingSupport: function () { + this.pollingSupport = false; + supportBus.stop_polling(); + this.call('local_storage', 'removeItem', POLL_TIMEOUT_KEY); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Handles poll notifications from the Support server. + * + * @private + * @param {Object[]} notifications + */ + _onSupportNotification: function (notifications) { + var self = this; + if (notifications && notifications.length) { + this._setPollTimeout(); + } + _.each(notifications, function (notification) { + if (notification[1]._type === 'history_command') { + // ignore history requests + return; + } + var msg = _.extend(notification[1], { + channel_ids: [self.supportChannelUUID], + }); + self._manageChannelNotification(msg); + }); + }, +}); + + +// Unfortunately, we can't override init() of ChatManager because it is called +// before the include is applied, so we override the WebClient instead to call +// an initialization hook for Livechat Support in the ChatManager service. +WebClient.include({ + /** + * Overrides to ask the ChatManager service to check whether there is a + * pending chat session with Support, and if so, to re-open it. + * + * @override + */ + show_application: function () { + this.call('chat_manager', 'initSupport'); + return this._super.apply(this, arguments); + }, +}); + +}); diff --git a/addons/im_support/static/src/js/discuss.js b/addons/im_support/static/src/js/discuss.js new file mode 100644 index 00000000000..df2526a2a26 --- /dev/null +++ b/addons/im_support/static/src/js/discuss.js @@ -0,0 +1,65 @@ +odoo.define('im_support.chat_discuss', function (require) { +"use strict"; + +/** + * This module includes Discuss to handle the case of the Support channel. + */ + +var Discuss = require('mail.chat_discuss'); + +Discuss.include({ + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Overrides to hide stars in the Support channel, as this feature does not + * work in that channel. + * + * @override + * @private + */ + _getThreadRenderingOptions: function () { + var options = this._super.apply(this, arguments); + if (this.channel.supportChannel) { + options.display_stars = false; + } + return options; + }, + /** + * Overrides to hide the attachment button in the composer of the Support + * channel, as this feature does not work in that channel. Also hides the + * composer when no operator is available. + * + * @override + * @private + */ + _setChannel: function (channel) { + var self = this; + return this._super.apply(this, arguments).then(function () { + var $buttonAddAttachment = self.basicComposer.$('.o_composer_button_add_attachment'); + if (channel.supportChannel) { + if (!channel.available) { + self.basicComposer.do_hide(); + } + $buttonAddAttachment.hide(); + } else { + $buttonAddAttachment.show(); + } + }); + }, + /** + * Overrides to handle the Support channel case (hide all buttons). + * + * @override + * @private + */ + _updateControlPanelButtons: function (channel) { + this._super.apply(this, arguments); + if (channel.supportChannel) { + this.$buttons.find('button').hide(); + } + }, +}); + +}); diff --git a/addons/im_support/static/src/js/extended_chat_window.js b/addons/im_support/static/src/js/extended_chat_window.js new file mode 100644 index 00000000000..6f598d16cba --- /dev/null +++ b/addons/im_support/static/src/js/extended_chat_window.js @@ -0,0 +1,47 @@ +odoo.define('im_support.ExtendedChatWindow', function (require) { +"use strict"; + +/** + * This module includes ExtendedChatWindow to handle the case of the Support + * channel. + */ + + +var ExtendedChatWindow = require('mail.ExtendedChatWindow'); + +ExtendedChatWindow.include({ + /** + * Overrides to tweak the options in the case of the Support channel (no + * star, and no input if no operator available). + * + * @override + */ + init: function () { + this._super.apply(this, arguments); + var channel = this.call('chat_manager', 'getChannel', this.channel_id); + if (channel && channel.supportChannel) { + this.options.display_stars = false; + if (!channel.available) { + this.options.input_less = true; + } + } + }, + /** + * Overrides to remove the attachment button in the Support channel as this + * feature does not work in that channel. + * + * @note: this feature could be enabled by storing the attachments on the + * Support server. + * @override + */ + start: function () { + var def = this._super.apply(this, arguments); + var channel = this.call('chat_manager', 'getChannel', this.channel_id); + if (channel && channel.supportChannel) { + this.$('.o_composer .o_composer_button_add_attachment').remove(); + } + return def; + }, +}); + +}); diff --git a/addons/im_support/static/src/js/support_bus.js b/addons/im_support/static/src/js/support_bus.js new file mode 100644 index 00000000000..70c4e1dd3c1 --- /dev/null +++ b/addons/im_support/static/src/js/support_bus.js @@ -0,0 +1,28 @@ +odoo.define('im_support.SupportBus', function (require) { +"use strict"; + +/** + * This module instantiates and exports the instance of the Bus, parameterized + * to poll the Support server. + */ + +var supportSession = require('im_support.SupportSession'); + +var bus = require('bus.bus'); + +var Bus; +if(typeof Storage !== "undefined"){ + Bus = bus.CrossTabBus; +} else { + Bus = bus.Bus; +} + +var params = { + session: supportSession, + pollRoute: '/longpolling/support_poll', +}; + +return new Bus(params); + +}); + diff --git a/addons/im_support/static/src/js/support_session.js b/addons/im_support/static/src/js/support_session.js new file mode 100644 index 00000000000..d1228674737 --- /dev/null +++ b/addons/im_support/static/src/js/support_session.js @@ -0,0 +1,18 @@ +odoo.define('im_support.SupportSession', function (require) { +"use strict"; + +/** + * This module returns an instance of Session which is linked to the Support + * server, allowing the current instance to communicate with the Support + * server (CORS). + */ + +var session = require('web.session'); +var Session = require('web.Session'); + +return new Session(null, session.support_origin, { + modules: odoo._modules, + use_cors: true, +}); + +}); diff --git a/addons/im_support/static/src/js/systray.js b/addons/im_support/static/src/js/systray.js new file mode 100644 index 00000000000..99c6f7f077b --- /dev/null +++ b/addons/im_support/static/src/js/systray.js @@ -0,0 +1,72 @@ +odoo.define('im_support.systray', function (require) { +"use strict"; + +/** + * This module adds a fake Support channel in the messaging menu of the systray. + */ + +var mailSystray = require('mail.systray'); + +var config = require('web.config'); +var core = require('web.core'); +var session = require('web.session'); + +var _t = core._t; +var MessagingMenu = mailSystray.MessagingMenu; +var SUPPORT_CHANNEL_ID = 'SupportChannel'; + +// Disable Support in mobile for design purposes (for now at least), and don't +// add it to the messaging dropdown if it isn't available +if (config.device.isMobile || !session.support_token || !session.support_origin) { + return; +} + +MessagingMenu.include({ + /** + * @override + */ + init: function () { + this._super.apply(this, arguments); + this.supportChannel = { + id: SUPPORT_CHANNEL_ID, + name: _t('Support'), + image_src: '/mail/static/src/img/odoo_o.png', + }; + }, + /** + * Overrides to add a className to the bottom part of the dropdown + * (containing the Support channel), so that the css rules apply. This + * className can't be added directly in the template, otherwise + * this.$channels_preview would be a nodeset containing the bottom part as + * well, and it will cause rendering issues when the dropdown is rerendered. + * + * @override + */ + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self.$('.o_mail_navbar_dropdown_bottom').addClass('o_mail_navbar_dropdown_channels'); + }); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Overrides to handle the click on the fake Support channel. + * + * @override + * @private + */ + _onClickChannel: function (ev) { + var channelID = $(ev.currentTarget).data('channel_id'); + if (channelID === SUPPORT_CHANNEL_ID) { + this.call('chat_manager', 'startSupportLivechat'); + } else { + this._super.apply(this, arguments); + } + }, +}); + +}); diff --git a/addons/im_support/static/src/less/systray.less b/addons/im_support/static/src/less/systray.less new file mode 100644 index 00000000000..1c93846eaf8 --- /dev/null +++ b/addons/im_support/static/src/less/systray.less @@ -0,0 +1,13 @@ +.o_mail_navbar_item { + .o_mail_navbar_dropdown { + .o_mail_navbar_dropdown_bottom { + border-top: 1px solid @gray-lighter-darker; + box-sizing: content-box; + + .o_channel_info { + .o-flex-display(); + .o-align-items(center); + } + } + } +} diff --git a/addons/im_support/static/src/xml/discuss.xml b/addons/im_support/static/src/xml/discuss.xml new file mode 100644 index 00000000000..4797c688d1a --- /dev/null +++ b/addons/im_support/static/src/xml/discuss.xml @@ -0,0 +1,19 @@ + + diff --git a/addons/im_support/static/src/xml/systray.xml b/addons/im_support/static/src/xml/systray.xml new file mode 100644 index 00000000000..83199a4768c --- /dev/null +++ b/addons/im_support/static/src/xml/systray.xml @@ -0,0 +1,13 @@ + + + + + +
  • + + +
  • +
    +
    + +
    diff --git a/addons/im_support/views/assets.xml b/addons/im_support/views/assets.xml new file mode 100644 index 00000000000..7322c6e7611 --- /dev/null +++ b/addons/im_support/views/assets.xml @@ -0,0 +1,16 @@ + + + + +