slack.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. const
  2. WebSocket = require('ws'),
  3. https = require('https'),
  4. sleep = require("sleep").sleep
  5. ,SlackData = require("./slackData.js").SlackData
  6. ,SlackHistory = require("./slackHistory.js").SlackHistory
  7. ,config = require("../config.js")
  8. ,httpsRequest = require('./httpsRequest.js').httpsRequest
  9. ;
  10. const SLACK_ENDPOINT = "https://slack.com/api/"
  11. ,SLACK_HOSTNAME = "slack.com"
  12. ,SLACK_ENDPOINT_PATH = "/api/"
  13. ,GETAPI = {
  14. rtmStart: "rtm.start"
  15. ,oauth: "oauth.access"
  16. ,identityEmail: "users.identity"
  17. ,channelHistory: "channels.history"
  18. ,directHistory: "im.history"
  19. ,groupHistory: "groups.history"
  20. ,postMsg: "chat.postMessage"
  21. ,postMeMsg: "chat.meMessage"
  22. ,editMsg: "chat.update"
  23. ,removeMsg: "chat.delete"
  24. ,postFile: "files.upload"
  25. ,setActive: "users.setActive"
  26. ,emojiList: "emoji.list"
  27. ,slashList: "commands.list"
  28. ,slashExec: "chat.command"
  29. ,addReaction: "reactions.add"
  30. ,removeReaction: "reactions.remove"
  31. ,sendAction: "chat.attachmentAction"
  32. ,read: {
  33. group: "groups.mark"
  34. ,im: "im.mark"
  35. ,group: "mpim.mark"
  36. ,channel: "channels.mark"
  37. }
  38. }
  39. ,HISTORY_LENGTH = 35
  40. ,HISTORY_MAX_AGE = 10000// * 60 * 1000
  41. ,UPDATE_LIVE = [
  42. "message"
  43. ,"pin_added"
  44. ,"pin_removed"
  45. ,"reaction_added"
  46. ,"reaction_removed"
  47. ,"star_added"
  48. ,"star_removed"
  49. ] // Message type that affect live history
  50. ;
  51. /**
  52. * @implements {ChatSystem}
  53. **/
  54. function Slack(slackToken, manager) {
  55. this.token = slackToken;
  56. this.manager = manager;
  57. this.rtm = null;
  58. this.rtmId = 1;
  59. this.data = new SlackData(this);
  60. this.history = {};
  61. this.pendingRtm = {};
  62. this.pendingMessages = [];
  63. this.pendingPing = false;
  64. this.connected = false;
  65. this.closing = false;
  66. }
  67. Slack.prototype.getId = function() {
  68. return this.data.team ? this.data.team.id : null;
  69. };
  70. Slack.prototype.onRequest = function() {
  71. if (this.connected === false) {
  72. this.connect();
  73. }
  74. };
  75. Slack.prototype.connect = function(cb) {
  76. var _this = this;
  77. this.connected = undefined;
  78. httpsRequest(SLACK_ENDPOINT +GETAPI.rtmStart +"?token=" +this.token, (status, body) => {
  79. if (!body || !body.ok) {
  80. _this.error = "Slack API error";
  81. _this.connected = false;
  82. console.error("Slack api responded " +status +" with body " +JSON.stringify(body));
  83. cb && cb(_this);
  84. } else if (status !== 200) {
  85. _this.error = body.error;
  86. _this.connected = false;
  87. console.error("Slack api responded " +status);
  88. cb && cb(_this);
  89. } else {
  90. _this.data.updateStatic({
  91. team: body["team"],
  92. users: body["users"],
  93. bots: body["bots"],
  94. self: body["self"]
  95. }, Date.now());
  96. _this.connectRtm(body.url);
  97. }
  98. });
  99. };
  100. Slack.prototype.sendCommand = function(room, cmd, arg) {
  101. httpsRequest(
  102. SLACK_ENDPOINT
  103. +GETAPI.slashExec
  104. +"?token=" +this.token
  105. +"&command=" +encodeURIComponent(cmd.name)
  106. +"&disp=" +encodeURIComponent(cmd.name)
  107. +"&channel=" +room.remoteId
  108. +"&text=" +arg);
  109. }
  110. Slack.prototype.sendTyping = function(room) {
  111. this.rtm.send('{"id":' +this.rtmId++ +',"type":"typing","channel":"' +room.remoteId +'"}');
  112. }
  113. Slack.prototype.getSlashCommands = function(cb) {
  114. httpsRequest(SLACK_ENDPOINT +GETAPI.slashList +"?token=" +this.token, (status, body) => {
  115. if (!status || !body || !body.ok)
  116. cb(null);
  117. else
  118. cb(body.commands || {});
  119. });
  120. };
  121. Slack.prototype.getEmojis = function(cb) {
  122. httpsRequest(SLACK_ENDPOINT +GETAPI.emojiList +"?token=" +this.token, (status, body) => {
  123. if (!status || !body || !body.ok)
  124. cb(null);
  125. else
  126. cb(body.emoji || {});
  127. });
  128. };
  129. Slack.prototype.poll = function(knownVersion, now) {
  130. if (this.connected) {
  131. var updatedCtx = this.data.getUpdates(knownVersion)
  132. ,updatedTyping = this.data.getWhoIsTyping(now)
  133. ,updatedLive = this.getLiveUpdates(knownVersion);
  134. if (updatedCtx || updatedLive || updatedTyping) {
  135. return {
  136. "static": updatedCtx,
  137. "live": updatedLive,
  138. "typing": updatedTyping,
  139. "v": Math.max(this.data.liveV, this.data.staticV)
  140. };
  141. }
  142. }
  143. };
  144. /** @return {Object|undefined} */
  145. Slack.prototype.getLiveUpdates = function(knownVersion) {
  146. var result = {};
  147. for (var roomId in this.history) {
  148. var history = this.history[roomId];
  149. if (history.isNew) {
  150. result[roomId] = history.toStatic(0);
  151. history.isNew = false;
  152. }
  153. else {
  154. var roomData = history.toStatic(knownVersion);
  155. if (roomData.length)
  156. result[roomId] = roomData;
  157. }
  158. }
  159. for (var roomId in result) {
  160. return result;
  161. }
  162. return undefined;
  163. };
  164. Slack.prototype.unstackPendingMessages = function() {
  165. for (var i = this.pendingMessages.length -1; i >= 0; i--) {
  166. this.onMessage(this.pendingMessages[0], Date.now());
  167. this.pendingMessages.shift();
  168. }
  169. };
  170. Slack.prototype.onMessage = function(msg, t) {
  171. if (msg["reply_to"] && this.pendingRtm[msg["reply_to"]]) {
  172. var ts = msg["ts"]
  173. ,rtmId = msg["reply_to"];
  174. msg = this.pendingRtm[rtmId];
  175. msg["ts"] = ts;
  176. delete this.pendingRtm[rtmId];
  177. }
  178. if (msg["type"] === "hello" && msg["start"] && msg["start"]["rtm_start"]) {
  179. var _this = this;
  180. _this.getEmojis((emojis) => {
  181. _this.getSlashCommands((commands) => {
  182. var msgContent = msg.start.rtm_start;
  183. msgContent.self = msg.self;
  184. msgContent.emojis = emojis;
  185. msgContent.commands = commands;
  186. _this.data.updateStatic(msgContent, Date.now());
  187. _this.unstackPendingMessages();
  188. _this.connected = true;
  189. _this.unstackPendingMessages();
  190. });
  191. });
  192. } else if (this.connected) {
  193. this.data.onMessage(msg, t);
  194. if ((msg["channel"] || msg["channel_id"] || (msg["item"] && msg["item"]["channel"])) && msg["type"] && UPDATE_LIVE.indexOf(msg["type"]) !== -1) {
  195. var channelId = this.data.team.id +'|' +(msg["channel"] || msg["channel_id"] || msg["item"]["channel"])
  196. ,channel = this.data.channels[channelId]
  197. ,histo = this.history[channelId];
  198. // FIXME remove typing for user
  199. if (!histo) {
  200. histo = this.history[channelId] = new SlackHistory(this, channel.remoteId, channelId, this.data.team.id +'|', HISTORY_LENGTH);
  201. histo.isNew = true;
  202. }
  203. var lastMsg = histo.push(msg, t);
  204. if (lastMsg)
  205. this.data.liveV = t;
  206. histo.resort();
  207. if (channel)
  208. channel.setLastMsg(lastMsg, t);
  209. }
  210. } else {
  211. this.pendingMessages.push(msg);
  212. }
  213. };
  214. /**
  215. * @param {SlackChan|SlackGroup|SlackIms} chan
  216. * @param {string} id
  217. * @param {number} ts
  218. **/
  219. Slack.prototype.markRead = function(chan, id, ts) {
  220. var apiURI;
  221. if (chan.remoteId[0] === 'C')
  222. apiURI = SLACK_ENDPOINT+GETAPI.read.channel;
  223. else if (chan.remoteId[0] === 'G')
  224. apiURI = SLACK_ENDPOINT+GETAPI.read.group;
  225. else if (chan.remoteId[0] === 'D')
  226. apiURI = SLACK_ENDPOINT+GETAPI.read.im;
  227. httpsRequest(apiURI
  228. +"?token=" +this.token
  229. +"&channel="+chan.remoteId
  230. +"&ts="+id);
  231. };
  232. Slack.prototype.connectRtm = function(url, cb) {
  233. var _this = this;
  234. this.rtmId = 1;
  235. var protocol = url.substr(0, url.indexOf('://') +3);
  236. url = url.substr(protocol.length);
  237. url = protocol +url.substr(0, url.indexOf('/'))+
  238. "/?flannel=1&token=" +this.token+
  239. "&start_args="+
  240. encodeURIComponent("?simple_latest=true&presence_sub=true&mpim_aware=false&canonical_avatars=true")
  241. this.rtm = new WebSocket(url);
  242. this.rtm.on("message", function(msg) {
  243. if (!_this.connected && cb) {
  244. cb();
  245. }
  246. try {
  247. msg = JSON.parse(msg);
  248. } catch (ex) {
  249. console.error("WTF invalid JSON ", msg);
  250. }
  251. _this.onMessage(msg, Date.now());
  252. });
  253. this.rtm.once("error", function(e) {
  254. _this.connected = false;
  255. console.error(e);
  256. _this.close();
  257. });
  258. this.rtm.once("end", function() {
  259. console.error("RTM hang up");
  260. _this.onClose();
  261. });
  262. };
  263. Slack.prototype.onClose = function() {
  264. this.manager.suicide(this);
  265. };
  266. Slack.prototype.ping = function() {
  267. httpsRequest(SLACK_ENDPOINT+GETAPI.setActive
  268. +"?token=" +this.token);
  269. };
  270. Slack.prototype.rtmPing = function() {
  271. if (this.connected) {
  272. if (this.pendingPing && this.pendingRtm[this.pendingPing]) {
  273. //FIXME timeout
  274. console.error("Ping timeout");
  275. } else {
  276. var rtmId = this.rtmId++;
  277. this.pendingRtm[rtmId] = { type: 'ping' };
  278. this.pendingPing = rtmId;
  279. this.rtm.send('{"id":' +rtmId +',"type":"ping"}');
  280. }
  281. }
  282. };
  283. Slack.prototype.close = function() {
  284. if (!this.closing) {
  285. this.closing = true;
  286. if (this.rtm)
  287. this.rtm.close();
  288. this.onClose();
  289. }
  290. };
  291. Slack.getUserId = function(code, redirectUri, cb) {
  292. Slack.getOauthToken(code, redirectUri, (token) => {
  293. if (token) {
  294. httpsRequest(SLACK_ENDPOINT+GETAPI.identityEmail +"?token="+token,
  295. (status, resp) => {
  296. if (status === 200 && resp.ok && resp.user && resp.user.email) {
  297. cb(resp.user.id +'_' +resp.team.id);
  298. } else {
  299. cb(null);
  300. }
  301. });
  302. } else {
  303. cb(null);
  304. }
  305. });
  306. };
  307. Slack.getOauthToken = function(code, cb) {
  308. httpsRequest(SLACK_ENDPOINT+GETAPI.oauth
  309. +"?client_id=" +config.services.Slack.clientId
  310. +"&client_secret=" +config.services.Slack.clientSecret
  311. +"&redirect_uri=" +encodeURIComponent(config.rootUrl +"account/addservice/slack")
  312. +"&code=" +code,
  313. (status, resp) => {
  314. if (status === 200 && resp.ok) {
  315. cb(resp["team_name"], resp["team_id"] +resp["user_id"], resp["access_token"]);
  316. } else {
  317. cb(null);
  318. }
  319. });
  320. };
  321. /**
  322. * @param {SlackChan|SlackGroup|SlackIms} channel
  323. * @param {string} contentType
  324. * @param {function(string|null)} callback
  325. **/
  326. Slack.prototype.openUploadFileStream = function(channel, contentType, callback) {
  327. var req = https.request({
  328. hostname: SLACK_HOSTNAME
  329. ,method: 'POST'
  330. ,path: SLACK_ENDPOINT_PATH +GETAPI.postFile
  331. +"?token=" +this.token
  332. +"&channels=" +channel.remoteId
  333. ,headers: {
  334. "Content-Type": contentType
  335. }
  336. }, (res) => {
  337. var errorJson;
  338. res.on("data", (chunk) => {
  339. errorJson = errorJson ? Buffer.concat([errorJson, chunk], errorJson.length +chunk.length) : Buffer.from(chunk);
  340. });
  341. res.once("end", () => {
  342. if (res.statusCode === 200) {
  343. callback(null);
  344. } else {
  345. try {
  346. errorJson = JSON.parse(errorJson.toString());
  347. } catch(e) {
  348. callback("error");
  349. return;
  350. }
  351. callback(errorJson["error"] || "error");
  352. }
  353. });
  354. });
  355. return req;
  356. };
  357. function findBoundary() {
  358. const prefix = '-'.repeat(15)
  359. ,alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
  360. ,doYouKnowWhoManyTheyAre = alphabet.length // 26 letters in da alphabet
  361. ,nbArg = arguments.length;
  362. for (let i =0; i < doYouKnowWhoManyTheyAre; i++)
  363. bLoop: for (let j =0; j < doYouKnowWhoManyTheyAre; j++) {
  364. const boundary = prefix +alphabet[i] +alphabet[j];
  365. for (let argIndex =0; argIndex < nbArg; argIndex++) {
  366. if (arguments[argIndex].indexOf(boundary) >= 0)
  367. continue bLoop;
  368. }
  369. return boundary;
  370. }
  371. }
  372. function encodeWithBoundary(boundary, data) {
  373. var resp = "";
  374. for (var k in data) {
  375. resp += '--' +boundary +'\r\n';
  376. resp += 'Content-Disposition: form-data; name="' +k +'"\r\n\r\n'
  377. +data[k]
  378. +'\r\n';
  379. };
  380. return resp +'--' +boundary +'--\r\n';
  381. }
  382. /**
  383. * @param {string} serviceId
  384. * @param {Object} payload
  385. * @param {function(string|null)=} callback
  386. **/
  387. Slack.prototype.sendAction = function(serviceId, payload, callback) {
  388. var channel = this.data.channels[payload["channel_id"]]
  389. ,service = this.data.users[serviceId];
  390. if (channel && service) {
  391. payload["channel_id"] = channel.remoteId;
  392. var payloadString = JSON.stringify(payload)
  393. ,boundary = findBoundary(service.remoteId, payloadString)
  394. ,body = encodeWithBoundary(boundary, {
  395. "service_id": service.remoteId
  396. ,"payload": payloadString
  397. });
  398. var req = https.request({
  399. hostname: SLACK_HOSTNAME
  400. ,method: 'POST'
  401. ,path: SLACK_ENDPOINT_PATH +GETAPI.sendAction +"?token=" +this.token
  402. ,headers: {
  403. "Content-Type": "multipart/form-data; boundary=" +boundary,
  404. "Content-Length": body.length
  405. }
  406. }, (res) => {
  407. if (callback) {
  408. var resp = [];
  409. res.on("data", (chunk) => { resp.push(chunk); });
  410. res.once("end", () => {
  411. resp = Buffer.concat(resp).toString();
  412. try {
  413. resp = JSON.parse(resp);
  414. } catch (e) {
  415. resp = null;
  416. }
  417. callback(resp && resp.ok ? resp : false);
  418. });
  419. }
  420. });
  421. req.end(body);
  422. return true;
  423. }
  424. return false;
  425. };
  426. /**
  427. * @param {SlackChan|SlackGroup|SlackIms} channel
  428. * @param {string} contentType
  429. * @param {function(string|null)} callback
  430. **/
  431. Slack.prototype.openUploadFileStream = function(channel, contentType, callback) {
  432. var req = https.request({
  433. hostname: SLACK_HOSTNAME
  434. ,method: 'POST'
  435. ,path: SLACK_ENDPOINT_PATH +GETAPI.postFile
  436. +"?token=" +this.token
  437. +"&channels=" +channel.remoteId
  438. ,headers: {
  439. "Content-Type": contentType
  440. }
  441. }, (res) => {
  442. var errorJson;
  443. res.on("data", (chunk) => {
  444. errorJson = errorJson ? Buffer.concat([errorJson, chunk], errorJson.length +chunk.length) : Buffer.from(chunk);
  445. });
  446. res.once("end", () => {
  447. if (res.statusCode === 200) {
  448. callback(null);
  449. } else {
  450. try {
  451. errorJson = JSON.parse(errorJson.toString());
  452. } catch(e) {
  453. callback("error");
  454. return;
  455. }
  456. callback(errorJson["error"] || "error");
  457. }
  458. });
  459. });
  460. return req;
  461. };
  462. /**
  463. * @param {SlackChan|SlackGroup|SlackIms} channel
  464. * @param {string} msgId
  465. * @param {string} reaction
  466. **/
  467. Slack.prototype.addReaction = function(channel, msgId, reaction) {
  468. httpsRequest(SLACK_ENDPOINT +GETAPI.addReaction
  469. +"?token=" +this.token
  470. +"&name=" +reaction
  471. +"&channel="+channel.remoteId
  472. +"&timestamp="+msgId);
  473. }
  474. /**
  475. * @param {SlackChan|SlackGroup|SlackIms} channel
  476. * @param {string} msgId
  477. * @param {string} reaction
  478. **/
  479. Slack.prototype.removeReaction = function(channel, msgId, reaction) {
  480. httpsRequest(SLACK_ENDPOINT +GETAPI.removeReaction
  481. +"?token=" +this.token
  482. +"&name=" +reaction
  483. +"&channel="+channel.remoteId
  484. +"&timestamp="+msgId);
  485. }
  486. /**
  487. * @param {SlackChan|SlackGroup|SlackIms} channel
  488. * @param {Array.<string>} text
  489. **/
  490. Slack.prototype.sendMeMsg = function(channel, text) {
  491. httpsRequest(SLACK_ENDPOINT +GETAPI.postMeMsg
  492. +"?token=" +this.token
  493. +"&channel=" +channel.remoteId
  494. +"&text=" +text.join("\n")
  495. +"&as_user=true");
  496. };
  497. /**
  498. * @param {SlackChan|SlackGroup|SlackIms} channel
  499. * @param {Array.<string>} text
  500. * @param {Array.<Object>=} attachments
  501. **/
  502. Slack.prototype.sendMsg = function(channel, text, attachments) {
  503. if (attachments) {
  504. attachments.forEach((attachmentObj) => {
  505. if (attachmentObj["ts"])
  506. attachmentObj["ts"] /= 1000;
  507. });
  508. httpsRequest(SLACK_ENDPOINT +GETAPI.postMsg
  509. +"?token=" +this.token
  510. +"&channel=" +channel.remoteId
  511. +"&text=" +text.join("\n")
  512. + (attachments ? ("&attachments=" +encodeURIComponent(JSON.stringify(attachments))) : "")
  513. +"&as_user=true");
  514. } else {
  515. var decodedText = [];
  516. text.forEach(function(i) {
  517. decodedText.push(decodeURIComponent(i));
  518. });
  519. var fullDecodedText = decodedText.join("\n");
  520. this.pendingRtm[this.rtmId] = {
  521. type: 'message',
  522. channel: channel.remoteId,
  523. user: this.data.self.remoteId,
  524. text: fullDecodedText
  525. };
  526. this.rtm.send('{"id":' +this.rtmId++ +',"type":"message","channel":"' +channel.remoteId +'", "text":' +JSON.stringify(fullDecodedText) +'}');
  527. }
  528. };
  529. /**
  530. * @param {SlackChan|SlackGroup|SlackIms} channel
  531. * @param {string} msgId
  532. **/
  533. Slack.prototype.removeMsg = function(channel, msgId) {
  534. httpsRequest(SLACK_ENDPOINT +GETAPI.removeMsg
  535. +"?token=" +this.token
  536. +"&channel=" +channel.remoteId
  537. +"&ts=" +msgId
  538. +"&as_user=true");
  539. };
  540. /**
  541. * @param {SlackChan|SlackGroup|SlackIms} channel
  542. * @param {string} msgId
  543. * @param {string} text
  544. **/
  545. Slack.prototype.editMsg = function(channel, msgId, text) {
  546. httpsRequest(SLACK_ENDPOINT +GETAPI.editMsg
  547. +"?token=" +this.token
  548. +"&channel=" +channel.remoteId
  549. +"&ts=" +msgId
  550. +"&text=" +text.join("\n")
  551. +"&as_user=true");
  552. };
  553. /**
  554. * @param {SlackChan|SlackGroup|SlackIms} target
  555. **/
  556. Slack.prototype.fetchHistory = function(target, cb, count, firstMsgId) {
  557. var _this = this
  558. ,baseUrl = ""
  559. ,targetId = target.remoteId;
  560. if (targetId[0] === 'D') {
  561. baseUrl = SLACK_ENDPOINT +GETAPI.directHistory;
  562. } else if (targetId[0] === 'C') {
  563. baseUrl = SLACK_ENDPOINT +GETAPI.channelHistory;
  564. } else if (targetId[0] === 'G') {
  565. baseUrl = SLACK_ENDPOINT +GETAPI.groupHistory;
  566. }
  567. httpsRequest(baseUrl
  568. +"?token="+this.token
  569. +"&channel=" +targetId
  570. +(firstMsgId ? ("&inclusive=true&latest=" +firstMsgId) : "")
  571. +"&count=" +(count || 100),
  572. (status, resp) => {
  573. var history = [];
  574. if (status === 200 && resp && resp.ok) {
  575. var histo = this.history[target.id];
  576. if (!histo)
  577. histo = this.history[target.id] = new SlackHistory(_this, target.remoteId, target.id, this.data.team.id +'|', HISTORY_LENGTH, HISTORY_MAX_AGE);
  578. resp.messages.forEach((respMsg) => {
  579. respMsg["id"] = respMsg["ts"];
  580. history.push(histo.messageFactory(histo.prepareMessage(respMsg)));
  581. });
  582. }
  583. cb(history);
  584. });
  585. };
  586. Slack.prototype.getChatContext = function() {
  587. return this.data;
  588. };
  589. module.exports.Slack = Slack;