| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682 |
- 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"
- ,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"
- ,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) {
- if (cmd.name === "/away") {
- // intercept away commands
- // TODO should be idempotent
- httpsRequest(
- SLACK_ENDPOINT
- +GETAPI.setPresence
- +"?token=" +this.token
- +"&presence=" +(this.data.self.presence ? "away": "auto"));
- } else {
- 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.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;
- _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);
- }
- };
- /**
- * @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&mpim_aware=false&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.<string>} 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 {Array.<string>} text
- * @param {Array.<Object>=} 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.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
- +(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)));
- });
- }
- cb(history);
- });
- };
- Slack.prototype.getChatContext = function() {
- return this.data;
- };
- module.exports.Slack = Slack;
|