slack.js 17 KB

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