From c5094d1a15a67cdc941cf21dcc5916cb97fefd77 Mon Sep 17 00:00:00 2001 From: perissology Date: Fri, 12 Jan 2018 14:51:54 -0800 Subject: [PATCH 01/10] add support for send msgs as bot user --- lib/AdminCommands.js | 11 +++++++++-- lib/BridgedRoom.js | 35 +++++++++++++++++++++++++++-------- lib/Main.js | 12 ++++++++++++ 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/lib/AdminCommands.js b/lib/AdminCommands.js index bd355c16..cf9bd0b4 100644 --- a/lib/AdminCommands.js +++ b/lib/AdminCommands.js @@ -131,9 +131,13 @@ 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_token: { + description: "Slack bot user token. Used with slack bot user & Events api", + aliases: ['t'], + }, }, func: function(main, opts, args, respond) { return main.actionLink({ @@ -141,10 +145,13 @@ 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_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/BridgedRoom.js b/lib/BridgedRoom.js index 6420edda..56cbd644 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -20,6 +20,7 @@ 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._access_token = opts.access_token; this._access_scopes = opts.access_scopes; @@ -30,13 +31,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,7 +66,7 @@ BridgedRoom.prototype.getSlackWebhookUri = function() { }; BridgedRoom.prototype.getAccessToken = function() { - return this._access_token; + return this._access_token || this._slack_bot_token; }; BridgedRoom.prototype.getMatrixRoomId = function() { @@ -92,7 +93,13 @@ 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.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 +115,7 @@ 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, access_token: entry.remote.access_token, access_scopes: entry.remote.access_scopes, }; @@ -123,6 +131,7 @@ 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, access_token: this._access_token, access_scopes: this._access_scopes, }, @@ -137,18 +146,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 +181,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); } }); }); diff --git a/lib/Main.js b/lib/Main.js index 009eae59..68ba609c 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -521,6 +521,18 @@ Main.prototype.actionLink = function(opts) { room.updateSlackWebhookUri(opts.slack_webhook_uri); } + if (opts.slack_bot_token) { + room.updateSlackBotToken(opts.slack_bot_token); + } + + if (opts.slack_channel_id) { + room.updateSlackChannelId(opts.slack_channel_id); + } + + // if (opts.slack_channel_name) { + // room.updateSlackChannelName(opts.slack_channel_name); + // } + if (room.isDirty()) { this.putRoomToStore(room); } From 2139a7c21a36da0f555cca1e1eada4367d8ad454 Mon Sep 17 00:00:00 2001 From: perissology Date: Sat, 13 Jan 2018 09:06:34 -0800 Subject: [PATCH 02/10] got basic slack event bridging --- README.md | 31 +++ lib/BridgedRoom.js | 94 +++++++- lib/Main.js | 29 ++- lib/SlackEventHandler.js | 447 +++++++++++++++++++++++++++++++++++++++ lib/SlackGhost.js | 66 ++++-- lib/SlackHookHandler.js | 12 +- 6 files changed, 655 insertions(+), 24 deletions(-) create mode 100644 lib/SlackEventHandler.js diff --git a/README.md b/README.md index fa484849..91a1afe0 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,37 @@ The bridge itself should now be running. To actually use it, you will need to configure some linked channels. +Slack Bot Setup +--------------- + +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 + +5. Click on `Install App` and `Install App to Workspace`. Note the `Bot User OAuth Access Token` + as you will need it whenever you link a room. + +6. You will need to invite the bot user you created above to each slack channel you would like + to bridge. + + ``` + /invite @bot-user-name + + ``` + Provisioning ------------ diff --git a/lib/BridgedRoom.js b/lib/BridgedRoom.js index 56cbd644..d038e98a 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -21,6 +21,10 @@ function BridgedRoom(main, opts) { 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_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; @@ -73,6 +77,18 @@ 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.updateInboundId = function(inbound_id) { if (this._inbound_id !== inbound_id) this._dirty = true; this._inbound_id = inbound_id; @@ -98,6 +114,11 @@ BridgedRoom.prototype.updateSlackBotToken = function(slack_bot_token) { this._slack_bot_token = slack_bot_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 && @@ -116,6 +137,10 @@ BridgedRoom.fromEntry = function(main, entry) { slack_channel_name: entry.remote.name, slack_webhook_uri: entry.remote.webhook_uri, slack_bot_token: entry.remote.slack_bot_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, }; @@ -132,6 +157,10 @@ BridgedRoom.prototype.toEntry = function() { name: this._slack_channel_name, webhook_uri: this._slack_webhook_uri, slack_bot_token: this._slack_bot_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, }, @@ -146,7 +175,7 @@ BridgedRoom.prototype.toEntry = function() { }; BridgedRoom.prototype.onMatrixMessage = function(message) { - if (!this._slack_webhook_uri || !this._slack_bot_token) 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); @@ -319,4 +348,67 @@ 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; + } + + return; + }); +}; + +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) => { + 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 Promise.resolve(); + + if (!this._slack_bot_id !== response.user.profile.bot_id) { + this._slack_bot_id = response.user.profile.bot_id; + this._dirty = true; + } + + return; + }); +}; + module.exports = BridgedRoom; diff --git a/lib/Main.js b/lib/Main.js index 68ba609c..cc5c9568 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 it 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++) { @@ -505,6 +521,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 +529,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); } @@ -523,6 +540,14 @@ Main.prototype.actionLink = function(opts) { if (opts.slack_bot_token) { room.updateSlackBotToken(opts.slack_bot_token); + Promise.all([ room.lookupAndSetTeamInfo(), room.lookupAndSetUserInfo() ]) + .then(() => { + this.addBridgedRoom(room); + + if (room.isDirty()) { + this.putRoomToStore(room); + } + }); } if (opts.slack_channel_id) { @@ -533,6 +558,8 @@ Main.prototype.actionLink = function(opts) { // room.updateSlackChannelName(opts.slack_channel_name); // } + 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..c5403462 --- /dev/null +++ b/lib/SlackEventHandler.js @@ -0,0 +1,447 @@ +"use strict"; + +var substitutions = require("./substitutions"); +var rp = require('request-promise'); +var qs = require("querystring"); +var Promise = require('bluebird'); +var promiseWhile = require("./promiseWhile"); + +var PRESERVE_KEYS = [ + "team_domain", "team_id", + "channel_name", "channel_id", + "user_name", "user_id", +]; + +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; +} + + +/** + * 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. + // * @param {string} timestamp Timestamp when message was received, in seconds + // * formatted as a float. + */ +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 room = main.getRoomBySlackChannelId(params.event.channel); + + 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); + } + ); + + response.writeHead(200, {"Content-Type": "application/json"}); + response.end(); + } 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(); + } +}; + +SlackEventHandler.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; + }); +}; + +SlackEventHandler.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. + * + * The webhook request that we receive doesn't have enough information to richly + * represent the message in Matrix, so we look up more details. + * + * @param {string} channelID Slack channel ID. + * @param {string} timestamp Timestamp when message was received, in seconds + * formatted as a float. + * @param {Intent} intent Intent for sending messages as the relevant user. + * @param {string} roomID Matrix room ID associated with channelID. + */ +//SlackEventHandler.prototype.lookupAndSendMessage = +SlackEventHandler.prototype.lookupMessage = function(channelID, timestamp, token) { + // Look up all messages at the exact timestamp we received. + // This has microsecond granularity, so should return the message we want. + var params = { + method: 'POST', + form : { + channel: channelID, + latest: timestamp, + oldest: timestamp, + inclusive: "1", + token: token, + }, + uri: "https://slack.com/api/channels.history", + json: true + }; + this._main.incRemoteCallCounter("channels.history"); + return rp(params).then((response) => { + if (!response || !response.messages || response.messages.length === 0) { + console.log("Could not find history: " + response); + return undefined; + } + if (response.messages.length != 1) { + // Just laziness. + // If we get unlucky and two messages were sent at exactly the + // same microsecond, we could parse them all, filter by user, + // filter by whether they have attachments, and such, and pick + // the right message. But this is unlikely, and I'm lazy, so + // we'll just drop the message... + console.log("Really unlucky, got multiple messages at same" + + " microsecond, dropping:" + response); + return undefined; + } + 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 + */ +SlackEventHandler.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; + } + + 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 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; + }); +} + + +/** + * + * @param room + * @param params + */ +SlackEventHandler.prototype.handleEvent = function(room, params) { + var main = this._main; + + // TODO set bot_user_id on room on link. authed_user isn't the correct check + // TODO(paul): This will reject every bot-posted message, both our own + // reflections and other messages from other bot integrations. It would + // be nice if we could distinguish the two by somehow learning our own + // 'bot_id' parameter. + // https://github.com/matrix-org/matrix-appservice-slack/issues/29 + // if (params.user_id === "USLACKBOT") { + if (params.event.user in params.authed_user) { + return Promise.resolve(); + } + + // Only count received messages that aren't self-reflections + main.incCounter("received_messages", {side: "remote"}); + + +}; + +/** + * 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.user_name Slack user name of the 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' && 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 text = params.event.text; + 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()); + + if (text) { + return room.onSlackMessage({ + text, + user_id: params.event.user, + team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() + }); + } + return Promise.resolve(); + } + + if (undefined == text) { + // 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); + return Promise.resolve(); + } + + // TODO update this. I think it only involves file_share messages + // return this.lookupMessage(params.event.channel, params.event.ts, token).then((msg) => { + // console.log('msg ->', msg); + // if(undefined == msg) { + // msg = params; + // } + + // Restore the original parameters, because we've forgotten a lot of + // them by now + // "team_domain", "team_id", + // "channel_name", "channel_id", + // "user_name", "user_id", + // PRESERVE_KEYS.forEach((k) => msg[k] = params[k]); + + var msg = Object.assign({}, params.event, { + user_id: params.event.user, + team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() + }); + console.log(msg); + return room.onSlackMessage(msg); + // return this.replaceChannelIdsWithNames(msg, token); + // }).then((msg) => { + // return this.replaceUserIdsWithNames(msg, token); + // }).then((msg) => { + // we can't use .finally here as it does not get the final value, see https://github.com/kriskowal/q/issues/589 + // return room.onSlackMessage(msg); + // }); +}; + +module.exports = SlackEventHandler; diff --git a/lib/SlackGhost.js b/lib/SlackGhost.js index f053b700..661b6af5 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,50 @@ 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 = (!display_name) ? + this.lookupUserInfo(message.user_id, room.getAccessToken()) + .then(user => { + if (user && user.profile) { + return user.profile.display_name || user.profile.real_name; + } + return undefined; + }) : + Promise.resolve(display_name); + + return getDisplayName.then(display_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); + }); + }) }; 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 +109,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..c5cc7a83 100644 --- a/lib/SlackHookHandler.js +++ b/lib/SlackHookHandler.js @@ -1,6 +1,7 @@ "use strict"; var substitutions = require("./substitutions"); +var SlackEventHandler = require('./SlackEventHandler'); var rp = require('request-promise'); var qs = require("querystring"); var Promise = require('bluebird'); @@ -19,6 +20,7 @@ var PRESERVE_KEYS = [ */ function SlackHookHandler(main) { this._main = main; + this.eventHandler = new SlackEventHandler(main); } /** @@ -53,9 +55,15 @@ SlackHookHandler.prototype.startAndListen = function(port, tls_config) { }); request.on("end", () => { - var params = qs.parse(body); + 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); From 579776198ed2e17c9c8818632802dd127184de4c Mon Sep 17 00:00:00 2001 From: perissology Date: Sat, 13 Jan 2018 09:17:34 -0800 Subject: [PATCH 03/10] update readme --- README.md | 67 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 91a1afe0..96c5c004 100644 --- a/README.md +++ b/README.md @@ -90,8 +90,21 @@ The bridge itself should now be running. To actually use it, you will need to configure some linked channels. -Slack Bot Setup ---------------- +Provisioning +------------ + +This bridge allows linking together pairs of Matrix rooms and Slack channels, +relaying messages said by people in one side into the other. To create a link +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`. @@ -113,23 +126,39 @@ Slack Bot Setup 5. Click on `Install App` and `Install App to Workspace`. Note the `Bot User OAuth Access Token` as you will need it whenever you link a room. -6. You will need to invite the bot user you created above to each slack channel you would like - to bridge. - - ``` - /invite @bot-user-name +6. For each channel you would like to bridge, perform the following steps: - ``` - - -Provisioning ------------- + 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 -This bridge allows linking together pairs of Matrix rooms and Slack channels, -relaying messages said by people in one side into the other. To create a link -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. + ``` + + You will also need to determine the "channel ID" that Slack uses to identify + the channel. Unfortunately, it is not easily obtained from the Slack UI. The + easiest way to do this is to send a message from Slack to the bridge; the + bridge will log the channel ID as part of the unrecognised message output. + You can then take note of the `channel_id` field. + + 2. Issue a ``link`` command in the administration control room with these + collected values as arguments: + + ``` + link --channel_id CHANNELID --room !the-matrix:room.id --slack_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`. @@ -154,13 +183,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 :) From 86353f213fa190b504e9884a1c05760fc6084f73 Mon Sep 17 00:00:00 2001 From: perissology Date: Sat, 13 Jan 2018 15:37:16 -0800 Subject: [PATCH 04/10] better file support & fix room registration bug - abstracted out a BaseSlackHandler class which the SlackEventHandler & SlackHookHandler extend. - fix bug with room linking - enable slack msg transformations in SlackEventHandler - provide better file bridging support from slack -> matrix --- README.md | 39 +++-- lib/AdminCommands.js | 8 +- lib/BaseSlackHandler.js | 172 ++++++++++++++++++++++ lib/BridgedRoom.js | 60 +++++++- lib/Main.js | 24 +-- lib/SlackEventHandler.js | 309 ++++++--------------------------------- lib/SlackHookHandler.js | 164 +++------------------ lib/substitutions.js | 20 ++- 8 files changed, 354 insertions(+), 442 deletions(-) create mode 100644 lib/BaseSlackHandler.js diff --git a/README.md b/README.md index 96c5c004..46bd8890 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,23 @@ and bot users. This allows you to link as many channels as you would like with o - team_domain_change - message.channels + - message.groups (if you want to bridge private channels) -5. Click on `Install App` and `Install App to Workspace`. Note the `Bot User OAuth Access Token` - as you will need it whenever you link a room. +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: media uploaded to matrix is currently not permissioned, and anyone with the link + can access the file. In order to make slack files visible to matrix users, this bridge + will set make the slack file visible to anyone with the url (including files in private channels). + The current behavior in slack is that files are only accessible to authenticated users. + +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. -6. For each channel you would like to bridge, perform the following steps: +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`. @@ -147,15 +159,22 @@ and bot users. This allows you to link as many channels as you would like with o 2. Issue a ``link`` command in the administration control room with these collected values as arguments: - ``` - link --channel_id CHANNELID --room !the-matrix:room.id --slack_token xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx - ``` + 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: + These arguments can be shortened to single-letter forms: - ``` - link -I CHANNELID -R !the-matrix:room.id -t xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx - ``` + ``` + link -I CHANNELID -R !the-matrix:room.id -t xoxb-xxxxxxxxxx-xxxxxxxxxxxxxxxxxxxx + ``` ### Legacy diff --git a/lib/AdminCommands.js b/lib/AdminCommands.js index cf9bd0b4..ed0888d1 100644 --- a/lib/AdminCommands.js +++ b/lib/AdminCommands.js @@ -134,10 +134,13 @@ adminCommands.link = new AdminCommand({ description: "Slack webhook URL. Used with slack outgoing hooks integration", aliases: ['u'], }, - slack_token: { + 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({ @@ -145,7 +148,8 @@ 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_token, + slack_bot_token: opts.slack_bot_token, + slack_user_token: opts.slack_user_token, }).then( (room) => { respond("Room is now " + room.getStatus()); 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 d038e98a..30c81c63 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -21,6 +21,7 @@ function BridgedRoom(main, opts) { 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; @@ -89,6 +90,10 @@ 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; @@ -114,6 +119,11 @@ BridgedRoom.prototype.updateSlackBotToken = function(slack_bot_token) { 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; @@ -137,6 +147,7 @@ BridgedRoom.fromEntry = function(main, entry) { 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, @@ -157,6 +168,7 @@ BridgedRoom.prototype.toEntry = function() { 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, @@ -233,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); } @@ -245,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) { @@ -264,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 { @@ -340,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; }; @@ -384,6 +427,7 @@ BridgedRoom.prototype.lookupAndSetUserInfo = function() { }, json: true, }).then((response) => { + console.log(response); if (!response.user_id) return; if (!this._slack_user_id !== response.user_id) { diff --git a/lib/Main.js b/lib/Main.js index cc5c9568..6f0fa804 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -538,26 +538,30 @@ 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); - Promise.all([ room.lookupAndSetTeamInfo(), room.lookupAndSetUserInfo() ]) + return Promise.all([ room.lookupAndSetTeamInfo(), room.lookupAndSetUserInfo() ]) .then(() => { - this.addBridgedRoom(room); + if (isNew) { + this.addBridgedRoom(room); + } if (room.isDirty()) { this.putRoomToStore(room); } - }); - } - if (opts.slack_channel_id) { - room.updateSlackChannelId(opts.slack_channel_id); + return room; + }); } - // if (opts.slack_channel_name) { - // room.updateSlackChannelName(opts.slack_channel_name); - // } - if (isNew) this.addBridgedRoom(room); if (room.isDirty()) { diff --git a/lib/SlackEventHandler.js b/lib/SlackEventHandler.js index c5403462..6a3346ac 100644 --- a/lib/SlackEventHandler.js +++ b/lib/SlackEventHandler.js @@ -1,19 +1,14 @@ "use strict"; -var substitutions = require("./substitutions"); -var rp = require('request-promise'); -var qs = require("querystring"); +var BaseSlackHandler = require('./BaseSlackHandler'); var Promise = require('bluebird'); -var promiseWhile = require("./promiseWhile"); +var util = require("util"); -var PRESERVE_KEYS = [ - "team_domain", "team_id", - "channel_name", "channel_id", - "user_name", "user_id", -]; - -var UnknownEvent = function() {}; -var UnknownChannel = function(channel) { this.channel = channel; }; +var UnknownEvent = function () { +}; +var UnknownChannel = function (channel) { + this.channel = channel; +}; /** * @constructor @@ -24,6 +19,7 @@ function SlackEventHandler(main) { this._main = main; } +util.inherits(SlackEventHandler, BaseSlackHandler); /** * Handles a slack event request. @@ -37,7 +33,7 @@ function SlackEventHandler(main) { // * @param {string} timestamp Timestamp when message was received, in seconds // * formatted as a float. */ -SlackEventHandler.prototype.handle = function(params, response) { +SlackEventHandler.prototype.handle = function (params, response) { try { console.log("Received slack event:", JSON.stringify(params)); @@ -104,230 +100,6 @@ SlackEventHandler.prototype.handle = function(params, response) { } }; -SlackEventHandler.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; - }); -}; - -SlackEventHandler.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. - * - * The webhook request that we receive doesn't have enough information to richly - * represent the message in Matrix, so we look up more details. - * - * @param {string} channelID Slack channel ID. - * @param {string} timestamp Timestamp when message was received, in seconds - * formatted as a float. - * @param {Intent} intent Intent for sending messages as the relevant user. - * @param {string} roomID Matrix room ID associated with channelID. - */ -//SlackEventHandler.prototype.lookupAndSendMessage = -SlackEventHandler.prototype.lookupMessage = function(channelID, timestamp, token) { - // Look up all messages at the exact timestamp we received. - // This has microsecond granularity, so should return the message we want. - var params = { - method: 'POST', - form : { - channel: channelID, - latest: timestamp, - oldest: timestamp, - inclusive: "1", - token: token, - }, - uri: "https://slack.com/api/channels.history", - json: true - }; - this._main.incRemoteCallCounter("channels.history"); - return rp(params).then((response) => { - if (!response || !response.messages || response.messages.length === 0) { - console.log("Could not find history: " + response); - return undefined; - } - if (response.messages.length != 1) { - // Just laziness. - // If we get unlucky and two messages were sent at exactly the - // same microsecond, we could parse them all, filter by user, - // filter by whether they have attachments, and such, and pick - // the right message. But this is unlikely, and I'm lazy, so - // we'll just drop the message... - console.log("Really unlucky, got multiple messages at same" + - " microsecond, dropping:" + response); - return undefined; - } - 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 - */ -SlackEventHandler.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; - } - - 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 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; - }); -} - - -/** - * - * @param room - * @param params - */ -SlackEventHandler.prototype.handleEvent = function(room, params) { - var main = this._main; - - // TODO set bot_user_id on room on link. authed_user isn't the correct check - // TODO(paul): This will reject every bot-posted message, both our own - // reflections and other messages from other bot integrations. It would - // be nice if we could distinguish the two by somehow learning our own - // 'bot_id' parameter. - // https://github.com/matrix-org/matrix-appservice-slack/issues/29 - // if (params.user_id === "USLACKBOT") { - if (params.event.user in params.authed_user) { - return Promise.resolve(); - } - - // Only count received messages that aren't self-reflections - main.incCounter("received_messages", {side: "remote"}); - - -}; - /** * Attempts to handle the `team_domain_change` event. * @@ -335,7 +107,7 @@ SlackEventHandler.prototype.handleEvent = function(room, params) { * @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) { +SlackEventHandler.prototype.handleDomainChangeEvent = function (params) { this._main.getRoomsBySlackTeamId(params.team_id).forEach(room => { room.updateSlackTeamDomain(params.event.domain); if (room.isDirty()) { @@ -352,7 +124,7 @@ SlackEventHandler.prototype.handleDomainChangeEvent = function(params) { * @param {string} params.event.id The slack channel id * @param {string} params.event.name The new name */ -SlackEventHandler.prototype.handleChannelRenameEvent = function(params) { +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); @@ -378,7 +150,7 @@ SlackEventHandler.prototype.handleChannelRenameEvent = function(params) { * @param {string} params.event.channel The slack channel id * @param {string} params.event.ts The unique (per-channel) timestamp */ -SlackEventHandler.prototype.handleMessageEvent = function(params) { +SlackEventHandler.prototype.handleMessageEvent = function (params) { var room = this._main.getRoomBySlackChannelId(params.event.channel); if (!room) throw new UnknownChannel(params.event.channel); @@ -412,36 +184,43 @@ SlackEventHandler.prototype.handleMessageEvent = function(params) { // 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(); } - // TODO update this. I think it only involves file_share messages - // return this.lookupMessage(params.event.channel, params.event.ts, token).then((msg) => { - // console.log('msg ->', msg); - // if(undefined == msg) { - // msg = params; - // } + var result; + if (params.event.subtype === "file_share") { + // 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(params.event.file, room.getSlackUserToken()) + .then((file) => { + params.event.file = file; + + return this.fetchFileContent(params.event.file, token) + .then((content) => { + params.event.file._content = content; + }); + }) + } + } else { + result = Promise.resolve(); + } - // Restore the original parameters, because we've forgotten a lot of - // them by now - // "team_domain", "team_id", - // "channel_name", "channel_id", - // "user_name", "user_id", - // PRESERVE_KEYS.forEach((k) => msg[k] = params[k]); + return result.then(() => { + var msg = Object.assign({}, params.event, { + user_id: params.event.user || params.event.bot_id, + team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() + }); - var msg = Object.assign({}, params.event, { - user_id: params.event.user, - team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() - }); - console.log(msg); - return room.onSlackMessage(msg); - // return this.replaceChannelIdsWithNames(msg, token); - // }).then((msg) => { - // return this.replaceUserIdsWithNames(msg, token); - // }).then((msg) => { - // we can't use .finally here as it does not get the final value, see https://github.com/kriskowal/q/issues/589 - // return room.onSlackMessage(msg); - // }); + if (msg.subtype === 'file_comment') { + msg.user_id = msg.comment.user; + } + + return 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/SlackHookHandler.js b/lib/SlackHookHandler.js index c5cc7a83..587afeaf 100644 --- a/lib/SlackHookHandler.js +++ b/lib/SlackHookHandler.js @@ -2,10 +2,12 @@ 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", @@ -23,6 +25,8 @@ function SlackHookHandler(main) { this.eventHandler = new SlackEventHandler(main); } +util.inherits(SlackHookHandler, BaseSlackHandler); + /** * Starts the hook server listening on the given port and (optional) TLS * configuration. @@ -279,96 +283,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. * @@ -417,63 +331,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 }; From a241862ed24d3b732641a955d6da0a89a87d90a0 Mon Sep 17 00:00:00 2001 From: perissology Date: Sat, 13 Jan 2018 16:09:11 -0800 Subject: [PATCH 05/10] fix bug when failed to enablePublicSharing --- lib/SlackEventHandler.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/SlackEventHandler.js b/lib/SlackEventHandler.js index 6a3346ac..655e2c27 100644 --- a/lib/SlackEventHandler.js +++ b/lib/SlackEventHandler.js @@ -189,13 +189,15 @@ SlackEventHandler.prototype.handleMessageEvent = function (params) { } var result; - if (params.event.subtype === "file_share") { + if (params.event.subtype === "file_share" && params.event.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(params.event.file, room.getSlackUserToken()) .then((file) => { - params.event.file = file; + if (file) { + params.event.file = file; + } return this.fetchFileContent(params.event.file, token) .then((content) => { From d050846a8ea5676555b2c0b03cc98b2a7df8ad04 Mon Sep 17 00:00:00 2001 From: perissology Date: Mon, 15 Jan 2018 11:54:26 -0800 Subject: [PATCH 06/10] small slack event handler refactor --- lib/Main.js | 1 + lib/SlackEventHandler.js | 48 ++++++++++++++++++---------------------- 2 files changed, 22 insertions(+), 27 deletions(-) diff --git a/lib/Main.js b/lib/Main.js index 6f0fa804..44df4fda 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -397,6 +397,7 @@ Main.prototype.listRoomsFor = function(intent, state) { }; Main.prototype.onMatrixEvent = function(ev) { + console.log("Received matrix event:", JSON.stringify(ev)); // simple de-dup var recents = this._recentMatrixEventIds; for (var i = 0; i < recents.length; i++) { diff --git a/lib/SlackEventHandler.js b/lib/SlackEventHandler.js index 655e2c27..64ccd2a7 100644 --- a/lib/SlackEventHandler.js +++ b/lib/SlackEventHandler.js @@ -163,24 +163,28 @@ SlackEventHandler.prototype.handleMessageEvent = function (params) { var token = room.getAccessToken(); - var text = params.event.text; + 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()); - if (text) { - return room.onSlackMessage({ - text, - user_id: params.event.user, - team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() - }); - } - return Promise.resolve(); + return (processMsg) ? room.onSlackMessage(msg) : Promise.resolve(); } - if (undefined == text) { + 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); @@ -189,19 +193,19 @@ SlackEventHandler.prototype.handleMessageEvent = function (params) { } var result; - if (params.event.subtype === "file_share" && params.event.file) { + 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(params.event.file, room.getSlackUserToken()) + result = this.enablePublicSharing(msg.file, room.getSlackUserToken()) .then((file) => { if (file) { - params.event.file = file; + msg.file = file; } - return this.fetchFileContent(params.event.file, token) + return this.fetchFileContent(msg.file, token) .then((content) => { - params.event.file._content = content; + msg.file._content = content; }); }) } @@ -209,18 +213,8 @@ SlackEventHandler.prototype.handleMessageEvent = function (params) { result = Promise.resolve(); } - return result.then(() => { - var msg = Object.assign({}, params.event, { - user_id: params.event.user || params.event.bot_id, - team_domain: room.getSlackTeamDomain() || room.getSlackTeamId() - }); - - if (msg.subtype === 'file_comment') { - msg.user_id = msg.comment.user; - } - - return msg; - }).then((msg) => this.replaceChannelIdsWithNames(msg, token)) + return result.then(() => msg) + .then((msg) => this.replaceChannelIdsWithNames(msg, token)) .then((msg) => this.replaceUserIdsWithNames(msg, token)) .then((msg) => room.onSlackMessage(msg)); }; From 9c692b3100e93ade72b627d552a92d17c0538db2 Mon Sep 17 00:00:00 2001 From: perissology Date: Mon, 15 Jan 2018 20:17:29 -0800 Subject: [PATCH 07/10] a bit more cleanup --- README.md | 16 +++++++--------- lib/BridgedRoom.js | 4 ---- lib/Main.js | 11 +++-------- lib/SlackEventHandler.js | 23 ++++++++--------------- lib/SlackGhost.js | 15 ++++++++------- lib/SlackHookHandler.js | 2 ++ 6 files changed, 28 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 46bd8890..96cbebef 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ and bot users. This allows you to link as many channels as you would like with o ### Recommended -1. add a custom app to your slack team/workspace by visiting https://api.slack.com/apps +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. @@ -129,10 +129,11 @@ and bot users. This allows you to link as many channels as you would like with o - files:write:user - Note: media uploaded to matrix is currently not permissioned, and anyone with the link - can access the file. In order to make slack files visible to matrix users, this bridge - will set make the slack file visible to anyone with the url (including files in private channels). - The current behavior in slack is that files are only accessible to authenticated users. + 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 @@ -151,10 +152,7 @@ and bot users. This allows you to link as many channels as you would like with o ``` You will also need to determine the "channel ID" that Slack uses to identify - the channel. Unfortunately, it is not easily obtained from the Slack UI. The - easiest way to do this is to send a message from Slack to the bridge; the - bridge will log the channel ID as part of the unrecognised message output. - You can then take note of the `channel_id` field. + 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: diff --git a/lib/BridgedRoom.js b/lib/BridgedRoom.js index 30c81c63..ce45dc9a 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -412,8 +412,6 @@ BridgedRoom.prototype.lookupAndSetTeamInfo = function() { this._slack_team_id = response.team.id; this._dirty = true; } - - return; }); }; @@ -450,8 +448,6 @@ BridgedRoom.prototype.lookupAndSetUserInfo = function() { this._slack_bot_id = response.user.profile.bot_id; this._dirty = true; } - - return; }); }; diff --git a/lib/Main.js b/lib/Main.js index 44df4fda..0ce15dcd 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -194,7 +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 it the team_domain is changed, we will recreate all users + // 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(), @@ -397,7 +397,6 @@ Main.prototype.listRoomsFor = function(intent, state) { }; Main.prototype.onMatrixEvent = function(ev) { - console.log("Received matrix event:", JSON.stringify(ev)); // simple de-dup var recents = this._recentMatrixEventIds; for (var i = 0; i < recents.length; i++) { @@ -551,13 +550,9 @@ Main.prototype.actionLink = function(opts) { room.updateSlackBotToken(opts.slack_bot_token); return Promise.all([ room.lookupAndSetTeamInfo(), room.lookupAndSetUserInfo() ]) .then(() => { - if (isNew) { - this.addBridgedRoom(room); - } + if (isNew) this.addBridgedRoom(room); - if (room.isDirty()) { - this.putRoomToStore(room); - } + if (room.isDirty()) this.putRoomToStore(room); return room; }); diff --git a/lib/SlackEventHandler.js b/lib/SlackEventHandler.js index 64ccd2a7..67e16605 100644 --- a/lib/SlackEventHandler.js +++ b/lib/SlackEventHandler.js @@ -30,8 +30,6 @@ util.inherits(SlackEventHandler, BaseSlackHandler); * @param {string} params.event.type Slack event type * @param {string} params.type type of callback we are receiving. typically event_callback * or url_verification. - // * @param {string} timestamp Timestamp when message was received, in seconds - // * formatted as a float. */ SlackEventHandler.prototype.handle = function (params, response) { try { @@ -49,8 +47,6 @@ SlackEventHandler.prototype.handle = function (params, response) { return; } - // var room = main.getRoomBySlackChannelId(params.event.channel); - var result; switch (params.event.type) { case 'message': @@ -69,8 +65,7 @@ SlackEventHandler.prototype.handle = function (params, response) { result = Promise.reject(new UnknownEvent()); } - result - .then(() => endTimer({outcome: "success"})) + result.then(() => endTimer({outcome: "success"})) .catch((e) => { if (e instanceof UnknownChannel) { console.log("Ignoring message from unrecognised slack channel id : %s (%s)", @@ -88,16 +83,14 @@ SlackEventHandler.prototype.handle = function (params, response) { console.log("Failed: ", e); } ); - - response.writeHead(200, {"Content-Type": "application/json"}); - response.end(); } 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(); } + + // return 200 so slack doesn't keep sending the event + response.writeHead(200, {"Content-Type": "text/plain"}); + response.end(); + }; /** @@ -145,7 +138,6 @@ SlackEventHandler.prototype.handleChannelRenameEvent = function (params) { * * @param {Object} params The event request emitted. * @param {string} params.event.user Slack user ID of user sending the message. - // * @param {string} params.user_name Slack user name of the 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 @@ -154,7 +146,8 @@ 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' && params.event.bot_id === room.getSlackBotId()) { + if (params.event.subtype === 'bot_message' && + (!room.getSlackBotId() || params.event.bot_id === room.getSlackBotId())) { return Promise.resolve(); } diff --git a/lib/SlackGhost.js b/lib/SlackGhost.js index 661b6af5..2bb0b236 100644 --- a/lib/SlackGhost.js +++ b/lib/SlackGhost.js @@ -58,19 +58,20 @@ SlackGhost.prototype.update = function(message, room) { SlackGhost.prototype.updateDisplayname = function(message, room) { var display_name = message.user_name; - var getDisplayName = (!display_name) ? - this.lookupUserInfo(message.user_id, room.getAccessToken()) + 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; } - return undefined; - }) : - Promise.resolve(display_name); + }); + } else { + getDisplayName = Promise.resolve(display_name); + } return getDisplayName.then(display_name => { - if (!display_name) return Promise.resolve(); - if (this._display_name === display_name) return Promise.resolve(); + if (!display_name || this._display_name === display_name) return Promise.resolve(); return this.getIntent().setDisplayName(display_name).then(() => { this._display_name = display_name; diff --git a/lib/SlackHookHandler.js b/lib/SlackHookHandler.js index 587afeaf..2612602f 100644 --- a/lib/SlackHookHandler.js +++ b/lib/SlackHookHandler.js @@ -59,6 +59,8 @@ SlackHookHandler.prototype.startAndListen = function(port, tls_config) { }); request.on("end", () => { + // 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 { if (isEvent) { From 64b1f8d8e902f0036b8d0e08de03e5299b6cb44b Mon Sep 17 00:00:00 2001 From: perissology Date: Tue, 16 Jan 2018 05:28:30 -0800 Subject: [PATCH 08/10] be consistent w/ returns --- lib/BridgedRoom.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/BridgedRoom.js b/lib/BridgedRoom.js index ce45dc9a..6bd0298b 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -442,7 +442,7 @@ BridgedRoom.prototype.lookupAndSetUserInfo = function() { json: true, }); }).then((response) => { - if (!response.user && response.user.profile) return Promise.resolve(); + 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; From beb4aaccab3dbc1580dd2eba2543a9d0e3ff0872 Mon Sep 17 00:00:00 2001 From: perissology Date: Wed, 17 Jan 2018 17:32:34 -0800 Subject: [PATCH 09/10] fix bug when inviting bot with a user on another HS --- lib/Main.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/Main.js b/lib/Main.js index 0ce15dcd..d3730ef6 100644 --- a/lib/Main.js +++ b/lib/Main.js @@ -422,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"}); From 6be1c789a3f305ef577f3b41df5e2d324653f860 Mon Sep 17 00:00:00 2001 From: perissology Date: Wed, 24 Jan 2018 15:15:25 -0800 Subject: [PATCH 10/10] fix some typos in if statements --- lib/BridgedRoom.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/BridgedRoom.js b/lib/BridgedRoom.js index 6bd0298b..30e0f341 100644 --- a/lib/BridgedRoom.js +++ b/lib/BridgedRoom.js @@ -403,12 +403,12 @@ BridgedRoom.prototype.lookupAndSetTeamInfo = function() { }).then((response) => { if (!response.team) return; - if (!this._slack_team_domain !== response.team.domain) { + 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) { + if (this._slack_team_id !== response.team.id) { this._slack_team_id = response.team.id; this._dirty = true; } @@ -428,7 +428,7 @@ BridgedRoom.prototype.lookupAndSetUserInfo = function() { console.log(response); if (!response.user_id) return; - if (!this._slack_user_id !== response.user_id) { + if (this._slack_user_id !== response.user_id) { this._slack_user_id = response.user_id; this._dirty = true; } @@ -442,9 +442,9 @@ BridgedRoom.prototype.lookupAndSetUserInfo = function() { json: true, }); }).then((response) => { - if (!response.user && response.user.profile) return; + if (!response.user || !response.user.profile) return; - if (!this._slack_bot_id !== response.user.profile.bot_id) { + if (this._slack_bot_id !== response.user.profile.bot_id) { this._slack_bot_id = response.user.profile.bot_id; this._dirty = true; }