[ADD] im_support

This rev. adds a new module enabling the livechat support from
the webclient. It allows employee users to communicate with
livechat operators of another database, assuming that a counterpart
addon is installed on that database.

Task 26762

Co-authored-by: Adrien Dieudonne <adr@odoo.com>
This commit is contained in:
Aaron Bohy
2018-05-28 07:30:12 +02:00
co-authored by Adrien Dieudonne
parent 10161f9d61
commit 2aeb763768
14 changed files with 803 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models
+24
View File
@@ -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,
}
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import ir_http
+27
View File
@@ -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
@@ -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. <a href='https://www.odoo.com/help' " +
"target='_blank'>Submit a ticket</a> 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);
},
});
});
@@ -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();
}
},
});
});
@@ -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;
},
});
});
@@ -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);
});
@@ -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,
});
});
@@ -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);
}
},
});
});
@@ -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);
}
}
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<template>
<t t-extend="mail.chat.Sidebar">
<t t-jquery="hr" t-operation="before">
<t t-foreach="channels" t-as="channel">
<t t-if="channel.type == 'livechat'">
<div t-attf-class="o_mail_chat_title_main o_mail_chat_channel_item #{(active_channel_id == channel.id) ? 'o_active': ''}"
t-att-data-channel-id="channel.id">
<span class="o_channel_name"><i class="fa fa-question-circle mr8"/>Odoo Support</span>
<t t-set="counter" t-value="0"/>
<t t-call="mail.chat.SidebarNeedaction"/>
</div>
</t>
</t>
</t>
</t>
</template>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-extend="mail.chat.MessagingMenu">
<t t-jquery=".o_mail_navbar_dropdown" t-operation="append">
<li t-if="widget.supportChannel" class="o_mail_navbar_dropdown_bottom">
<t t-set="channel" t-value="widget.supportChannel"/>
<t t-call="mail.chat.ChannelPreview"/>
</li>
</t>
</t>
</templates>
+16
View File
@@ -0,0 +1,16 @@
<openerp>
<data>
<template id="assets_backend" name="im_support_backend assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<link rel="stylesheet" href="/im_support/static/src/less/systray.less" type="text/less"/>
<script type="text/javascript" src="/im_support/static/src/js/chat_manager.js"></script>
<script type="text/javascript" src="/im_support/static/src/js/discuss.js"></script>
<script type="text/javascript" src="/im_support/static/src/js/extended_chat_window.js"></script>
<script type="text/javascript" src="/im_support/static/src/js/support_bus.js"></script>
<script type="text/javascript" src="/im_support/static/src/js/support_session.js"></script>
<script type="text/javascript" src="/im_support/static/src/js/systray.js"></script>
</xpath>
</template>
</data>
</openerp>