diff --git a/README.md b/README.md index fa484849..36d17e1c 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,87 @@ 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 - Events API + +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 + - chat:write:bot + - message.groups (if you want to bridge private channels) + - users:read + - team.info + +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. See [MSC701](https://github.com/matrix-org/matrix-doc/issues/701) + for details. + +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//`. + + 3. 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 - Webhooks + 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 +203,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 62c0e723..f3d2e640 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..5e23e4c7 --- /dev/null +++ b/lib/BaseSlackHandler.js @@ -0,0 +1,208 @@ +"use strict"; + +const rp = require('request-promise'); +const Promise = require('bluebird'); +const promiseWhile = require("./promiseWhile"); +const getSlackFileUrl = require("./substitutions").getSlackFileUrl; +const log = require("matrix-appservice-bridge").Logging.get("BaseSlackHandler"); + +const CHANNEL_ID_REGEX = /<#(\w+)\|?\w*?>/g; +const CHANNEL_ID_REGEX_FIRST = /<#(\w+)\|?\w*?>/; + +// (if the message is an emote, the format is <@ID|nick>, but in normal msgs it's just <@ID> +const USER_ID_REGEX = /<@(\w+)\|?\w*?>/g; +const USER_ID_REGEX_FIRST = /<@(\w+)\|?\w*?>/; + +/** + * @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(CHANNEL_ID_REGEX); + 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(CHANNEL_ID_REGEX_FIRST)[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) => { + let name = id; + if (response && response.channel && response.channel.name) { + log.info("channels.info: " + id + " mapped to " + response.channel.name); + name = response.channel.name; + } + else { + log.info("channels.info returned no result for " + id); + } + message.text = message.text.replace(CHANNEL_ID_REGEX_FIRST, "#" + name); + iteration++; + }).catch((err) => { + log.error("Caught error handling channels.info:" + 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(USER_ID_REGEX); + 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(USER_ID_REGEX_FIRST)[1]; + var usersInfoApiParams = { + uri: 'https://slack.com/api/users.info', + qs: { + token: token, + user: id + }, + json: true + }; + main.incRemoteCallCounter("users.info"); + let response; + iteration++; + return rp(usersInfoApiParams).then((res) => { + // Technically the function only requires the team_id, so + // pass in the response to get user instead. + // Though obviously don't if the response was wrong. + response = res; + return main.getTeamDomainForMessage( + { + team_id: (res && res.user ? res.user : message).team_id, + channel: message.channel, + } + ); + }).then((team_domain) => { + const user_id = main.getUserId(id, team_domain); + if (response && response.user && response.user.name) { + log.info("users.info: " + id + " mapped to " + response.user.name); + const pill = ``; + message.text = message.text.replace(USER_ID_REGEX_FIRST, response.user.name); + message.markdownText = message.markdownText.replace( + USER_ID_REGEX_FIRST, + pill + ); + return; + } + log.warn(`users.info returned no result for ${id} Response:`, response); + // Fallback to checking the user store. + var store = this.getUserStore(); + return store.select({id: user_id}); + }).then((result) => { + if (result === undefined) { + return; + } + let name = user_id; + log.info(`${user_id} did ${result.length > 0 ? "not" : ""} an entry`); + if (result.length) { + // It's possible not to have a displayname set. + name = result[0].display_name || result[0].id; + } + message.text = message.text.replace(USER_ID_REGEX_FIRST, name); + message.markdownText = message.markdownText.replace( + USER_ID_REGEX_FIRST, + `` + ); + }).catch((err) => { + log.error("Caught error handing users.info: " + err); + }); + }).then(() => { + // Notice we can chain it because it's a Promise, + // this will run after completion of the promiseWhile Promise! + return message; + }); +}; + + +/** + * 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) { + log.warn("Could not find sharedPublicURL: " + 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(); + + const url = getSlackFileUrl(file) || file.permalink_public; + if (!url) { + return Promise,reject("File doesn't have any URLs we can use."); + } + + return rp({ + uri: url, + resolveWithFullResponse: true, + encoding: null + }).then((response) => { + var content = response.body; + log.debug("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 9611c488..3c600be6 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -21,6 +21,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; @@ -31,13 +37,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"; @@ -66,13 +72,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; @@ -93,7 +115,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) { + log.info('updateAccessToken ->', token, scopes); if (this._access_token === token && this._access_scopes.sort().join(",") === scopes.sort().join(",")) return; @@ -109,6 +147,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, }; @@ -124,6 +168,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, }, @@ -138,17 +188,27 @@ 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 + }; + // See https://api.slack.com/methods/chat.postMessage#authorship + sendMessageParams.body.as_user = false; + sendMessageParams.body.channel = this._slack_channel_id; + } sendMessageParams.body.username = user.getDisplaynameForRoom(message.room_id); @@ -186,7 +246,7 @@ BridgedRoom.prototype._handleSlackMessage = function(message, ghost) { var subtype = message.subtype; - if (!subtype) { + if (!subtype || subtype === "bot_message") { let text = substitutions.slackToMatrix(message.text); let markdownText = null; if (message.markdownText) { @@ -202,32 +262,45 @@ 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) { - log.warn("Ignoring non-text non-image message: " + res); - return; - } - if (message.file._content) { - // TODO: Currently Matrix lacks a way to upload a "captioned image", - // so we just send a separate `m.image` and `m.text` message - // See https://github.com/matrix-org/matrix-doc/issues/906 - - // upload to media repo; get media repo URL back - return ghost.uploadContent(message.file).then((content_uri) => { - if(undefined == content_uri) { - // no URL returned from media repo; abort - return undefined; - } - var matrixMessage = slackImageToMatrixImage(message.file, content_uri); - return ghost.sendMessage(roomID, matrixMessage); - }).finally(() => { - var text = substitutions.slackToMatrix( - message.file.initial_comment.comment + else if (message.files) { + for (var i = 0; i < message.files.length; i++) { + const file = message.files[i]; + // We also need to upload the thumbnail + let thumbnail_promise = Promise.resolve(); + // Slack ain't a believer in consistency. + const thumb_uri = file.thumb_video || file.thumb_360; + if (thumb_uri) { + thumbnail_promise = ghost.uploadContentFromURI( + { + // Yes, we hardcode jpeg. Slack always use em. + title: `${file.name}_thumb.jpeg`, + mimetype: "image/jpeg", + }, + thumb_uri, + this._slack_bot_token + ); + } + let content_uri = ""; + return ghost.uploadContentFromURI(file, file.url_private, this._slack_bot_token) + .then((file_content_uri) => { + content_uri = file_content_uri; + return thumbnail_promise; + }).then((thumb_content_uri) => { + return ghost.sendMessage( + roomID, + slackFileToMatrixMessage(file, content_uri, thumb_content_uri) ); - return ghost.sendText(roomID, text); + }).then(() => { + // TODO: Currently Matrix lacks a way to upload a "captioned image", + // so we just send a separate `m.image` and `m.text` message + // See https://github.com/matrix-org/matrix-doc/issues/906 + if (message.text) { + const text = substitutions.slackToMatrix(message.text); + return ghost.sendText(roomID, text); + } }); } } @@ -252,10 +325,9 @@ BridgedRoom.prototype.leaveGhosts = function(ghosts) { * Converts a slack image attachment to a matrix image event. * * @param {Object} file The slack image attachment file object. - * @param {string} file.url URL of the file. + * @param {?integer} file.size size of the file in bytes. * @param {string} file.title alt-text for the file. * @param {string} file.mimetype mime-type of the file. - * @param {?integer} file.size size of the file in bytes. * @param {?integer} file.original_w width of the file if an image, in pixels. * @param {?integer} file.original_h height of the file if an image, in pixels. * @param {?string} file.thumb_360 URL of a 360 pixel wide thumbnail of the @@ -264,28 +336,31 @@ BridgedRoom.prototype.leaveGhosts = function(ghosts) { * wide thumbnail of the file, if an image. * @param {?integer} file.thumb_360_h height of the thumbnail of the 36 pixel * wide thumbnail of the file, if an image. + * @param {string} url The matrix file mxc. + * @param {?string} thumbnail_url The matrix thumbnail mxc. * @return {Object} Matrix event content, as per https://matrix.org/docs/spec/#m-image */ -var slackImageToMatrixImage = function(file, url) { +const slackImageToMatrixImage = function(file, url, thumbnail_url) { var message = { msgtype: "m.image", url: url, body: file.title, info: { - mimetype: file.mimetype + mimetype: file.mimetype, + size: file.size, } }; + if (file.original_w) { message.info.w = file.original_w; } + if (file.original_h) { message.info.h = file.original_h; } - if (file.size) { - message.info.size = file.size; - } - if (false && file.thumb_360) { - message.thumbnail_url = file.thumb_360; + + if (thumbnail_url) { + message.thumbnail_url = thumbnail_url; message.thumbnail_info = {}; if (file.thumb_360_w) { message.thumbnail_info.w = file.thumb_360_w; @@ -297,6 +372,98 @@ var slackImageToMatrixImage = function(file, url) { return message; }; +/** + * Converts a slack video attachment to a matrix video event. + * + * @param {Object} file The slack video attachment file object. + * @param {?integer} file.size size of the file in bytes. + * @param {string} file.title alt-text for the file. + * @param {string} file.mimetype mime-type of the file. + * @param {?integer} file.original_w width of the file if an image, in pixels. + * @param {?integer} file.original_h height of the file if an image, in pixels. + * @param {string} url The matrix file mxc. + * @param {?string} thumbnail_url The matrix thumbnail mxc. + * @return {Object} Matrix event content, as per https://matrix.org/docs/spec/client_server/r0.4.0.html#m-video + */ +const slackImageToMatrixVideo = function(file, url, thumbnail_url) { + var message = { + msgtype: "m.video", + url: url, + body: file.title, + info: { + mimetype: file.mimetype, + size: file.size, + } + }; + + if (file.original_w) { + message.info.w = file.original_w; + } + + if (file.original_h) { + message.info.h = file.original_h; + } + + if (thumbnail_url) { + message.thumbnail_url = thumbnail_url; + // Slack don't tell us the thumbnail size for videos. Boo + } + + return message; +}; + +/** + * Converts a slack audio attachment to a matrix audio event. + * + * @param {Object} file The slack audio attachment file object. + * @param {?integer} file.size size of the file in bytes. + * @param {string} file.title alt-text for the file. + * @param {string} file.mimetype mime-type of the file. + * @param {string} url The matrix file mxc. + * @return {Object} Matrix event content, as per https://matrix.org/docs/spec/client_server/r0.4.0.html#m-audio + */ +const slackImageToMatrixAudio = function(file, url) { + return { + msgtype: "m.audio", + url: url, + body: file.title, + info: { + mimetype: file.mimetype, + size: file.size, + } + }; +}; +/** + * Converts a slack file upload to a matrix file upload event. + * + * @param {Object} file The slack file object. + * @param {string} url The matrix file mxc. + * @param {?string} thumbnail_url The matrix thumbnail mxc. + * @return {Object} Matrix event content, as per https://matrix.org/docs/spec/#m-file + */ +const slackFileToMatrixMessage = function(file, url, thumbnail_url) { + if (file.mimetype) { + if (file.mimetype.startsWith("image/")) { + return slackImageToMatrixImage(file, url, thumbnail_url); + } else if (file.mimetype.startsWith("video/")) { + return slackImageToMatrixVideo(file, url, thumbnail_url); + } else if (file.mimetype.startsWith("audio/")) { + return slackImageToMatrixAudio(file, url); + } + } + + const message = { + msgtype: "m.file", + url: url, + body: file.title, + info: { + mimetype: file.mimetype, + size: file.size, + } + }; + return message; +}; + BridgedRoom.prototype.getRemoteATime = function() { return this._slackAtime; }; @@ -305,4 +472,64 @@ BridgedRoom.prototype.getMatrixATime = function() { return this._matrixAtime; }; +BridgedRoom.prototype.refreshTeamInfo = 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.refreshUserInfo = 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) => { + log.debug("auth.test res:", 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 41843417..146f48d8 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -14,11 +14,13 @@ const OAuth2 = require("./OAuth2"); const Provisioning = require("./Provisioning"); const randomstring = require("randomstring"); const log = require("matrix-appservice-bridge").Logging.get("Main"); +const rp = require('request-promise'); function Main(config) { var self = this; this._config = config; + this._teams = new Map(); //team_id => team. if (config.oauth2) { this._oauth2 = new OAuth2({ @@ -37,6 +39,7 @@ function Main(config) { this._rooms = []; this._roomsBySlackChannelId = {}; + this._roomsBySlackTeamId = {}; this._roomsByMatrixRoomId = {}; this._roomsByInboundId = {}; @@ -188,40 +191,87 @@ Main.prototype.getBotIntent = function() { return this._bridge.getIntent(); }; +Main.prototype.getTeamDomainForMessage = function(message) { + if (message.team_domain) { + return Promise.resolve(message.team_domain); + } + + if (!message.team_id) { + return Promise.reject("Cannot determine team, no id given."); + } + + if (this._teams.has(message.team_id)) { + return Promise.resolve(this._teams.get(message.team_id).domain); + } + + const room = this.getRoomBySlackChannelId(message.channel); + + var channelsInfoApiParams = { + uri: 'https://slack.com/api/team.info', + qs: { + token: room.getAccessToken() + }, + json: true + }; + this.incRemoteCallCounter("team.info"); + return rp(channelsInfoApiParams).then((response) => { + if (!response.ok) { + log.error(`Trying to fetch the ${message.team_id} team.`, response); + return Promise.resolve(); + } + log.info("Got new team:", response); + this._teams.set(message.team_id, response.team); + return response.team.domain; + }); +} + +Main.prototype.getUserId = function(id, team_domain) { + return [ + "@", this._config.username_prefix, team_domain.toLowerCase(), + "_", id.toUpperCase(), ":", this._config.homeserver.server_name + ].join(""); + +} + // Returns a Promise of a SlackGhost 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(), - "_", message.user_id.toUpperCase(), ":", this._config.homeserver.server_name - ].join(""); - - if (this._ghostsByUserId[user_id]) { - return Promise.resolve(this._ghostsByUserId[user_id]); - } - var intent = this._bridge.getIntent(user_id); + // team_domain is gone, so we have to actually get the domain from a friendly object. + return this.getTeamDomainForMessage(message).then((team_domain) => { + const user_id = this.getUserId( + message.user_id.toUpperCase(), + team_domain.toLowerCase() + ); - var store = this.getUserStore(); - return store.select({id: user_id}).then((entries) => { - var ghost; - if (entries.length) { - ghost = SlackGhost.fromEntry(this, entries[0], intent); + if (this._ghostsByUserId[user_id]) { + return Promise.resolve(this._ghostsByUserId[user_id]); } - else { - ghost = new SlackGhost({ - main: this, - user_id: user_id, - intent: intent, - }); - this.putUserToStore(ghost); - } + const intent = this._bridge.getIntent(user_id); + const store = this.getUserStore(); - this._ghostsByUserId[user_id] = ghost; - return ghost; + return store.select({id: user_id}).then((entries) => { + var ghost; + if (entries.length) { + ghost = SlackGhost.fromEntry(this, entries[0], intent); + } + else { + ghost = new SlackGhost({ + main: this, + + user_id: user_id, + intent: intent, + }); + this.putUserToStore(ghost); + } + + this._ghostsByUserId[user_id] = ghost; + return ghost; + }); }); }; @@ -257,6 +307,17 @@ 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) { @@ -273,6 +334,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++) { @@ -396,6 +461,8 @@ Main.prototype.onMatrixEvent = function(ev) { var membership = ev.content.membership; if (membership === "invite") { // Automatically accept all invitations + // NOTE: This can race and fail if the invite goes down the AS stream + // before the homeserver believes we can actually join the room. this.getBotIntent().join(ev.room_id); } @@ -513,6 +580,7 @@ Main.prototype.actionLink = function(opts) { var room = this.getRoomByMatrixRoomId(matrix_room_id); + var isNew = false; if (!room) { var inbound_id = this.genInboundId(); @@ -520,7 +588,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); } @@ -529,6 +597,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.refreshTeamInfo(), room.refreshUserInfo() ]) + .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..214363b6 --- /dev/null +++ b/lib/SlackEventHandler.js @@ -0,0 +1,216 @@ +"use strict"; + +const BaseSlackHandler = require('./BaseSlackHandler'); +const Promise = require('bluebird'); +const util = require("util"); +const log = require("matrix-appservice-bridge").Logging.get("SlackEventHandler"); + +const UnknownEvent = function () { +}; +const 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 { + log.debug("Received slack event:", 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) { + log.warn("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"}); + } + log.error("Failed to handle slack event: ", e); + }); + } catch (e) { + log.error("SlackEventHandler.handle 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(), + team_id: params.team_id, + channel_id: params.event.channel + }); + + var processMsg = msg.text || msg.subtype === 'message_deleted' || msg.files != undefined; + + 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. + log.warn("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); + log.warn("Did not understand event: ", JSON.stringify(message)); + 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(); + } + + msg.markdownText = msg.text; + + 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 b3ea3662..27227689 100644 --- a/lib/SlackGhost.js +++ b/lib/SlackGhost.js @@ -1,11 +1,14 @@ "use strict"; +var url = require('url'); +var https = require('https'); var rp = require('request-promise'); var slackdown = require('Slackdown'); +const substitutions = require("./substitutions"); const log = require("matrix-appservice-bridge").Logging.get("SlackGhost"); -// How long in msec to cache avatar URL lookups from slack -var AVATAR_CACHE_TIMEOUT = 10 * 60 * 1000; // 10 minutes +// How long in milliseconds to cache user info lookups. +var USER_CACHE_TIMEOUT = 10 * 60 * 1000; // 10 minutes function SlackGhost(opts) { this._main = opts.main; @@ -46,6 +49,7 @@ SlackGhost.prototype.getIntent = function() { }; SlackGhost.prototype.update = function(message, room) { + log.info("Updating user information for " + message.user_id); return Promise.all([ this.updateDisplayname(message, room).catch((e) => { log.error("Failed to update ghost displayname:", e); @@ -58,20 +62,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,20 +114,19 @@ 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; + if (!response.user || !response.user.profile) { + log.error("Failed to get user profile", response); + return; + }; - 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) { @@ -144,6 +178,24 @@ SlackGhost.prototype.sendMessage = function(room_id, msg) { }); }; +SlackGhost.prototype.uploadContentFromURI = function(file, uri, token) { + return rp({ + uri: uri, + headers: { + Authorization: `Bearer ${token}`, + }, + encoding: null, // Because we expect a binary + }).then((buffer) => { + file._content = buffer; + return this.uploadContent(file); + }).then((contentUri) => { + return contentUri; + }).catch((reason) => { + log.error("Failed to upload content:\n%s", reason); + throw reason; + }); +}; + SlackGhost.prototype.uploadContent = function(file) { return this.getIntent().getClient().uploadContent({ stream: new Buffer(file._content, "binary"), diff --git a/lib/SlackHookHandler.js b/lib/SlackHookHandler.js index 1b86f721..09313a7a 100644 --- a/lib/SlackHookHandler.js +++ b/lib/SlackHookHandler.js @@ -1,13 +1,17 @@ "use strict"; -var substitutions = require("./substitutions"); -var rp = require('request-promise'); -var qs = require("querystring"); -var Promise = require('bluebird'); -var promiseWhile = require("./promiseWhile"); +const substitutions = require("./substitutions"); +const SlackEventHandler = require('./SlackEventHandler'); +const BaseSlackHandler = require('./BaseSlackHandler'); +const rp = require('request-promise'); +const qs = require("querystring"); +const Promise = require('bluebird'); +const promiseWhile = require("./promiseWhile"); +const util = require("util"); +const fs = require("fs"); const log = require("matrix-appservice-bridge").Logging.get("SlackHookHandler"); -var PRESERVE_KEYS = [ +const PRESERVE_KEYS = [ "team_domain", "team_id", "channel_name", "channel_id", "user_name", "user_id", @@ -20,8 +24,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. @@ -31,10 +38,9 @@ function SlackHookHandler(main) { * ready to accept requests */ SlackHookHandler.prototype.startAndListen = function(port, tls_config) { - var createServer; + let createServer; if (tls_config) { - var fs = require("fs"); - var tls_options = { + const tls_options = { key: fs.readFileSync(tls_config.key_file), cert: fs.readFileSync(tls_config.crt_file) }; @@ -47,35 +53,44 @@ SlackHookHandler.prototype.startAndListen = function(port, tls_config) { } return new Promise((resolve, reject) => { - createServer((request, response) => { - var body = ""; - request.on("data", (chunk) => { - body += chunk; - }); - - request.on("end", () => { - var params = qs.parse(body); - try { - this.handle(request.method, request.url, params, response); - } - catch (e) { - log.error("Oops - SlackHookHandler failed:", e); - - response.writeHead(500, {"Content-Type": "text/plain"}); - if (request.method !== "HEAD") { - response.write("Internal Server Error"); - } - response.end(); - } - }); - }).listen(port, () => { - var protocol = tls_config ? "https" : "http"; + createServer(this.onRequest.bind(this)).listen(port, () => { + const protocol = tls_config ? "https" : "http"; log.info("Slack-side listening on port " + port + " over " + protocol); resolve(); }); }); }; +SlackHookHandler.prototype.onRequest = function(request, response) { + var body = ""; + request.on("data", (chunk) => { + body += chunk; + }); + + request.on("end", () => { + // if isEvent === true, this was an event emitted from the slack Event API + // https://api.slack.com/events-api + const isEvent = request.headers['content-type'] === 'application/json' && request.method === 'POST'; + try { + 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) { + log.error("SlackHookHandler failed:", e); + response.writeHead(500, {"Content-Type": "text/plain"}); + if (request.method !== "HEAD") { + response.write("Internal Server Error"); + } + response.end(); + } + }); +} /** * Handles a slack webhook request. * @@ -272,120 +287,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) { - log.info("channels.info: " + id + " mapped to " + response.channel.name); - message.text = message.text.replace(/<#(\w+)\|?\w*?>/, "#" + response.channel.name); - } - else { - log.info("channels.info returned no result for " + id); - } - iteration++; - }).catch((err) => { - log.error("Caught error while trying to get channels.info" + 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) => { - const user_id = main.getUserId(id, message.team_domain); - if (response && response.user && response.user.name) { - log.info("users.info: " + id + " mapped to " + response.user.name); - const pill = `[${response.user.name}](https://matrix.to/#/${user_id})`; - message.text = message.text.replace(/<@(\w+)\|?\w*?>/, response.user.name); - message.markdownText = message.markdownText.replace( - /<@(\w+)\|?\w*?>/, - pill - ); - return; - } - log.warn(`users.info returned no result for ${id} Response:`, response); - // Fallback to checking the user store. - var store = this.getUserStore(); - return store.select({id: user_id}); - }).then((result) => { - if (result === undefined) { - return; - } - log.debug(`${user_id} did ${result.length > 0 ? "not" : ""} an entry`); - if (result.length) { - // It's possible not to have a displayname set. - const name = result[0].display_name || result[0].id; - - const pill = `[${name}](https://matrix.to/#/${user_id})`; - message.text = message.text.replace(/<@(\w+)\|?\w*?>/, name); - message.markdownText = message.markdownText.replace( - /<@(\w+)\|?\w*?>/, - pill - ); - } - }).catch((err) => { - log.error("Caught error while trying to get users.info " + err); - }); - iteration++; - }).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. * @@ -434,63 +335,19 @@ SlackHookHandler.prototype.lookupMessage = function(channelID, timestamp, token) var message = response.messages[0]; log.debug("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) { - log.warn("Could not find sharedPublichURL: " + JSON.stringify(response)); - return undefined; - } - - 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]; + if (messages.subtype !== "file_share") { + return message; } - - return rp({ - uri: public_file_url, - resolveWithFullResponse: true, - encoding: null + return this.enablePublicSharing(message.file, token) + .then((file) => { + message.file = file; + return this.fetchFileContent(message.file, token); + }).then((content) => { + message.file._content = content; + return message; + }).catch((err) => { + log.error("Failed to get file content: ", err); }); - }).then((response) => { - var content = response.body; - log.debug("Successfully fetched file " + file.id + - " content (" + content.length + " bytes)"); - return content; }); } diff --git a/lib/substitutions.js b/lib/substitutions.js index c9deb098..76504bc9 100644 --- a/lib/substitutions.js +++ b/lib/substitutions.js @@ -52,8 +52,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) { log.info("running substitutions on: " + body); if (undefined != body) { @@ -77,6 +78,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) @@ -93,6 +100,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. @@ -124,6 +139,9 @@ var matrixToSlack = function(event, main) { // convert @room to @channel string = string.replace("@room", "@channel"); + // Strip out any language specifier on the code tags, as they are not supported by slack. + string = string.replace(/```[\w*]+\n/g, "```\n"); + // the link_names flag means that writing @username will act as a mention in slack var ret = { username: event.user_id, @@ -145,7 +163,15 @@ var matrixToSlack = function(event, main) { return ret; }; +var htmlEscape = function(s) { + return s.replace(/&/g, "&") + .replace(//g, ">"); +}; + module.exports = { matrixToSlack: matrixToSlack, - slackToMatrix: slackToMatrix + slackToMatrix: slackToMatrix, + getSlackFileUrl: getSlackFileUrl, + htmlEscape: htmlEscape };