diff --git a/plugins/kolab_chat/drivers/mattermost.php b/plugins/kolab_chat/drivers/mattermost.php index abf1c530..f9bb8b29 100644 --- a/plugins/kolab_chat/drivers/mattermost.php +++ b/plugins/kolab_chat/drivers/mattermost.php @@ -1,264 +1,271 @@ * * Copyright (C) 2014-2018, Kolab Systems AG * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ class kolab_chat_mattermost { private $rc; private $plugin; private $token_valid = false; /** * Object constructor * * @param rcube_plugin $plugin Kolab_chat plugin object */ public function __construct($plugin) { $this->rc = rcube::get_instance(); $this->plugin = $plugin; } /** * Returns location of the chat app * * @param bool $websocket Return websocket URL * * @return string The chat app location */ public function url($websocket = false) { $url = rtrim($this->rc->config->get('kolab_chat_url'), '/'); if ($websocket) { $url = str_replace(array('http://', 'https://'), array('ws://', 'wss://'), $url); $url .= '/api/v4/websocket'; } else if ($this->rc->action == 'index' && $this->rc->task == 'kolab-chat') { - if (($channel = rcube_utils::get_input_value('_channel', rcube_utils::INPUT_GET)) - && ($channel = $this->get_channel($channel, true)) - ) { - $url .= '/' . urlencode($channel['team_name']) . '/channels/' . urlencode($channel['id']); + if ($channel = rcube_utils::get_input_value('_channel', rcube_utils::INPUT_GET)) { + $team = rcube_utils::get_input_value('_team', rcube_utils::INPUT_GET); + + if (empty($team) && ($_channel = $this->get_channel($channel, true))) { + $team = $_channel['team_name']; + } + + if ($channel && $team) { + $url .= '/' . urlencode($team) . '/channels/' . urlencode($channel); + } } } return $url; } /** * Add/register UI elements */ public function ui() { if ($this->rc->task != 'kolab-chat') { $this->plugin->include_script("js/mattermost.js"); - $this->plugin->add_label('openchat', 'directmessage'); + $this->plugin->add_label('openchat', 'directmessage', 'mentionmessage'); } else if ($this->get_token()) { rcube_utils::setcookie('MMUSERID', $_SESSION['mattermost'][0], 0, false); rcube_utils::setcookie('MMAUTHTOKEN', $_SESSION['mattermost'][1], 0, false); } } /** * Driver specific actions handler */ public function action() { $result = array( - 'url' => $this->url(true), - 'token' => $this->get_token(), + 'url' => $this->url(true), + 'token' => $this->get_token(), + 'user_id' => $this->get_user_id(), ); echo rcube_output::json_serialize($result); exit; } /** * Returns the Mattermost session token * Note: This works only if the user/pass is the same in Kolab and Mattermost * * @return string Session token */ protected function get_token() { $user = $_SESSION['username']; $pass = $this->rc->decrypt($_SESSION['password']); // Use existing token if still valid if (!empty($_SESSION['mattermost'])) { $user_id = $_SESSION['mattermost'][0]; $token = $_SESSION['mattermost'][1]; if ($this->token_valid) { return $token; } try { $request = $this->get_api_request('GET', '/api/v4/users/me'); $request->setHeader('Authorization', "Bearer $token"); $response = $request->send(); $status = $response->getStatus(); if ($status != 200) { $token = null; } } catch (Exception $e) { rcube::raise_error($e, true, false); $token = null; } } // Request a new session token if (empty($token)) { $request = $this->get_api_request('POST', '/api/v4/users/login'); $request->setBody(json_encode(array( 'login_id' => $user, 'password' => $pass, ))); // send request to the API, get token and user ID try { $response = $request->send(); $status = $response->getStatus(); $token = $response->getHeader('Token'); $body = json_decode($response->getBody(), true); if ($status == 200) { $user_id = $body['id']; } else if (is_array($body) && $body['message']) { throw new Exception($body['message']); } else { throw new Exception("Failed to authenticate the chat user ($user). Status: $status"); } } catch (Exception $e) { rcube::raise_error($e, true, false); } } if ($user_id && $token) { $this->token_valid = true; $_SESSION['mattermost'] = array($user_id, $token); return $token; } } /** * Returns the Mattermost user ID * Note: This works only if the user/pass is the same in Kolab and Mattermost * * @return string User ID */ protected function get_user_id() { if ($token = $this->get_token()) { return $_SESSION['mattermost'][0]; } } /** * Returns the Mattermost channel info * * @param string $channel_id Channel ID * @param bool $extended Return extended information (i.e. team information) * * @return array Channel information */ protected function get_channel($channel_id, $extended = false) { $channel = $this->api_get('/api/v4/channels/' . urlencode($channel_id)); if ($extended && is_array($channel)) { if (!empty($channel['team_id'])) { $team = $this->api_get('/api/v4/teams/' . urlencode($channel['team_id'])); } else if ($teams = $this->get_user_teams()) { // if there's no team assigned to the channel, we'll get the user's team // so we can build proper channel URL, there's no other way in the API $team = $teams[0]; } if (!empty($team)) { $channel['team_id'] = $team['id']; $channel['team_name'] = $team['name']; } } return $channel; } /** * Returns the Mattermost teams of the user * * @return array List of teams */ protected function get_user_teams() { return $this->api_get('/api/v4/users/' . urlencode($this->get_user_id()) . '/teams'); } /** * Return HTTP/Request2 instance for Mattermost API connection */ protected function get_api_request($type, $path) { $url = rtrim($this->rc->config->get('kolab_chat_url'), '/'); $defaults = array( 'store_body' => true, 'follow_redirects' => true, ); $config = array_merge($defaults, (array) $this->rc->config->get('kolab_chat_http_request')); return libkolab::http_request($url . $path, $type, $config); } /** * Call API GET command */ protected function api_get($path) { if ($token = $this->get_token()) { try { $request = $this->get_api_request('GET', $path); $request->setHeader('Authorization', "Bearer $token"); $response = $request->send(); $status = $response->getStatus(); $body = $response->getBody(); if ($status == 200) { return json_decode($body, true); } } catch (Exception $e) { rcube::raise_error($e, true, false); } } } } diff --git a/plugins/kolab_chat/js/mattermost.js b/plugins/kolab_chat/js/mattermost.js index 23ff6b99..7bcc2fb5 100644 --- a/plugins/kolab_chat/js/mattermost.js +++ b/plugins/kolab_chat/js/mattermost.js @@ -1,315 +1,335 @@ /** * Mattermost driver * Websocket code based on https://github.com/mattermost/mattermost-redux/master/client/websocket_client.js * * @author Aleksander Machniak * * @licstart The following is the entire license notice for the * JavaScript code in this file. * * Copyright (C) 2015-2018, Kolab Systems AG * Copyright (C) 2015-2018, Mattermost, Inc. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . * * @licend The above is the entire license notice * for the JavaScript code in this file. */ var MAX_WEBSOCKET_FAILS = 7; var MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec var MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins function MMWebSocketClient() { var Socket; this.conn = null; this.connectionUrl = null; this.token = null; this.sequence = 1; this.connectFailCount = 0; this.eventCallback = null; this.firstConnectCallback = null; this.reconnectCallback = null; this.errorCallback = null; this.closeCallback = null; this.connectingCallback = null; this.dispatch = null; this.getState = null; this.stop = false; this.platform = ''; this.initialize = function(token, dispatch, getState, opts) { var forceConnection = opts.forceConnection || true, webSocketConnector = opts.webSocketConnector || WebSocket, connectionUrl = opts.connectionUrl, platform = opts.platform, self = this; if (platform) { this.platform = platform; } if (forceConnection) { this.stop = false; } return new Promise(function(resolve, reject) { if (self.conn) { resolve(); return; } if (connectionUrl == null) { console.log('websocket must have connection url'); reject('websocket must have connection url'); return; } if (!dispatch) { console.log('websocket must have a dispatch'); reject('websocket must have a dispatch'); return; } if (self.connectFailCount === 0) { console.log('websocket connecting to ' + connectionUrl); } Socket = webSocketConnector; if (self.connectingCallback) { self.connectingCallback(dispatch, getState); } var regex = /^(?:https?|wss?):(?:\/\/)?[^/]*/; var captured = (regex).exec(connectionUrl); var origin; if (captured) { origin = captured[0]; if (platform === 'android') { // this is done cause for android having the port 80 or 443 will fail the connection // the websocket will append them var split = origin.split(':'); var port = split[2]; if (port === '80' || port === '443') { origin = split[0] + ':' + split[1]; } } } else { // If we're unable to set the origin header, the websocket won't connect, but the URL is likely malformed anyway console.warn('websocket failed to parse origin from ' + connectionUrl); } self.conn = new Socket(connectionUrl, [], {headers: {origin}}); self.connectionUrl = connectionUrl; self.token = token; self.dispatch = dispatch; self.getState = getState; self.conn.onopen = function() { if (token && platform !== 'android') { // we check for the platform as a workaround until we fix on the server that further authentications // are ignored self.sendMessage('authentication_challenge', {token}); } if (self.connectFailCount > 0) { console.log('websocket re-established connection'); if (self.reconnectCallback) { self.reconnectCallback(self.dispatch, self.getState); } } else if (self.firstConnectCallback) { self.firstConnectCallback(self.dispatch, self.getState); } self.connectFailCount = 0; resolve(); }; self.conn.onclose = function() { self.conn = null; self.sequence = 1; if (self.connectFailCount === 0) { console.log('websocket closed'); } self.connectFailCount++; if (self.closeCallback) { self.closeCallback(self.connectFailCount, self.dispatch, self.getState); } var retryTime = MIN_WEBSOCKET_RETRY_TIME; // If we've failed a bunch of connections then start backing off if (self.connectFailCount > MAX_WEBSOCKET_FAILS) { retryTime = MIN_WEBSOCKET_RETRY_TIME * self.connectFailCount; if (retryTime > MAX_WEBSOCKET_RETRY_TIME) { retryTime = MAX_WEBSOCKET_RETRY_TIME; } } setTimeout(function() { if (self.stop) { return; } self.initialize(token, dispatch, getState, Object.assign({}, opts, {forceConnection: true})); }, retryTime ); }; self.conn.onerror = function(evt) { if (self.connectFailCount <= 1) { console.log('websocket error'); console.log(evt); } if (self.errorCallback) { self.errorCallback(evt, self.dispatch, self.getState); } }; self.conn.onmessage = function(evt) { var msg = JSON.parse(evt.data); if (msg.seq_reply) { if (msg.error) { console.warn(msg); } } else if (self.eventCallback) { self.eventCallback(msg, self.dispatch, self.getState); } }; }); } this.setConnectingCallback = function(callback) { this.connectingCallback = callback; } this.setEventCallback = function(callback) { this.eventCallback = callback; } this.setFirstConnectCallback = function(callback) { this.firstConnectCallback = callback; } this.setReconnectCallback = function(callback) { this.reconnectCallback = callback; } this.setErrorCallback = function(callback) { this.errorCallback = callback; } this.setCloseCallback = function(callback) { this.closeCallback = callback; } this.close = function(stop) { this.stop = stop; this.connectFailCount = 0; this.sequence = 1; if (this.conn && this.conn.readyState === Socket.OPEN) { this.conn.onclose = function(){}; this.conn.close(); this.conn = null; console.log('websocket closed'); } } this.sendMessage = function(action, data) { var msg = { action, seq: this.sequence++, data }; if (this.conn && this.conn.readyState === Socket.OPEN) { this.conn.send(JSON.stringify(msg)); } else if (!this.conn || this.conn.readyState === Socket.CLOSED) { this.conn = null; this.initialize(this.token, this.dispatch, this.getState, {forceConnection: true, platform: this.platform}); } } } /** * Initializes and starts websocket connection with Mattermost */ function mattermost_websocket_init(url, token) { var api = new MMWebSocketClient(); api.setEventCallback(function(e) { mattermost_event_handler(e); }); api.initialize(token, {}, {}, {connectionUrl: url}); } /** * Handles websocket events */ function mattermost_event_handler(event) { - // We're interested only in direct messages for now + var msg, type; + + // Direct message notification if (event.event == 'posted' && event.data.channel_type == 'D') { - var user = event.data.sender_name, + msg = rcmail.gettext('kolab_chat.directmessage'); + type = 'user'; + } + // Mention notification + else if (event.event == 'posted' && String(event.data.mentions).indexOf(rcmail.env.mattermost_user) > 0) { + msg = rcmail.gettext('kolab_chat.mentionmessage'); + type = 'channel'; + } + + if (msg) { + var link = $('').text(rcmail.gettext('kolab_chat.openchat')), + user = event.data.sender_name, + channel = event.data.channel_display_name, channel_id = event.broadcast.channel_id, - // channel_name = event.data.channel_display_name, - msg = rcmail.gettext('kolab_chat.directmessage').replace('$u', user), - link = $('').text(rcmail.gettext('kolab_chat.openchat')) - .attr('href', '?_task=kolab-chat&_channel=' + urlencode(channel_id)); + msg_id = 'chat-' + type + '-' + (type == 'channel' ? channel_id : user), + href = '?_task=kolab-chat&_channel=' + urlencode(channel_id); + + msg = msg.replace('$u', user).replace('$c', channel); if (rcmail.env.kolab_chat_extwin) { - link.attr('target', '_blank').attr('href', link.attr('href') + '&redirect=1'); + link.attr('target', '_blank'); + href += '&redirect=1'; + } + + if (event.data.team_id) { + href += '&_team=' + urlencode(event.data.team_id); } + link.attr('href', href); msg = $('

').text(msg + ' ').append(link).html(); // FIXME: Should we display it indefinitely? - rcmail.display_message(msg, 'notice chat', 10 * 60 * 1000, 'chat-user-' + user); + rcmail.display_message(msg, 'notice chat', 10 * 60 * 1000, msg_id); } } window.WebSocket && window.rcmail && rcmail.addEventListener('init', function() { // Use ajax to get the token for websocket connection $.ajax({ type: 'GET', url: '?_task=kolab-chat&_action=action&_get=token', success: function(data) { data = JSON.parse(data); if (data && data.token) { -// rcmail.set_env({mattermost_url: data.url, mattermost_token: data.token}); + rcmail.set_env({mattermost_url: data.url, mattermost_token: data.token, mattermost_user: data.user_id}); mattermost_websocket_init(data.url, data.token); } } }); }); diff --git a/plugins/kolab_chat/localization/en_US.inc b/plugins/kolab_chat/localization/en_US.inc index 5561f2a9..7447e557 100644 --- a/plugins/kolab_chat/localization/en_US.inc +++ b/plugins/kolab_chat/localization/en_US.inc @@ -1,14 +1,15 @@