diff --git a/README.md b/README.md index fa484849..96cbebef 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,6 @@ The bridge itself should now be running. To actually use it, you will need to configure some linked channels. - Provisioning ------------ @@ -100,6 +99,84 @@ first the individual Matrix room and Slack channel need to be created, and then a command needs to be issued in the administration console room to add the link to the bridge's database. +There are 2 ways to bridge a room. The recommended way uses the newer slack events api +and bot users. This allows you to link as many channels as you would like with only +1 slack integration. The legacy way uses incoming/outgoing webhooks, and requires +2 slack integrations per channel to be bridged. + +### Recommended + +1. Add a custom app to your slack team/workspace by visiting https://api.slack.com/apps + and clicking on `Create New App`. + +2. Name the app & select the team/workspace this app will belong to. + +3. Click on `bot users` and add a new bot user. We will use this account to bridge the + the rooms. + +4. Click on `Event Subscriptions` and enable them. At this point, the bridge needs to be + started as slack will do some verification of the request rul. The request url should be + `https://$HOST:$SLACK_PORT"`. Then add the following events and save: + + Bot User Events: + + - team_domain_change + - message.channels + - message.groups (if you want to bridge private channels) + +5. Skip this step if you do not want to bridge files. + Click on `OAuth & Permissions` and add the following scopes: + + - files:write:user + + Note: any media uploaded to matrix is currently accessible by anyone who knows the url. + In order to make slack files visible to matrix users, this bridge will make slack files + visible to anyone with the url (including files in private channels). This is different + then the current behavior in slack, which only allows authenticated access to media + posted in private channels. + +6. Click on `Install App` and `Install App to Workspace`. Note the access tokens show. + You will need the `Bot User OAuth Access Token` and if you want to bridge files, the + `OAuth Access Token` whenever you link a room. + +7. For each channel you would like to bridge, perform the following steps: + + 1. Create a Matrix room in the usual manner for your client. Take a note of its + Matrix room ID - it will look something like `!aBcDeF:example.com`. + + 2. invite the bot user to the slack channel you would like to bridge. + + ``` + /invite @bot-user-name + + ``` + + You will also need to determine the "channel ID" that Slack uses to identify + the channel, which can be found in the url `https://XXX.slack.com/messages//`. + + 2. Issue a ``link`` command in the administration control room with these + collected values as arguments: + + with file bridging: + + ``` + link --channel_id CHANNELID --room !the-matrix:room.id --slack_bot_token xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx --slack_user_token xoxp-xxxxxxxx-xxxxxxxxx-xxxxxxxx-xxxxxxxx + ``` + without file bridging: + + ``` + link --channel_id CHANNELID --room !the-matrix:room.id --slack_bot_token xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx + ``` + + These arguments can be shortened to single-letter forms: + + ``` + link -I CHANNELID -R !the-matrix:room.id -t xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx + ``` + + +### Legacy + 1. Create a Matrix room in the usual manner for your client. Take a note of its Matrix room ID - it will look something like `!aBcDeF:example.com`. @@ -123,13 +200,13 @@ to the bridge's database. collected values as arguments: ``` - link --channel CHANNELID --room !the-matrix:room.id --token THETOKEN --webhook_uri http://the.webhook/uri + link --channel_id CHANNELID --room !the-matrix:room.id --webhook_url https://hooks.slack.com/services/ABC/DEF/123 ``` These arguments can be shortened to single-letter forms: ``` - link -c CHANNELID -r !the-matrix:room.id -t THETOKEN -u http://the.webhook/uri + link -I CHANNELID -R !the-matrix:room.id -u https://hooks.slack.com/services/ABC/DEF/123 ``` See also https://github.com/matrix-org/matrix-appservice-bridge/blob/master/HOWTO.md for the general theory of all this :) diff --git a/lib/AdminCommands.js b/lib/AdminCommands.js index bd355c16..ed0888d1 100644 --- a/lib/AdminCommands.js +++ b/lib/AdminCommands.js @@ -131,9 +131,16 @@ adminCommands.link = new AdminCommand({ required: true, }, webhook_url: { - description: "Slack webhook URL", + description: "Slack webhook URL. Used with slack outgoing hooks integration", aliases: ['u'], }, + slack_bot_token: { + description: "Slack bot user token. Used with slack bot user & Events api", + aliases: ['t'], + }, + slack_user_token: { + description: "Slack user token. Used to bridge files", + } }, func: function(main, opts, args, respond) { return main.actionLink({ @@ -141,10 +148,14 @@ adminCommands.link = new AdminCommand({ slack_channel_name: opts.channel, slack_channel_id: opts.channel_id, slack_webhook_uri: opts.webhook_url, + slack_bot_token: opts.slack_bot_token, + slack_user_token: opts.slack_user_token, }).then( (room) => { respond("Room is now " + room.getStatus()); - respond("Inbound URL is " + main.getInboundUrlForRoom(room)); + if (room.getSlackWebhookUri()) { + respond("Inbound URL is " + main.getInboundUrlForRoom(room)); + } }, (e) => { respond("Cannot link - " + e ) } ); diff --git a/lib/BaseSlackHandler.js b/lib/BaseSlackHandler.js new file mode 100644 index 00000000..6a515082 --- /dev/null +++ b/lib/BaseSlackHandler.js @@ -0,0 +1,172 @@ +"use strict"; + +var rp = require('request-promise'); +var Promise = require('bluebird'); +var promiseWhile = require("./promiseWhile"); +var getSlackFileUrl = require("./substitutions").getSlackFileUrl; + +/** + * @constructor + * @param {Main} main the toplevel bridge instance through which to + * communicate with matrix. + */ +function BaseSlackHandler(main) { + this._main = main; +} + +BaseSlackHandler.prototype.replaceChannelIdsWithNames = function (message, token) { + var main = this._main; + + // match all channelIds + var testForName = message.text.match(/<#(\w+)\|?\w*?>/g); + var iteration = 0; + var matches = 0; + if (testForName && testForName.length) { + matches = testForName.length; + } + return promiseWhile(function () { + // Do this until there are no more channel ID matches + return iteration < matches; + }, function () { + // foreach channelId, pull out the ID + // (if this is an emote msg, the format is <#ID|name>, but in normal msgs it's just <#ID> + var id = testForName[iteration].match(/<#(\w+)\|?\w*?>/)[1]; + var channelsInfoApiParams = { + uri: 'https://slack.com/api/channels.info', + qs: { + token: token, + channel: id + }, + json: true + }; + main.incRemoteCallCounter("channels.info"); + return rp(channelsInfoApiParams).then((response) => { + if (response && response.channel && response.channel.name) { + console.log("channels.info: " + id + " mapped to " + response.channel.name); + message.text = message.text.replace(/<#(\w+)\|?\w*?>/, "#" + response.channel.name); + } + else { + console.log("channels.info returned no result for " + id); + } + iteration++; + }).catch((err) => { + console.log("Caught error " + err); + }); + }).then(() => { + // Notice we can chain it because it's a Promise, + // this will run after completion of the promiseWhile Promise! + return message; + }); +}; + +BaseSlackHandler.prototype.replaceUserIdsWithNames = function (message, token) { + var main = this._main; + + // match all userIds + var testForName = message.text.match(/<@(\w+)\|?\w*?>/g); + var iteration = 0; + var matches = 0; + if (testForName && testForName.length) { + matches = testForName.length; + } + return promiseWhile(() => { + // Condition for stopping + return iteration < matches; + }, function () { + // foreach userId, pull out the ID + // (if this is an emote msg, the format is <@ID|nick>, but in normal msgs it's just <@ID> + var id = testForName[iteration].match(/<@(\w+)\|?\w*?>/)[1]; + var channelsInfoApiParams = { + uri: 'https://slack.com/api/users.info', + qs: { + token: token, + user: id + }, + json: true + }; + main.incRemoteCallCounter("users.info"); + return rp(channelsInfoApiParams).then((response) => { + if (response && response.user && response.user.profile) { + var name = response.user.profile.display_name || response.user.profile.real_name; + console.log("users.info: " + id + " mapped to " + name); + message.text = message.text.replace(/<@(\w+)\|?\w*?>/, name); + } + else { + console.log("users.info returned no result for " + id); + } + iteration++; + }).catch((err) => { + console.log("Caught error " + err); + }); + }).then(() => { + // Notice we can chain it because it's a Promise, + // this will run after completion of the promiseWhile Promise! + return message; + }); +}; + +// Return true if we ought to fetch the content of the given file object +BaseSlackHandler.prototype.shouldFetchContent = function (file) { + if (!file) return false; + if (file.mimetype && file.mimetype.indexOf("image/") === 0) return true; + return false; +} + +/** + * Enables public sharing on the given file object. then fetches its content. + * + * @param {Object} file A slack 'message.file' data object + * @param {string} token A slack API token that has 'files:write:user' scope + * @return {Promise} A Promise of the updated slack file data object + */ +BaseSlackHandler.prototype.enablePublicSharing = function (file, token) { + if (file.public_url_shared) return Promise.resolve(file); + + this._main.incRemoteCallCounter("files.sharedPublicURL"); + return rp({ + method: 'POST', + form: { + file: file.id, + token: token, + }, + uri: "https://slack.com/api/files.sharedPublicURL", + json: true + }).then((response) => { + if (!response || !response.file || !response.file.permalink_public) { + console.log("Could not find sharedPublichURL: " + JSON.stringify(response)); + return undefined; + } + + return response.file; + }); +} + +/** + * Fetchs the file at a given url. + * + * @param {Object} file A slack 'message.file' data object + * @return {Promise} A Promise of file contents + */ +BaseSlackHandler.prototype.fetchFileContent = function (file, token) { + if (!file) return Promise.resolve(); + + // if (file.is_public) { + var url = getSlackFileUrl(file); + if (!url) url = file.permalink_public; + // } else { + // url = file.url_private + // } + + return rp({ + uri: url, + resolveWithFullResponse: true, + encoding: null + }).then((response) => { + var content = response.body; + console.log("Successfully fetched file " + file.id + + " content (" + content.length + " bytes)"); + return content; + }); +}; + +module.exports = BaseSlackHandler; diff --git a/lib/BridgedRoom.js b/lib/BridgedRoom.js index 6420edda..30e0f341 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -20,6 +20,12 @@ function BridgedRoom(main, opts) { this._slack_channel_name = opts.slack_channel_name; this._slack_channel_id = opts.slack_channel_id; this._slack_webhook_uri = opts.slack_webhook_uri; + this._slack_bot_token = opts.slack_bot_token; + this._slack_user_token = opts.slack_user_token; + this._slack_team_domain = opts.slack_team_domain; + this._slack_team_id = opts.slack_team_id; + this._slack_user_id = opts.slack_user_id; + this._slack_bot_id = opts.slack_bot_id; this._access_token = opts.access_token; this._access_scopes = opts.access_scopes; @@ -30,13 +36,13 @@ function BridgedRoom(main, opts) { }; BridgedRoom.prototype.getStatus = function() { - if (!this._slack_webhook_uri) { + if (!this._slack_webhook_uri && !this._slack_bot_token) { return "pending-params"; } if (!this._slack_channel_name) { return "pending-name"; } - if (!this._access_token) { + if (!this._access_token && !this._slack_bot_token) { return "ready-no-token"; } return "ready"; @@ -65,13 +71,29 @@ BridgedRoom.prototype.getSlackWebhookUri = function() { }; BridgedRoom.prototype.getAccessToken = function() { - return this._access_token; + return this._access_token || this._slack_bot_token; }; BridgedRoom.prototype.getMatrixRoomId = function() { return this._matrix_room_id; }; +BridgedRoom.prototype.getSlackTeamDomain = function() { + return this._slack_team_domain; +}; + +BridgedRoom.prototype.getSlackTeamId = function() { + return this._slack_team_id; +}; + +BridgedRoom.prototype.getSlackBotId = function() { + return this._slack_bot_id; +}; + +BridgedRoom.prototype.getSlackUserToken = function() { + return this._slack_user_token; +}; + BridgedRoom.prototype.updateInboundId = function(inbound_id) { if (this._inbound_id !== inbound_id) this._dirty = true; this._inbound_id = inbound_id; @@ -92,7 +114,23 @@ BridgedRoom.prototype.updateSlackWebhookUri = function(slack_webhook_uri) { this._slack_webhook_uri = slack_webhook_uri; }; +BridgedRoom.prototype.updateSlackBotToken = function(slack_bot_token) { + if (this._slack_bot_token !== slack_bot_token) this._dirty = true; + this._slack_bot_token = slack_bot_token; +}; + +BridgedRoom.prototype.updateSlackUserToken = function(slack_user_token) { + if (this._slack_user_token !== slack_user_token) this._dirty = true; + this._slack_user_token = slack_user_token; +}; + +BridgedRoom.prototype.updateSlackTeamDomain = function(domain) { + if (this._slack_team_domain !== domain) this._dirty = true; + this._slack_team_domain = domain; +}; + BridgedRoom.prototype.updateAccessToken = function(token, scopes) { + console.log('updateAccessToken ->', token, scopes); if (this._access_token === token && this._access_scopes.sort().join(",") === scopes.sort().join(",")) return; @@ -108,6 +146,12 @@ BridgedRoom.fromEntry = function(main, entry) { slack_channel_id: entry.remote.id, slack_channel_name: entry.remote.name, slack_webhook_uri: entry.remote.webhook_uri, + slack_bot_token: entry.remote.slack_bot_token, + slack_user_token: entry.remote.slack_user_token, + slack_team_domain: entry.remote.slack_team_domain, + slack_team_id: entry.remote.slack_team_id, + slack_user_id: entry.remote.slack_user_id, + slack_bot_id: entry.remote.slack_bot_id, access_token: entry.remote.access_token, access_scopes: entry.remote.access_scopes, }; @@ -123,6 +167,12 @@ BridgedRoom.prototype.toEntry = function() { id: this._slack_channel_id, name: this._slack_channel_name, webhook_uri: this._slack_webhook_uri, + slack_bot_token: this._slack_bot_token, + slack_user_token: this._slack_user_token, + slack_team_domain: this._slack_team_domain, + slack_team_id: this._slack_team_id, + slack_user_id: this._slack_user_id, + slack_bot_id: this._slack_bot_id, access_token: this._access_token, access_scopes: this._access_scopes, }, @@ -137,18 +187,28 @@ BridgedRoom.prototype.toEntry = function() { }; BridgedRoom.prototype.onMatrixMessage = function(message) { - if (!this._slack_webhook_uri) return Promise.resolve(); + if (!this._slack_webhook_uri && !this._slack_bot_token) return Promise.resolve(); return this._main.getOrCreateMatrixUser(message.user_id).then((user) => { var body = substitutions.matrixToSlack(message, this._main); + var uri = (this._slack_bot_token) ? "https://slack.com/api/chat.postMessage" : this._slack_webhook_uri; + var sendMessageParams = { method: "POST", json: true, - uri: this._slack_webhook_uri, - body: body + uri: uri, + body: body, }; + if (this._slack_bot_token) { + sendMessageParams.headers = { + Authorization: 'Bearer ' + this._slack_bot_token + }; + sendMessageParams.body.as_user = false; + sendMessageParams.body.channel = this._slack_channel_id; + } + sendMessageParams.body.username = user.getDisplaynameForRoom(message.room_id); var avatar_url = user.getAvatarUrlForRoom(message.room_id); @@ -162,8 +222,8 @@ BridgedRoom.prototype.onMatrixMessage = function(message) { return rp(sendMessageParams).then((res) => { this._main.incCounter("sent_messages", {side: "remote"}); - if (!res) { - console.log("HTTP Error: %s", res); + if (!res || (this._slack_bot_token && !res.ok)) { + console.log("HTTP Error: ", res); } }); }); @@ -185,7 +245,7 @@ BridgedRoom.prototype._handleSlackMessage = function(message, ghost) { var subtype = message.subtype; - if (!subtype) { + if (!subtype || subtype === "bot_message") { var text = substitutions.slackToMatrix(message.text); return ghost.sendText(roomID, text); } @@ -197,12 +257,12 @@ BridgedRoom.prototype._handleSlackMessage = function(message, ghost) { return ghost.sendMessage(roomID, message); } else if (subtype === "file_comment") { - var text = substitutions.slackToMatrix(message.text); + var text = substitutions.slackToMatrix(message.text, message.file); return ghost.sendText(roomID, text); } else if (subtype === "file_share") { if (!message.file) { - console.log("Ignoring non-text non-image message: " + res); + console.log("Ignoring missing file message: " + message); return; } if (message.file._content) { @@ -216,14 +276,20 @@ BridgedRoom.prototype._handleSlackMessage = function(message, ghost) { // no URL returned from media repo; abort return undefined; } - var matrixMessage = slackImageToMatrixImage(message.file, content_uri); + var matrixMessage = slackFileToMatrixMessage(message.file, content_uri); return ghost.sendMessage(roomID, matrixMessage); }).finally(() => { - var text = substitutions.slackToMatrix( - message.file.initial_comment.comment - ); - return ghost.sendText(roomID, text); + if (message.file.initial_comment) { + var text = substitutions.slackToMatrix( + message.file.initial_comment.comment + ); + return ghost.sendText(roomID, text); + } }); + } else { + // post a msg with the link + var text = substitutions.slackToMatrix(message.text, message.file); + return ghost.sendText(roomID, text); } } else { @@ -292,6 +358,31 @@ var slackImageToMatrixImage = function(file, url) { return message; }; +/** + * Converts a slack file upload to a matrix file upload event. + * + * @param {Object} file The slack file object. + * @return {Object} Matrix event content, as per https://matrix.org/docs/spec/#m-file + */ +var slackFileToMatrixMessage = function(file, url) { + if (file.mimetype && file.mimetype.indexOf("image/") === 0) { + return slackImageToMatrixImage(file, url); + } + + var message = { + msgtype: "m.file", + url: url, + body: file.title, + info: { + mimetype: file.mimetype + } + }; + if (file.size) { + message.info.size = file.size; + } + return message; +}; + BridgedRoom.prototype.getRemoteATime = function() { return this._slackAtime; }; @@ -300,4 +391,64 @@ BridgedRoom.prototype.getMatrixATime = function() { return this._matrixAtime; }; +BridgedRoom.prototype.lookupAndSetTeamInfo = function() { + if (!this._slack_bot_token) return Promise.resolve(); + + return rp({ + uri: 'https://slack.com/api/team.info', + qs: { + token: this._slack_bot_token + }, + json: true, + }).then((response) => { + if (!response.team) return; + + if (this._slack_team_domain !== response.team.domain) { + this._slack_team_domain = response.team.domain; + this._dirty = true; + } + + if (this._slack_team_id !== response.team.id) { + this._slack_team_id = response.team.id; + this._dirty = true; + } + }); +}; + +BridgedRoom.prototype.lookupAndSetUserInfo = function() { + if (!this._slack_bot_token) return Promise.resolve(); + + return rp({ + uri: 'https://slack.com/api/auth.test', + qs: { + token: this._slack_bot_token + }, + json: true, + }).then((response) => { + console.log(response); + if (!response.user_id) return; + + if (this._slack_user_id !== response.user_id) { + this._slack_user_id = response.user_id; + this._dirty = true; + } + + return rp({ + uri: 'https://slack.com/api/users.info', + qs: { + token: this._slack_bot_token, + user: response.user_id, + }, + json: true, + }); + }).then((response) => { + if (!response.user || !response.user.profile) return; + + if (this._slack_bot_id !== response.user.profile.bot_id) { + this._slack_bot_id = response.user.profile.bot_id; + this._dirty = true; + } + }); +}; + module.exports = BridgedRoom; diff --git a/lib/Main.js b/lib/Main.js index 009eae59..d3730ef6 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -40,6 +40,7 @@ function Main(config) { this._rooms = []; this._roomsBySlackChannelId = {}; + this._roomsBySlackTeamId = {}; this._roomsByMatrixRoomId = {}; this._roomsByInboundId = {}; @@ -193,6 +194,7 @@ Main.prototype.getGhostForSlackMessage = function(message) { // Slack ghost IDs need to be constructed from user IDs, not usernames, // because users can change their names + // TODO if the team_domain is changed, we will recreate all users. // TODO(paul): Steal MatrixIdTemplate from matrix-appservice-gitter var user_id = [ "@", this._config.username_prefix, message.team_domain.toLowerCase(), @@ -258,6 +260,16 @@ Main.prototype.addBridgedRoom = function(room) { var inbound_id = room.getInboundId(); if (inbound_id) this._roomsByInboundId[inbound_id] = room; + + var team_id = room.getSlackTeamId(); + if (team_id) { + var rooms = this._roomsBySlackTeamId[team_id]; + if (!rooms) { + this._roomsBySlackTeamId[team_id] = [ room ]; + } else { + rooms.push(room); + } + } }; Main.prototype.removeBridgedRoom = function(room) { @@ -274,6 +286,10 @@ Main.prototype.getRoomBySlackChannelId = function(channel_id) { return this._roomsBySlackChannelId[channel_id]; }; +Main.prototype.getRoomsBySlackTeamId = function(team_id) { + return this._roomsBySlackTeamId[team_id] || []; +}; + Main.prototype.getRoomBySlackChannelName = function(channel_name) { // TODO(paul): this gets inefficient for long lists for(var i = 0; i < this._rooms.length; i++) { @@ -406,8 +422,11 @@ Main.prototype.onMatrixEvent = function(ev) { // A membership event about myself var membership = ev.content.membership; if (membership === "invite") { - // Automatically accept all invitations - this.getBotIntent().join(ev.room_id); + // Automatically accept all invitations, but wait 30 seconds before doing it. + // this was throwing an error when the bot was invited to a room by a user on + // a different homeserver ({"errcode":"M_FORBIDDEN","error":"You are not invited to this room."}) + Promise.delay(30 * 1000).then(() => this.getBotIntent().join(ev.room_id)); + } endTimer({outcome: "success"}); @@ -505,6 +524,7 @@ Main.prototype.actionLink = function(opts) { var room = this.getRoomByMatrixRoomId(matrix_room_id); + var isNew = false; if (!room) { var inbound_id = this.genInboundId(); @@ -512,7 +532,7 @@ Main.prototype.actionLink = function(opts) { inbound_id: inbound_id, matrix_room_id: matrix_room_id, }); - this.addBridgedRoom(room); + isNew = true; this._roomsByMatrixRoomId[matrix_room_id] = room; this._stateStorage.trackRoom(matrix_room_id); } @@ -521,6 +541,28 @@ Main.prototype.actionLink = function(opts) { room.updateSlackWebhookUri(opts.slack_webhook_uri); } + if (opts.slack_channel_id) { + room.updateSlackChannelId(opts.slack_channel_id); + } + + if (opts.slack_user_token) { + room.updateSlackUserToken(opts.slack_user_token); + } + + if (opts.slack_bot_token) { + room.updateSlackBotToken(opts.slack_bot_token); + return Promise.all([ room.lookupAndSetTeamInfo(), room.lookupAndSetUserInfo() ]) + .then(() => { + if (isNew) this.addBridgedRoom(room); + + if (room.isDirty()) this.putRoomToStore(room); + + return room; + }); + } + + if (isNew) this.addBridgedRoom(room); + if (room.isDirty()) { this.putRoomToStore(room); } diff --git a/lib/SlackEventHandler.js b/lib/SlackEventHandler.js new file mode 100644 index 00000000..67e16605 --- /dev/null +++ b/lib/SlackEventHandler.js @@ -0,0 +1,215 @@ +"use strict"; + +var BaseSlackHandler = require('./BaseSlackHandler'); +var Promise = require('bluebird'); +var util = require("util"); + +var UnknownEvent = function () { +}; +var UnknownChannel = function (channel) { + this.channel = channel; +}; + +/** + * @constructor + * @param {Main} main the toplevel bridge instance through which to + * communicate with matrix. + */ +function SlackEventHandler(main) { + this._main = main; +} + +util.inherits(SlackEventHandler, BaseSlackHandler); + +/** + * Handles a slack event request. + * + * @param {Object} params HTTP body of the event request, as a JSON-parsed dictionary. + * @param {string} params.team_id The unique identifier for the workspace/team where this event occurred. + * @param {Object} params.event Slack event object + * @param {string} params.event.type Slack event type + * @param {string} params.type type of callback we are receiving. typically event_callback + * or url_verification. + */ +SlackEventHandler.prototype.handle = function (params, response) { + try { + console.log("Received slack event:", JSON.stringify(params)); + + var main = this._main; + + var endTimer = main.startTimer("remote_request_seconds"); + + // respond to event url challenges + if (params.type === 'url_verification') { + response.writeHead(200, {"Content-Type": "application/json"}); + response.write(JSON.stringify({challenge: params.challenge})); + response.end(); + return; + } + + var result; + switch (params.event.type) { + case 'message': + result = this.handleMessageEvent(params); + break; + case 'channel_rename': + result = this.handleChannelRenameEvent(params); + break; + case 'team_domain_change': + result = this.handleDomainChangeEvent(params); + break; + case 'file_comment_added': + result = Promise.resolve(); + break; + default: + result = Promise.reject(new UnknownEvent()); + } + + result.then(() => endTimer({outcome: "success"})) + .catch((e) => { + if (e instanceof UnknownChannel) { + console.log("Ignoring message from unrecognised slack channel id : %s (%s)", + e.channel, params.team_id); + main.incCounter("received_messages", {side: "remote"}); + + endTimer({outcome: "dropped"}); + return; + } else if (e instanceof UnknownEvent) { + endTimer({outcome: "dropped"}); + } else { + endTimer({outcome: "fail"}); + } + + console.log("Failed: ", e); + } + ); + } catch (e) { + console.log("Oops - SlackEventHandler failed:", e); + } + + // return 200 so slack doesn't keep sending the event + response.writeHead(200, {"Content-Type": "text/plain"}); + response.end(); + +}; + +/** + * Attempts to handle the `team_domain_change` event. + * + * @param {Object} params The event request emitted. + * @param {Object} params.team_id The slack team_id for the event. + * @param {string} params.event.domain The new team domain. + */ +SlackEventHandler.prototype.handleDomainChangeEvent = function (params) { + this._main.getRoomsBySlackTeamId(params.team_id).forEach(room => { + room.updateSlackTeamDomain(params.event.domain); + if (room.isDirty()) { + this._main.putRoomToStore(room); + } + }); + return Promise.resolve(); +}; + +/** + * Attempts to handle the `channel_rename` event. + * + * @param {Object} params The event request emitted. + * @param {string} params.event.id The slack channel id + * @param {string} params.event.name The new name + */ +SlackEventHandler.prototype.handleChannelRenameEvent = function (params) { + //TODO test me. and do we even need this? doesn't appear to be used anymore + var room = this._main.getRoomBySlackChannelId(params.event.channel.id); + if (!room) throw new UnknownChannel(params.event.channel.id); + + var channel_name = room.getSlackTeamDomain() + ".#" + params.name; + room.updateSlackChannelName(channel_name); + if (room.isDirty()) { + this._main.putRoomToStore(room); + } + return Promise.resolve(); +}; + +/** + * Attempts to handle the `message` event. + * + * Sends a message to Matrix if it understands enough of the message to do so. + * Attempts to make the message as native-matrix feeling as it can. + * + * @param {Object} params The event request emitted. + * @param {string} params.event.user Slack user ID of user sending the message. + * @param {?string} params.event.text Text contents of the message, if a text message. + * @param {string} params.event.channel The slack channel id + * @param {string} params.event.ts The unique (per-channel) timestamp + */ +SlackEventHandler.prototype.handleMessageEvent = function (params) { + var room = this._main.getRoomBySlackChannelId(params.event.channel); + if (!room) throw new UnknownChannel(params.event.channel); + + if (params.event.subtype === 'bot_message' && + (!room.getSlackBotId() || params.event.bot_id === room.getSlackBotId())) { + return Promise.resolve(); + } + + // Only count received messages that aren't self-reflections + this._main.incCounter("received_messages", {side: "remote"}); + + var token = room.getAccessToken(); + + var msg = Object.assign({}, params.event, { + user_id: params.event.user || params.event.bot_id, + team_domain: room.getSlackTeamDomain() || room.getSlackTeamId(), + channel_id: params.event.channel + }); + + var processMsg = msg.text || msg.subtype === 'message_deleted'; + + if (msg.subtype === 'file_comment') { + msg.user_id = msg.comment.user; + } + + if (!token) { + // If we can't look up more details about the message + // (because we don't have a master token), but it has text, + // just send the message as text. + console.log("no slack token for " + room.getSlackTeamDomain() || room.getSlackChannelId()); + + return (processMsg) ? room.onSlackMessage(msg) : Promise.resolve(); + } + + if (!processMsg) { + // TODO(paul): When I started looking at this code there was no lookupAndSendMessage() + // I wonder if this code path never gets called...? + // lookupAndSendMessage(params.channel_id, params.timestamp, intent, roomID, token); + console.log('SlackEventHandler text === undefined'); + return Promise.resolve(); + } + + var result; + if (msg.subtype === "file_share" && msg.file) { + // TODO check is_public when matrix supports authenticated media https://github.com/matrix-org/matrix-doc/issues/701 + // we need a user token to be able to enablePublicSharing + if (room.getSlackUserToken()) { + result = this.enablePublicSharing(msg.file, room.getSlackUserToken()) + .then((file) => { + if (file) { + msg.file = file; + } + + return this.fetchFileContent(msg.file, token) + .then((content) => { + msg.file._content = content; + }); + }) + } + } else { + result = Promise.resolve(); + } + + return result.then(() => msg) + .then((msg) => this.replaceChannelIdsWithNames(msg, token)) + .then((msg) => this.replaceUserIdsWithNames(msg, token)) + .then((msg) => room.onSlackMessage(msg)); +}; + +module.exports = SlackEventHandler; diff --git a/lib/SlackGhost.js b/lib/SlackGhost.js index f053b700..2bb0b236 100644 --- a/lib/SlackGhost.js +++ b/lib/SlackGhost.js @@ -3,8 +3,8 @@ var rp = require('request-promise'); var slackdown = require('slackdown'); -// How long in msec to cache avatar URL lookups from slack -var AVATAR_CACHE_TIMEOUT = 10 * 60 * 1000; // 10 minutes +// How long in msec to cache user infor lookups from slack +var USER_CACHE_TIMEOUT = 10 * 60 * 1000; // 10 minutes function SlackGhost(opts) { this._main = opts.main; @@ -57,20 +57,51 @@ SlackGhost.prototype.update = function(message, room) { SlackGhost.prototype.updateDisplayname = function(message, room) { var display_name = message.user_name; - if (!display_name) return Promise.resolve(); - if (this._display_name === display_name) return Promise.resolve(); - return this.getIntent().setDisplayName(display_name).then(() => { - this._display_name = display_name; - return this._main.putUserToStore(this); - }); + var getDisplayName; + if (!display_name) { + getDisplayName = this.lookupUserInfo(message.user_id, room.getAccessToken()) + .then(user => { + if (user && user.profile) { + return user.profile.display_name || user.profile.real_name; + } + }); + } else { + getDisplayName = Promise.resolve(display_name); + } + + return getDisplayName.then(display_name => { + if (!display_name || this._display_name === display_name) return Promise.resolve(); + + return this.getIntent().setDisplayName(display_name).then(() => { + this._display_name = display_name; + return this._main.putUserToStore(this); + }); + }) }; SlackGhost.prototype.lookupAvatarUrl = function(user_id, token) { - if (this._avatar_url_cache) return Promise.resolve(this._avatar_url_cache); + return this.lookupUserInfo(user_id, token).then((user) => { + if (!user || !user.profile) return; + var profile = user.profile; + + // Pick the original image if we can, otherwise pick the largest image + // that is defined + var avatar_url = profile.image_original || + profile.image_1024 || profile.image_512 || profile.image_192 || + profile.image_72 || profile.image_48; + + return avatar_url; + }); +}; + +SlackGhost.prototype.lookupUserInfo = function(user_id, token) { + if (this._user_info_cache) return Promise.resolve(this._user_info_cache); + if (this._loading_user) return this._loading_user; + if (!token) return Promise.resolve(); this._main.incRemoteCallCounter("users.info"); - return rp({ + this._loading_user = rp({ uri: 'https://slack.com/api/users.info', qs: { token: token, @@ -79,19 +110,15 @@ SlackGhost.prototype.lookupAvatarUrl = function(user_id, token) { json: true, }).then((response) => { if (!response.user || !response.user.profile) return; - var profile = response.user.profile; - - // Pick the original image if we can, otherwise pick the largest image - // that is defined - var avatar_url = profile.image_original || - profile.image_1024 || profile.image_512 || profile.image_192 || - profile.image_72 || profile.image_48; - this._avatar_url_cache = avatar_url; - setTimeout(() => { this._avatar_url_cache = null }, AVATAR_CACHE_TIMEOUT); + this._user_info_cache = response.user; + setTimeout(() => { this._user_info_cache = null }, USER_CACHE_TIMEOUT); - return avatar_url; + delete this._loading_user; + return response.user; }); + + return this._loading_user; }; SlackGhost.prototype.updateAvatar = function(message, room) { diff --git a/lib/SlackHookHandler.js b/lib/SlackHookHandler.js index 8efbb104..2612602f 100644 --- a/lib/SlackHookHandler.js +++ b/lib/SlackHookHandler.js @@ -1,10 +1,13 @@ "use strict"; var substitutions = require("./substitutions"); +var SlackEventHandler = require('./SlackEventHandler'); +var BaseSlackHandler = require('./BaseSlackHandler'); var rp = require('request-promise'); var qs = require("querystring"); var Promise = require('bluebird'); var promiseWhile = require("./promiseWhile"); +var util = require("util"); var PRESERVE_KEYS = [ "team_domain", "team_id", @@ -19,8 +22,11 @@ var PRESERVE_KEYS = [ */ function SlackHookHandler(main) { this._main = main; + this.eventHandler = new SlackEventHandler(main); } +util.inherits(SlackHookHandler, BaseSlackHandler); + /** * Starts the hook server listening on the given port and (optional) TLS * configuration. @@ -53,9 +59,17 @@ SlackHookHandler.prototype.startAndListen = function(port, tls_config) { }); request.on("end", () => { - var params = qs.parse(body); + // if isEvent === true, this was an event emitted from the slack Event API + // https://api.slack.com/events-api + var isEvent = request.headers['content-type'] === 'application/json' && request.method === 'POST'; try { - this.handle(request.method, request.url, params, response); + if (isEvent) { + var params = JSON.parse(body); + this.eventHandler.handle(params, response); + } else { + var params = qs.parse(body); + this.handle(request.method, request.url, params, response); + } } catch (e) { console.log("Oops - SlackHookHandler failed:", e); @@ -271,96 +285,6 @@ SlackHookHandler.prototype.handleAuthorize = function(room, params) { ); }; -SlackHookHandler.prototype.replaceChannelIdsWithNames = function(message, token) { - var main = this._main; - - // match all channelIds - var testForName = message.text.match(/<#(\w+)\|?\w*?>/g); - var iteration = 0; - var matches = 0; - if (testForName && testForName.length) { - matches = testForName.length; - } - return promiseWhile(function() { - // Do this until there are no more channel ID matches - return iteration < matches; - }, function() { - // foreach channelId, pull out the ID - // (if this is an emote msg, the format is <#ID|name>, but in normal msgs it's just <#ID> - var id = testForName[iteration].match(/<#(\w+)\|?\w*?>/)[1]; - var channelsInfoApiParams = { - uri: 'https://slack.com/api/channels.info', - qs: { - token: token, - channel: id - }, - json: true - }; - main.incRemoteCallCounter("channels.info"); - return rp(channelsInfoApiParams).then((response) => { - if (response && response.channel && response.channel.name) { - console.log("channels.info: " + id + " mapped to " + response.channel.name); - message.text = message.text.replace(/<#(\w+)\|?\w*?>/, "#" + response.channel.name); - } - else { - console.log("channels.info returned no result for " + id); - } - iteration++; - }).catch((err) => { - console.log("Caught error " + err); - }); - }).then(() => { - // Notice we can chain it because it's a Promise, - // this will run after completion of the promiseWhile Promise! - return message; - }); -}; - -SlackHookHandler.prototype.replaceUserIdsWithNames = function(message, token) { - var main = this._main; - - // match all userIds - var testForName = message.text.match(/<@(\w+)\|?\w*?>/g); - var iteration = 0; - var matches = 0; - if (testForName && testForName.length) { - matches = testForName.length; - } - return promiseWhile(() => { - // Condition for stopping - return iteration < matches; - }, function() { - // foreach userId, pull out the ID - // (if this is an emote msg, the format is <@ID|nick>, but in normal msgs it's just <@ID> - var id = testForName[iteration].match(/<@(\w+)\|?\w*?>/)[1]; - var channelsInfoApiParams = { - uri: 'https://slack.com/api/users.info', - qs: { - token: token, - user: id - }, - json: true - }; - main.incRemoteCallCounter("users.info"); - return rp(channelsInfoApiParams).then((response) => { - if (response && response.user && response.user.name) { - console.log("users.info: " + id + " mapped to " + response.user.name); - message.text = message.text.replace(/<@(\w+)\|?\w*?>/, response.user.name); - } - else { - console.log("users.info returned no result for " + id); - } - iteration++; - }).catch((err) => { - console.log("Caught error " + err); - }); - }).then(() => { - // Notice we can chain it because it's a Promise, - // this will run after completion of the promiseWhile Promise! - return message; - }); -}; - /** * Attempts to handle a message received from a slack webhook request. * @@ -409,63 +333,23 @@ SlackHookHandler.prototype.lookupMessage = function(channelID, timestamp, token) var message = response.messages[0]; console.log("Looked up message from history as " + JSON.stringify(message)); - if (message.subtype === "file_share" && shouldFetchContent(message.file)) { - return this.fetchFileContent(message.file, token).then((content) => { - message.file._content = content; - return message; - }); - } - return message; - }); -} - -// Return true if we ought to fetch the content of the given file object -function shouldFetchContent(file) { - if (!file) return false; - if (file.mimetype && file.mimetype.indexOf("image/") === 0) return true; - return false; -} - -/** - * Enables public sharing on the given file object then fetches its content. - * - * @param {Object} file A slack 'message.file' data object - * @param {string} token A slack API token that has 'files:write:user' scope - * @return {Promise} A Promise of file contents - */ -SlackHookHandler.prototype.fetchFileContent = function(file, token) { - this._main.incRemoteCallCounter("files.sharedPublicURL"); - return rp({ - method: 'POST', - form : { - file: file.id, - token: token, - }, - uri: "https://slack.com/api/files.sharedPublicURL", - json: true - }).then((response) => { - if (!response || !response.file || !response.file.permalink_public) { - console.log("Could not find sharedPublichURL: " + JSON.stringify(response)); - return undefined; - } + if (message.subtype === "file_share" && this.shouldFetchContent(message.file)) { + return this.enablePublicSharing(message.file, token) + .then((file) => { + message.file = file; + + if (this.shouldFetchContent(message.file)) { + return this.fetchFileContent(message.file, token) + .then((content) => { + message.file._content = content; + return message; + }); + } - var pub_secret = file.permalink_public.match(/https?:\/\/slack-files.com\/[^-]*-[^-]*-(.*)/); - var public_file_url = file.permalink_public; - // try to get direct link to image - if (pub_secret != undefined && pub_secret.length > 0) { - public_file_url = file.url_private + "?pub_secret=" + pub_secret[1]; + return message; + }); } - - return rp({ - uri: public_file_url, - resolveWithFullResponse: true, - encoding: null - }); - }).then((response) => { - var content = response.body; - console.log("Successfully fetched file " + file.id + - " content (" + content.length + " bytes)"); - return content; + return message; }); } diff --git a/lib/substitutions.js b/lib/substitutions.js index e59418f9..34386f58 100644 --- a/lib/substitutions.js +++ b/lib/substitutions.js @@ -51,8 +51,9 @@ function replaceAll(string, old, replacement) { * of a Slack message appear like the text of a Matrix message. * * @param {string} string the text, in Slack's format. + * @param {Object} file options slack file object */ -var slackToMatrix = function(body) { +var slackToMatrix = function(body, file) { console.log("running substitutions on: " + body); if (undefined != body) { @@ -73,6 +74,12 @@ var slackToMatrix = function(body) { // so we need to strip <> from the link body = body.replace(/<(https?:\/\/[^>]+?)>/g, '$1'); + // if we have a file, attempt to get the direct link to the file + if (file && file.public_url_shared) { + var url = getSlackFileUrl(file); + body = body.replace(file.permalink, url); + } + // attempt to match any text inside colons to emoji, e.g. :smiley: // we use replace to run a function on each match, replacing matches in buf // (returning the match to body doesn't work, hence this approach) @@ -89,6 +96,14 @@ var slackToMatrix = function(body) { return buf; }; +var getSlackFileUrl = function(file) { + var pub_secret = file.permalink_public.match(/https?:\/\/slack-files.com\/[^-]*-[^-]*-(.*)/); + // try to get direct link to the file + if (pub_secret !== undefined && pub_secret.length > 0) { + return file.url_private + "?pub_secret=" + pub_secret[1]; + } +}; + /** * Performs any escaping, unescaping, or substituting required to make the text * of a Matrix message appear like the text of a Slack message. @@ -137,5 +152,6 @@ var matrixToSlack = function(event, main) { module.exports = { matrixToSlack: matrixToSlack, - slackToMatrix: slackToMatrix + slackToMatrix: slackToMatrix, + getSlackFileUrl: getSlackFileUrl };