nshimiye/relay

View on GitHub
src/slackRelay/SlackRelay.js

Summary

Maintainability
D
2 days
Test Coverage
'use strict';

/**
 * Stand for a bot user
 * in charge of carrying messages from a locally confugured bot to slack
 * @author Marcellin<nmarcellin2@gmail.com>
 */
 const events = require('events');

const RtmClient = require('@slack/client').RtmClient;
const WebClient = require('@slack/client').WebClient;
const RTM_CLIENT_EVENTS = require('@slack/client').CLIENT_EVENTS.RTM;
const RTM_EVENTS = require('@slack/client').RTM_EVENTS;

const Relay = require('../interface/relay');

// how to create private properties
const rtm = new WeakMap();
const web = new WeakMap();
const team = new WeakMap();
const me = new WeakMap();
const users = new WeakMap();
const channels = new WeakMap();
const token_private = new WeakMap();
const event_emitter = new WeakMap();

class SlackRelay extends Relay {

  constructor(token, debug) {
    super();
    token_private.set(this, token);

    // rtm
    let _rtm = new RtmClient(token, {logLevel: 'none'});
    rtm.set(this, _rtm);
    console.log('rtm  instance created!', token, debug);

    // it makes sense to start listening after the instance initialization because
    // this ensures us that only on listener is created
    this._intializeSlackListener();

    // web
    // no need to create a class for managing web api, because we won't really be using it that much
    // 1. it is used to send attachments
    let _web = new WebClient(token);
    web.set(this, _web);

    // event
    let _event_emitter = new events.EventEmitter();
    event_emitter.set(this, _event_emitter);
  }

  get token() {
    return token_private.get(this);
  }
  get users() {
    let _user = users.get(this);
    return _user;
  }
  get channels() {
    let _channels = channels.get(this);
    return _channels;
  }

  connect() {
    let connectionTimeout;
    let _rtm = rtm.get(this);
    let token = token_private.get(this);
    let slackRelayInstance = this;
    console.log('== connect ==', _rtm.slackAPIUrl, _rtm.userAgent, _rtm._token);
    let promisedResponse = new Promise(function(resolve, reject) {
      // @DONE make sure bot is not already connected
      // if so then just return this instance
      if (_rtm.connected) {
        resolve(slackRelayInstance);
      } else {

        // @DONE setup a listener
        // you need to wait for the client to fully connect before you can send messages
        _rtm.on(RTM_CLIENT_EVENTS.AUTHENTICATED, slack => {

          // This will send the message 'this is a test message' to the channel identified by id 'C0CHZA86Q'
          console.log('connection authenticated!', _rtm.connected, slack.url, slack.self.id, slack.self.name);
          // slackRelayInstance.getTeamInfo(token, _rtm);
          let slackData = slackRelayInstance._cleanSlackInfo(slack.team, slack.self, slack.users, slack.channels, slack.ims);
          console.log('connection authenticated!', slackData);

          // add team info to the private variables
          slackRelayInstance._distributeSlackData(slackData);

          // no need to listen to authentication
          let rtm_out = _rtm.removeListener(RTM_CLIENT_EVENTS.AUTHENTICATED);
          console.log('listener removed!', rtm_out.slackAPIUrl);

        });

        // 1. runs if the token used is invalid
        _rtm.on(RTM_CLIENT_EVENTS.DISCONNECT, (message, cause) => {
          console.log('RTM_CLIENT_EVENTS.DISCONNECT', _rtm.connected, message, cause);

          let rtm_out = _rtm.removeListener(RTM_CLIENT_EVENTS.DISCONNECT);
          clearTimeout(connectionTimeout);
          reject({ok: false, message, data: cause});
        });
        _rtm.on(RTM_CLIENT_EVENTS.WS_ERROR, () => {
          // This will send the message 'this is a test message' to the channel identified by id 'C0CHZA86Q'
          console.log('RTM_CLIENT_EVENTS.WS_ERROR', _rtm.connected, arguments);
          slackRelayInstance.getTeamInfo(token, _rtm);
          clearTimeout(connectionTimeout);
          reject({ok: false, message: 'Unknown error while connecting', data: arguments});
        });

        _rtm.on(RTM_CLIENT_EVENTS.RTM_CONNECTION_OPENED, () => {
          console.log('connection opened!');
          // no need to listen to authentication
          let rtm_out = _rtm.removeListener(RTM_CLIENT_EVENTS.RTM_CONNECTION_OPENED);

          clearTimeout(connectionTimeout);
          resolve(slackRelayInstance);
        });

        // @DONE start the connection
        _rtm.start();
        console.log('connection opened!', _rtm.connected);
        connectionTimeout = setTimeout( () => {
          reject({ok: false, message: 'It is taking too long to connect to slack', data: null});
        }, 5000);

      }

    });

    return promisedResponse;
  }

  /**
   * @public
   * @TODO decide whether this method returns a promise
   */
  disconnect() {
    let _rtm = rtm.get(this);
    console.log('== disconnect ==', _rtm.slackAPIUrl, _rtm.userAgent, _rtm._token, _rtm.token);
    _rtm.disconnect();
  }
  configBot(options) { // set the name, the image ... of your bot
    throw new Error('Not implemented');
  }

  /**
   * @public
   * @param { json/string } message
   * @param { json } user
   */
  send(message, name) { // => message = { text: <> } or <>,  user = { username: <>, userid: <> }
    let self = this;
    return new Promise((resolve, reject) => {

      // rtm.sendMessage('this is a test message', 'C0CHZA86Q', function messageSent() {
      //   // optionally, you can supply a callback to execute once the message has been sent
      // });
      let user, channelid;
      // type: String, Ex: 'Hi there!'
      let text = (typeof message === 'object')? message.text : message;
      // type: Object, ex: { attachments: [{title: 'Time in our offices', fields: [{ key: 'New York - USA', value: '' }, { key: 'Kathmandu - Nepal', value: '' }] }] }
      let data = message.data;

      // .find function is an es6 function
      user = self.users.find(user_param => { return user_param.name === name; }) || {};
      console.log('Found user = ', user);
      // try find user if not then look for named channel

      channelid = user.channelid;
      this._genericSend(text, data, channelid).then(success_object => {
        resolve(success_object);
      }, error_object => {
        reject(error_object);
      }).catch(e => {
        let ok = false,
          data = e,
          message = 'unknown error found';
        reject({ ok, message, data });
      });

      resolve({ok: false, message: 'not sure if message got sent' });

    });
  }

  /**
   * @public
   * post a message to slack channel
   * @param { json/string } message
   * @param { json } user
   */
  post(message, channel_name) {
    // => message = { text: <> } or <>,  user = { username: <>, userid: <> }
    let self = this;
    return new Promise((resolve, reject) => {

      let found_channel, channelid;
      // type: String, Ex: 'Hi there!'
      let text = (typeof message === 'string')? message : message.text;
      // type: Object, ex: { attachments: [{title: 'Time in our offices', fields: [{ key: 'New York - USA', value: '' }, { key: 'Kathmandu - Nepal', value: '' }] }] }
      let data = message.data;

      // .find function is an es6 function
      found_channel = self.channels.find(channel => channel.name === channel_name) || {};
      console.log('Found channel = ',found_channel);
      // try find user if not then look for named channel

      channelid = found_channel.channelid;
      this._genericSend(text, data, channelid).then(success_object => {
        resolve(success_object);
      }, error_object => {
        reject(error_object);
      }).catch(e => {
        let ok = false,
          data = e,
          message = 'unknown error found';
        reject({ ok, message, data });
      });

    });
  }

  /**
   * @private
   * send messages on behalf of "send" or "post" method
   * @param {String} text
   * @param {} data
   * @param {String} channelid
   * @return {Promise} just to make sure we can easily catch unknown errors
   */
  _genericSend(text, data, channelid) {
    let _rtm = rtm.get(this);
    let _web = web.get(this);
    return new Promise((resolve, reject) => {

      if (data) {
        _web.chat.postMessage(channelid, text, data, (err, result) => {
          console.log('done', err, result);
        });
      } else if(text) {
        _rtm.sendMessage(text, channelid, (err, result) => {
          console.log('done sending message', err, result);
        });
      } else {
        // @DONE reject saying the some info is missing => this will run if both text and data params are undefined
        reject({ok: false, message: 'no message to send', data: null });
      }

      resolve({ok: true, message: 'not sure if message got sent' });

    });
  }

  /**
   * @public
   */
  broadcast(message) {

    return new Promise((resolve, reject) => { reject({ok: false, message: 'not implemented yet' }); });
  }

  /**
   * @public
   * Send a notification to slack user of channel (ex: notify user that you are typing)
   * @param {String} name a user name or channel name
   * @param {String} notification a notification type similar to event type from slack API ( https://api.slack.com/events )
   * @return {Promise} which resolve with a success object or rejects with an error object
   */
  notify(name, notification) {
    let _rtm, _users, _channels, user, channel, channelid;
    _rtm = rtm.get(this);

    user = this.users.find(user_param => { return user_param.name === name; }) || {};
    channel = this.channels.find(channel_param => channel_param.name === name) || {};

    channelid = user.channelid || channel.channelid;

    // only one notification type is supported 'user_typing'
    _rtm.sendTyping(channelid);

  }

  /**
   * @public
   * @param {String or Array} message_type ex: direct_message, direct_mention, mention, ambient
   * @param {Function} handler function to run once the message is received
   * @return {Promise} just to make sure we can easily catch unknown errors
   */
  listen(message_type, handler) {
    // let _rtm = rtm.get(this);
    // let _me = me.get(this);
    // let _handlerHolder = handler_holder.get(this);
    let _event_emitter = event_emitter.get(this);
    // let _users = this.users;
    // let _channels = this.channels;
    // let self = this;
    return new Promise((resolve, reject) => {

      if (typeof handler !== 'function') {
        reject({ok: false, message: 'the handler must be a function' });
      }

      // add it to the list of events to listen to
      if (typeof message_type === 'string') { // input is a string
        _event_emitter.on(message_type, handler);
      } else if ( message_type && message_type.length) { // input is non-empty array
        message_type.forEach(mt => {
          _event_emitter.on(mt, handler);
        });
      } else {
        // tell user about supported message types
        let ok = false;
        let message = 'supported types are direct_message, direct_mention, mention, and ambient';
        let data = message_type;
        reject({ok, message, data });
      }

      resolve({ok: true, message: 'A message listener has just been turned on!', data: handler});

    });
  }

  /**
   * @private
   * exctract userful information and throw away the rest
   */
  _cleanSlackInfo(team, me, users, channels, ims) {

    let team_clean = { name: team.name, domain: team.domain };

    // console.log('The id of this bot:');
    let me_clean = { name: me.name, userid: me.id, channelid: undefined }; // { name: '@<user>', userid: '<user-id>', channelid: '<channel-id>' };
    // console.log('The id of this bot:', me);

    let all_users = [];
    let members = users.map( member => { return { name: member.name, userid: member.id, channelid: undefined}; } );
    // console.log('Users Info:', members);
    members.forEach( mb => all_users.push(mb) ); // mb : members

    let channels_clean = [];
    let local_channels = channels.map( channel => { return { name: channel.name, userid: undefined, channelid: channel.id }; } );
    // console.log('Channels Info:', local_channels);
    local_channels.forEach( am => channels_clean.push(am) ); // am : ambient message

    let ims_clean = [];
    let dms = ims.map( channel => { return { name: undefined, userid: channel.user, channelid: channel.id }; } );
    // console.log('Direct Message Info:', dms);
    dms.forEach(dm => ims_clean.push(dm) ); // dm : direct message
    //add channelids to the user list
    all_users.forEach(user => { let cid = ( ims_clean.filter(channel => { return channel.userid === user.userid; })[0] || {} ).channelid; user.channelid = cid; });

    //-- START user filtering
    let users_clean = all_users.filter(user => { return user.channelid; }); // any user object with no channel is not userful

    // console.log('========= START Reachable Users ========');
    // console.log(users_clean, all_users);
    // console.log('========= END   Reachable Users ========');
    //-- END user filtering

    return { team_clean, me_clean, users_clean, channels_clean, ims_clean, all_users };
  }

  /**
   * @private
   * helper method to add fetched data to respective holders
   */
  _distributeSlackData(slackData) {
    // add team info to the private variables
    team.set(this, slackData.team_clean);
    me.set(this, slackData.me_clean);
    users.set(this, slackData.users_clean);
    channels.set(this, slackData.channels_clean);
  }

  /***** END   get team users and channels *****/

  /**
   * @private
   * call the rtm.on function on
   */
  _intializeSlackListener() {
    let _rtm = rtm.get(this);

    let self = this;
    _rtm.on(RTM_EVENTS.MESSAGE, function handleRtmMessage(message) {
      let _me = me.get(self);
      let _event_emitter = event_emitter.get(self);
      let _users = self.users;
      let _channels = self.channels;

      let bot_id = _me.userid;
      let found_channel = _channels.find(channel_param => channel_param.channelid === message.channel) || {},
        found_user = _users.find(user_param => user_param.channelid === message.channel) || {};
      // direct_message : message.user = ( _users.find(user => user.channelid === message.channel) || {} ).userid
      let direct_message = message.user === found_user.userid;
      console.log('Message:', bot_id, message, direct_message);
      let clean_message, parsed_message, user, channel, event;
      event = 'direct_message';

      if (direct_message) {
        parsed_message = message.text;
        user = found_user.name;
        // @DONE make channel is undefined => it is not set to anything from it creation to this point
      } else {
        clean_message = self.message_separation(message.text || '', bot_id);
        parsed_message = clean_message.message;
        event = clean_message.event; // @DONE respond to messages depending on the event type, right now the channel_type is being ignored
        // lookup channel name
        channel = found_channel.name;
        // when posting message to chanel it is a good idea to tell the receiver about who posted
        user = found_user.name;
      }

      // @DONE run the handler function
      // @DONE right now message object is a string
      console.log('event type: ', event);

      _event_emitter.emit(event, self, parsed_message, user, channel);

    });
  }

  // @TODO remove this at the end of implemention
  message_separation(message, bot_id) {
    var event;
    var direct_mention = new RegExp('^\<\@' + bot_id + '\>', 'i');
    var mention = new RegExp('\<\@' + bot_id + '\>', 'i');

    if (message.match(direct_mention)) {
        // this is a direct mention
        message = message.replace(direct_mention, '')
        .replace(/^\s+/, '').replace(/^\:\s+/, '').replace(/^\s+/, '');
        event = 'direct_mention';

        return { message, event };
    } else if (message.match(mention)) {
        event = 'mention';
        return { message, event };
    } else {
        event = 'ambient';
        return { message, event };
    }

  }

}

module.exports = SlackRelay;