const WebSocket = require('ws'), https = require('https'), sleep = require("sleep").sleep ,SlackData = require("./slackData.js").SlackData ,SlackHistory = require("./slackHistory.js").SlackHistory ,config = require("../config.js") ,httpsRequest = require('./httpsRequest.js').httpsRequest ; const SLACK_ENDPOINT = "https://slack.com/api/" ,SLACK_HOSTNAME = "slack.com" ,SLACK_ENDPOINT_PATH = "/api/" ,GETAPI = { rtmStart: "rtm.start" ,oauth: "oauth.access" ,identityEmail: "users.identity" ,channelHistory: "channels.history" ,directHistory: "im.history" ,groupHistory: "groups.history" ,starChannel: "stars.add" ,unstarChannel: "stars.remove" ,starMsg: "stars.add" ,unstarMsg: "stars.remove" ,postMsg: "chat.postMessage" ,postMeMsg: "chat.meMessage" ,editMsg: "chat.update" ,removeMsg: "chat.delete" ,postFile: "files.upload" ,setActive: "users.setActive" ,setPresence: "users.setPresence" ,emojiList: "emoji.list" ,slashList: "commands.list" ,listPinned: "pins.list" ,listMpims: "mpim.list" ,slashExec: "chat.command" ,addReaction: "reactions.add" ,removeReaction: "reactions.remove" ,sendAction: "chat.attachmentAction" ,read: { group: "groups.mark" ,im: "im.mark" ,group: "mpim.mark" ,channel: "channels.mark" } } ,HISTORY_LENGTH = 35 ,HISTORY_MAX_AGE = 10000// * 60 * 1000 ,UPDATE_LIVE = [ "message" ,"pin_added" ,"pin_removed" ,"reaction_added" ,"reaction_removed" ,"star_added" ,"star_removed" ] // Message type that affect live history ; /** * @implements {ChatSystem} **/ function Slack(slackToken, manager) { this.token = slackToken; this.manager = manager; this.rtm = null; this.rtmId = 1; this.data = new SlackData(this); this.history = {}; this.pendingRtm = {}; this.pendingMessages = []; this.pendingPing = false; this.connected = false; this.closing = false; } Slack.prototype.getId = function() { return this.data.team ? this.data.team.id : null; }; Slack.prototype.onRequest = function() { if (this.connected === false) { this.connect(); } }; Slack.prototype.connect = function(cb) { var _this = this; this.connected = undefined; httpsRequest(SLACK_ENDPOINT +GETAPI.rtmStart +"?token=" +this.token, (status, body) => { if (!body || !body.ok) { _this.error = "Slack API error"; _this.connected = false; console.error("Slack api responded " +status +" with body " +JSON.stringify(body)); cb && cb(_this); } else if (status !== 200) { _this.error = body.error; _this.connected = false; console.error("Slack api responded " +status); cb && cb(_this); } else { _this.data.updateStatic({ team: body["team"], users: body["users"], bots: body["bots"], self: body["self"] }, Date.now()); _this.connectRtm(body.url); } }); }; Slack.prototype.sendCommand = function(room, cmd, arg) { httpsRequest( SLACK_ENDPOINT +GETAPI.slashExec +"?token=" +this.token +"&command=" +encodeURIComponent(cmd.name) +"&disp=" +encodeURIComponent(cmd.name) +"&channel=" +room.remoteId +"&text=" +arg); } Slack.prototype.sendTyping = function(room) { this.rtm.send('{"id":' +this.rtmId++ +',"type":"typing","channel":"' +room.remoteId +'"}'); } Slack.prototype.getSlashCommands = function(cb) { httpsRequest(SLACK_ENDPOINT +GETAPI.slashList +"?token=" +this.token, (status, body) => { if (!status || !body || !body.ok) cb(null); else cb(body.commands || {}); }); }; Slack.prototype.getEmojis = function(cb) { httpsRequest(SLACK_ENDPOINT +GETAPI.emojiList +"?token=" +this.token, (status, body) => { if (!status || !body || !body.ok) cb(null); else cb(body.emoji || {}); }); }; Slack.prototype.poll = function(knownVersion, now) { if (this.connected) { var updatedCtx = this.data.getUpdates(knownVersion) ,updatedTyping = this.data.getWhoIsTyping(now) ,updatedLive = this.getLiveUpdates(knownVersion); if (updatedCtx || updatedLive || updatedTyping) { return { "static": updatedCtx, "live": updatedLive, "typing": updatedTyping, "v": Math.max(this.data.liveV, this.data.staticV) }; } } }; /** @return {Object|undefined} */ Slack.prototype.getLiveUpdates = function(knownVersion) { var result = {}; for (var roomId in this.history) { var history = this.history[roomId]; if (history.isNew) { result[roomId] = history.toStatic(0); history.isNew = false; } else { var roomData = history.toStatic(knownVersion); if (roomData.length) result[roomId] = roomData; } } for (var roomId in result) { return result; } return undefined; }; Slack.prototype.unstackPendingMessages = function() { for (var i = this.pendingMessages.length -1; i >= 0; i--) { this.onMessage(this.pendingMessages[0], Date.now()); this.pendingMessages.shift(); } }; Slack.prototype.resetVersions = function(v) { this.data.team.version = v; for (var i in this.data.channels) this.data.channels[i].version = v; for (var i in this.data.users) this.data.users[i].version = v; if (this.data.self && this.data.self.prefs) this.data.self.prefs.version = v; this.data.staticV = v; }; Slack.prototype.onMessage = function(msg, t) { if (msg["reply_to"] && this.pendingRtm[msg["reply_to"]]) { var ts = msg["ts"] ,rtmId = msg["reply_to"]; msg = this.pendingRtm[rtmId]; msg["ts"] = ts; delete this.pendingRtm[rtmId]; } if (msg["type"] === "hello" && msg["start"] && msg["start"]["rtm_start"]) { var _this = this; _this.getRawMpims((mpims) => { _this.getEmojis((emojis) => { _this.getSlashCommands((commands) => { var msgContent = msg.start.rtm_start; var now = Date.now(); msgContent.self = msg.self; msgContent.emojis = emojis; msgContent.commands = commands; msgContent.mpims = mpims; _this.resetVersions(now); _this.data.updateStatic(msgContent, now); _this.connected = true; _this.unstackPendingMessages(); _this.ping(); }); }); }); } else if (this.connected) { this.data.onMessage(msg, t); if ((msg["channel"] || msg["channel_id"] || (msg["item"] && msg["item"]["channel"])) && msg["type"] && UPDATE_LIVE.indexOf(msg["type"]) !== -1) { var channelId = this.data.team.id +'|' +(msg["channel"] || msg["channel_id"] || msg["item"]["channel"]) ,channel = this.data.channels[channelId] ,histo = this.history[channelId]; // FIXME remove typing for user if (!histo) { histo = this.history[channelId] = new SlackHistory(this, channel.remoteId, channelId, this.data.team.id +'|', HISTORY_LENGTH); histo.isNew = true; } var lastMsg = histo.push(msg, t); if (lastMsg) this.data.liveV = t; histo.resort(); if (channel) channel.setLastMsg(lastMsg, t); } } else { this.pendingMessages.push(msg); } }; Slack.prototype.getRawMpims = function(cb) { httpsRequest(SLACK_ENDPOINT +GETAPI.listMpims +"?token=" +this.token, (status, content) => { if (status === 200 && content.ok) cb(content.groups); else cb(undefined); }); }; /** * @param {SlackChan|SlackGroup|SlackIms} chan * @param {string} id * @param {number} ts **/ Slack.prototype.markRead = function(chan, id, ts) { var apiURI; if (chan.remoteId[0] === 'C') apiURI = SLACK_ENDPOINT+GETAPI.read.channel; else if (chan.remoteId[0] === 'G') apiURI = SLACK_ENDPOINT+GETAPI.read.group; else if (chan.remoteId[0] === 'D') apiURI = SLACK_ENDPOINT+GETAPI.read.im; httpsRequest(apiURI +"?token=" +this.token +"&channel="+chan.remoteId +"&ts="+id); }; Slack.prototype.connectRtm = function(url, cb) { var _this = this; this.rtmId = 1; var protocol = url.substr(0, url.indexOf('://') +3); url = url.substr(protocol.length); url = protocol +url.substr(0, url.indexOf('/'))+ "/?flannel=1&token=" +this.token+ "&start_args=" +encodeURIComponent("?simple_latest=true&presence_sub=true&canonical_avatars=true") this.rtm = new WebSocket(url); this.rtm.on("message", function(msg) { if (!_this.connected && cb) { cb(); } try { msg = JSON.parse(msg); } catch (ex) { console.error("WTF invalid JSON ", msg); } _this.onMessage(msg, Date.now()); }); this.rtm.once("error", function(e) { _this.connected = false; console.error(e); _this.close(); }); this.rtm.once("end", function() { console.error("RTM hang up"); _this.onClose(); }); }; Slack.prototype.onClose = function() { this.manager.suicide(this); }; Slack.prototype.ping = function() { httpsRequest(SLACK_ENDPOINT+GETAPI.setActive +"?token=" +this.token); }; Slack.prototype.rtmPing = function() { if (this.connected) { if (this.pendingPing && this.pendingRtm[this.pendingPing]) { //FIXME timeout console.error("Ping timeout"); } else { var rtmId = this.rtmId++; this.pendingRtm[rtmId] = { type: 'ping' }; this.pendingPing = rtmId; this.rtm.send('{"id":' +rtmId +',"type":"ping"}'); } } }; Slack.prototype.close = function() { if (!this.closing) { this.closing = true; if (this.rtm) this.rtm.close(); this.onClose(); } }; Slack.getUserId = function(code, redirectUri, cb) { Slack.getOauthToken(code, redirectUri, (token) => { if (token) { httpsRequest(SLACK_ENDPOINT+GETAPI.identityEmail +"?token="+token, (status, resp) => { if (status === 200 && resp.ok && resp.user && resp.user.email) { cb(resp.user.id +'_' +resp.team.id); } else { cb(null); } }); } else { cb(null); } }); }; Slack.getOauthToken = function(code, cb) { httpsRequest(SLACK_ENDPOINT+GETAPI.oauth +"?client_id=" +config.services.Slack.clientId +"&client_secret=" +config.services.Slack.clientSecret +"&redirect_uri=" +encodeURIComponent(config.rootUrl +"account/addservice/slack") +"&code=" +code, (status, resp) => { if (status === 200 && resp.ok) { cb(resp["team_name"], resp["team_id"] +resp["user_id"], resp["access_token"]); } else { cb(null); } }); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} contentType * @param {function(string|null)} callback **/ Slack.prototype.openUploadFileStream = function(channel, contentType, callback) { var req = https.request({ hostname: SLACK_HOSTNAME ,method: 'POST' ,path: SLACK_ENDPOINT_PATH +GETAPI.postFile +"?token=" +this.token +"&channels=" +channel.remoteId ,headers: { "Content-Type": contentType } }, (res) => { var errorJson; res.on("data", (chunk) => { errorJson = errorJson ? Buffer.concat([errorJson, chunk], errorJson.length +chunk.length) : Buffer.from(chunk); }); res.once("end", () => { if (res.statusCode === 200) { callback(null); } else { try { errorJson = JSON.parse(errorJson.toString()); } catch(e) { callback("error"); return; } callback(errorJson["error"] || "error"); } }); }); return req; }; function findBoundary() { const prefix = '-'.repeat(15) ,alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' ,doYouKnowWhoManyTheyAre = alphabet.length // 26 letters in da alphabet ,nbArg = arguments.length; for (let i =0; i < doYouKnowWhoManyTheyAre; i++) bLoop: for (let j =0; j < doYouKnowWhoManyTheyAre; j++) { const boundary = prefix +alphabet[i] +alphabet[j]; for (let argIndex =0; argIndex < nbArg; argIndex++) { if (arguments[argIndex].indexOf(boundary) >= 0) continue bLoop; } return boundary; } } function encodeWithBoundary(boundary, data) { var resp = ""; for (var k in data) { resp += '--' +boundary +'\r\n'; resp += 'Content-Disposition: form-data; name="' +k +'"\r\n\r\n' +data[k] +'\r\n'; }; return resp +'--' +boundary +'--\r\n'; } /** * @param {string} serviceId * @param {Object} payload * @param {function(string|null)=} callback **/ Slack.prototype.sendAction = function(serviceId, payload, callback) { var channel = this.data.channels[payload["channel_id"]] ,service = this.data.users[serviceId]; if (channel && service) { payload["channel_id"] = channel.remoteId; var payloadString = JSON.stringify(payload) ,boundary = findBoundary(service.remoteId, payloadString) ,body = encodeWithBoundary(boundary, { "service_id": service.remoteId ,"payload": payloadString }); var req = https.request({ hostname: SLACK_HOSTNAME ,method: 'POST' ,path: SLACK_ENDPOINT_PATH +GETAPI.sendAction +"?token=" +this.token ,headers: { "Content-Type": "multipart/form-data; boundary=" +boundary, "Content-Length": body.length } }, (res) => { if (callback) { var resp = []; res.on("data", (chunk) => { resp.push(chunk); }); res.once("end", () => { resp = Buffer.concat(resp).toString(); try { resp = JSON.parse(resp); } catch (e) { resp = null; } callback(resp && resp.ok ? resp : false); }); } }); req.end(body); return true; } return false; }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} contentType * @param {function(string|null)} callback **/ Slack.prototype.openUploadFileStream = function(channel, contentType, callback) { var req = https.request({ hostname: SLACK_HOSTNAME ,method: 'POST' ,path: SLACK_ENDPOINT_PATH +GETAPI.postFile +"?token=" +this.token +"&channels=" +channel.remoteId ,headers: { "Content-Type": contentType } }, (res) => { var errorJson; res.on("data", (chunk) => { errorJson = errorJson ? Buffer.concat([errorJson, chunk], errorJson.length +chunk.length) : Buffer.from(chunk); }); res.once("end", () => { if (res.statusCode === 200) { callback(null); } else { try { errorJson = JSON.parse(errorJson.toString()); } catch(e) { callback("error"); return; } callback(errorJson["error"] || "error"); } }); }); return req; }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId * @param {string} reaction **/ Slack.prototype.addReaction = function(channel, msgId, reaction) { httpsRequest(SLACK_ENDPOINT +GETAPI.addReaction +"?token=" +this.token +"&name=" +reaction +"&channel="+channel.remoteId +"×tamp="+msgId); } /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId * @param {string} reaction **/ Slack.prototype.removeReaction = function(channel, msgId, reaction) { httpsRequest(SLACK_ENDPOINT +GETAPI.removeReaction +"?token=" +this.token +"&name=" +reaction +"&channel="+channel.remoteId +"×tamp="+msgId); } /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {Array.} text **/ Slack.prototype.sendMeMsg = function(channel, text) { httpsRequest(SLACK_ENDPOINT +GETAPI.postMeMsg +"?token=" +this.token +"&channel=" +channel.remoteId +"&text=" +text.join("\n") +"&as_user=true"); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel **/ Slack.prototype.starChannel = function(channel) { httpsRequest(SLACK_ENDPOINT +GETAPI.starChannel +"?token=" +this.token +"&channel=" +channel.remoteId); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel **/ Slack.prototype.unstarChannel = function(channel) { httpsRequest(SLACK_ENDPOINT +GETAPI.unstarChannel +"?token=" +this.token +"&channel=" +channel.remoteId); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId **/ Slack.prototype.starMsg = function(channel, msgId) { httpsRequest(SLACK_ENDPOINT +GETAPI.starChannel +"?token=" +this.token +"&channel=" +channel.remoteId +"×tamp=" +msgId); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId **/ Slack.prototype.unstarMsg = function(channel, msgId) { httpsRequest(SLACK_ENDPOINT +GETAPI.unstarChannel +"?token=" +this.token +"&channel=" +channel.remoteId +"×tamp=" +msgId); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {Array.} text * @param {Array.=} attachments **/ Slack.prototype.sendMsg = function(channel, text, attachments) { if (attachments) { attachments.forEach((attachmentObj) => { if (attachmentObj["ts"]) attachmentObj["ts"] /= 1000; }); httpsRequest(SLACK_ENDPOINT +GETAPI.postMsg +"?token=" +this.token +"&channel=" +channel.remoteId +"&text=" +text.join("\n") + (attachments ? ("&attachments=" +encodeURIComponent(JSON.stringify(attachments))) : "") +"&as_user=true"); } else { var decodedText = []; text.forEach(function(i) { decodedText.push(decodeURIComponent(i)); }); var fullDecodedText = decodedText.join("\n"); this.pendingRtm[this.rtmId] = { type: 'message', channel: channel.remoteId, user: this.data.self.remoteId, text: fullDecodedText }; this.rtm.send('{"id":' +this.rtmId++ +',"type":"message","channel":"' +channel.remoteId +'", "text":' +JSON.stringify(fullDecodedText) +'}'); } }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId **/ Slack.prototype.removeMsg = function(channel, msgId) { httpsRequest(SLACK_ENDPOINT +GETAPI.removeMsg +"?token=" +this.token +"&channel=" +channel.remoteId +"&ts=" +msgId +"&as_user=true"); }; /** * @param {SlackChan|SlackGroup|SlackIms} channel * @param {string} msgId * @param {string} text **/ Slack.prototype.editMsg = function(channel, msgId, text) { httpsRequest(SLACK_ENDPOINT +GETAPI.editMsg +"?token=" +this.token +"&channel=" +channel.remoteId +"&ts=" +msgId +"&text=" +text.join("\n") +"&as_user=true"); }; /** * @param {SlackChan|SlackGroup|SlackIms} target **/ Slack.prototype.fetchPinned = function(target) { var _this = this; httpsRequest(SLACK_ENDPOINT +GETAPI.listPinned +"?token="+this.token +"&channel=" +target.remoteId, (status, resp) => { if (status === 200 && resp && resp.ok) { var msgs = [], histo = _this.history[target.id], now = Date.now(); if (!histo) histo = _this.history[target.id] = new SlackHistory(_this, target.remoteId, target.id, this.data.team.id +'|', HISTORY_LENGTH, HISTORY_MAX_AGE); resp.items.forEach(function(msg) { msgs.push(histo.messageFactory(msg.message, now)); }); target.pins = msgs; target.version = Math.max(target.version, now); _this.data.staticV = Math.max(_this.data.staticV, now); } }); }; /** * @param {SlackChan|SlackGroup|SlackIms} target **/ Slack.prototype.fetchHistory = function(target, cb, count, firstMsgId) { var _this = this ,baseUrl = "" ,targetId = target.remoteId; if (targetId[0] === 'D') { baseUrl = SLACK_ENDPOINT +GETAPI.directHistory; } else if (targetId[0] === 'C') { baseUrl = SLACK_ENDPOINT +GETAPI.channelHistory; } else if (targetId[0] === 'G') { baseUrl = SLACK_ENDPOINT +GETAPI.groupHistory; } httpsRequest(baseUrl +"?token="+this.token +"&channel=" +targetId +"&include_pin_count=true" +(firstMsgId ? ("&inclusive=true&latest=" +firstMsgId) : "") +"&count=" +(count || 100), (status, resp) => { var history = []; if (status === 200 && resp && resp.ok) { var histo = this.history[target.id]; if (!histo) histo = this.history[target.id] = new SlackHistory(_this, target.remoteId, target.id, this.data.team.id +'|', HISTORY_LENGTH, HISTORY_MAX_AGE); resp.messages.forEach((respMsg) => { respMsg["id"] = respMsg["ts"]; history.push(histo.messageFactory(histo.prepareMessage(respMsg))); }); if (!target.pins || target.pins.length !== resp["pin_count"]) { if (!resp["pin_count"]) { target.pins = []; target.v = Math.max(target.v, Date.now()); _this.data.staticV = Math.max(_this.data.staticV, target.v); } else { _this.fetchPinned(target); } } } cb(history); }); }; Slack.prototype.getChatContext = function() { return this.data; }; module.exports.Slack = Slack;