UNPKG

node-flint

Version:
2,110 lines 58.7 kB
'use strict';

var EventEmitter = require('events').EventEmitter;
EventEmitter.prototype._maxListeners = 0;
var sequence = require('when/sequence');
var moment = require('moment');
var Spark = require('node-sparky');
var _debug = require('debug')('flint');
var util = require('util');
var when = require('when');
var path = require('path');
var _ = require('lodash');

var MemStore = require('../storage/memory');

var Bot = require('./bot');
var u = require('./utils');

/**
 * Creates an instance of Flint.
 *
 * @constructor Flint
 * @param {Object} options - Configuration object containing Flint settings.
 * @property {string} id - Flint UUID
 * @property {boolean} active - Flint active state
 * @property {boolean} intialized - Flint fully initialized
 * @property {boolean} isBotAccount - Is Flint attached to Spark using a bot account?
 * @property {boolean} isUserAccount - Is Flint attached to Spark using a user account?
 * @property {object} person - Flint person object
 * @property {string} email - Flint email
 * @property {object} spark - The Spark instance used by flint
 *
 * @example
 * var options = {
 *   webhookUrl: 'http://myserver.com/flint',
 *   token: 'Tm90aGluZyB0byBzZWUgaGVyZS4uLiBNb3ZlIGFsb25nLi4u'
 * };
 * var flint = new Flint(options);
 */
function Flint(options) {
  EventEmitter.call(this);

  this.id = options.id || u.genUUID64();

  /**
   * Options Object
   *
   * @memberof Flint
   * @instance
   * @namespace options
   * @property {string} token - Spark Token.
   * @property {string} webhookUrl - URL that is used for SPark API to send callbacks.
   * @property {string} [webhookSecret] - If specified, inbound webhooks are authorized before being processed.
   * @property {string} [messageFormat=text] - Default Spark message format to use with bot.say().
   * @property {number} [maxPageItems=50] - Max results that the paginator uses.
   * @property {number} [maxConcurrent=3] - Max concurrent sessions to the Spark API
   * @property {number} [minTime=600] - Min time between consecutive request starts.
   * @property {number} [requeueMinTime=minTime*10] - Min time between consecutive request starts of requests that have been re-queued.
   * @property {number} [requeueMaxRetry=3] - Msx number of atteempts to make for failed request.
   * @property {array} [requeueCodes=[429,500,503]] - Array of http result codes that should be retried.
   * @property {number} [requestTimeout=20000] - Timeout for an individual request recieving a response.
   * @property {number} [queueSize=10000] - Size of the buffer that holds outbound requests.
   * @property {number} [requeueSize=10000] - Size of the buffer that holds outbound re-queue requests.
   * @property {string} [id=random] - The id this instance of flint uses.
   * @property {string} [webhookRequestJSONLocation=body] - The property under the Request to find the JSON contents.
   * @property {Boolean} [removeWebhooksOnStart=true] - If you wish to have the bot remove all account webhooks when starting.
   */
  this.options = options;

  this.active = false;
  this.initialized = false;
  this.storageActive = false;
  this.isBotAccount = false;
  this.isUserAccount = false;
  this.person = {};
  this.email;

  // define location in webhook request to find json values of incoming webhook.
  // note: this is typically 'request.body' but depending on express/restify configuration, it may be 'request.params'
  this.options.webhookRequestJSONLocation = this.options.webhookRequestJSONLocation || 'body';

  // define if flint remove all webhooks attached to token on start (if not defined, defaults to true)
  this.options.removeWebhooksOnStart = typeof this.options.removeWebhooksOnStart === 'boolean' ? this.options.removeWebhooksOnStart : true;

  // define default messageFormat used with bot.say (if not defined, defaults to 'text')
  if(typeof this.options.messageFormat === 'string' && _.includes(['text', 'markdown', 'html'], _.toLower(this.options.messageFormat))) {
    this.messageFormat = _.toLower(this.options.messageFormat);
  } else {
    this.messageFormat = 'text';
  }

  this.batchDelay = options.minTime * 2;
  this.auditInterval;
  this.auditDelay = 300;
  this.auditCounter = 0;
  this.logs = [];
  this.logMax = 1000;
  this.lexicon = [];
  this.bots = [];
  this.spark = {};
  this.webhook = {};
  this.cardsWebhook = {};

  // register internal events
  this.on('error', err => {
    if(err) {
      console.err(err.stack);
    }
  });
  this.on('start', () => {
    require('./logs')(this);
    this.initialize();
  });
}
util.inherits(Flint, EventEmitter);

/**
 * Internal logger function.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} message - Message to log
 * @returns {string} Formatted message
 */
Flint.prototype.log = function(message) {
  if(this.log.length > this.logMax) {
    this.log = this.log.slice(this.log.length - this.logMax);
  }
  message = (moment().utc().format('YYYY-MM-DD HH:mm:ss') + ' ' + message);
  this.logs.push(message);

  /**
   * Flint log event.
   *
   * @event log
   * @property {string} message - Log Message
   */
  this.emit('log', message);
  return message;
};

/**
 * Internal debug function.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} message - Message to debug
 * @returns {null}
 */
Flint.prototype.debug = function(message) {
  message = util.format.apply(null, Array.prototype.slice.call(arguments));

  if(typeof this.debugger === 'function') {
    this.debugger(message, this.id);
  } else {
    _debug(message);
  }
};

/**
 * Tests, and then sets a new Spark Token.
 *
 * @function
 * @memberof Flint
 * @param {String} token - New Spark Token for Flint to use.
 * @returns {Promise.<String>}
 *
 * @example
 * flint.setSparkToken('Tm90aGluZyB0byBzZWUgaGVyZS4uLiBNb3ZlIGFsb25nLi4u')
 *   .then(function(token) {
 *      console.log('token updated to: ' + token);
 *   });
 */
Flint.prototype.setSparkToken = function(token) {
  return this.testSparkToken(token)
    .then(token => {
      this.options.token = token;
      return when(token);
    })
    .catch(() => {
      when.reject(new Error('could not change token, token not valid'));
    });
};

/**
 * Test a new Spark Token.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} token - Test if Token is valid by attempting a simple Spark API Call.
 * @returns {Promise.<String>}
 *
 * @example
 * flint.testSparkToken('Tm90aGluZyB0byBzZWUgaGVyZS4uLiBNb3ZlIGFsb25nLi4u')
 *   .then(function() {
 *     console.log('token valid');
 *   })
 *   .catch(function(err) {
 *     console.log(err.message);
 *   });
 */
Flint.prototype.testSparkToken = function(token) {
  var testOptions = _.clone(this.options);
  testOptions.token = token;
  var testSpark = new Spark(testOptions);

  return testSpark.membershipsGet()
    .then(memberships => {
      testSpark = {};
      return when(token);
    })
    .catch(() =>{
      return when.reject(new Error('token not valid'));
    });
};

/**
 * Stop Flint.
 *
 * @function
 * @memberof Flint
 * @returns {Promise.<Boolean>}
 *
 * @example
 * flint.stop();
 */
Flint.prototype.stop = function() {

  // if not stopped...
  if(this.active) {

    return cleanupListeners(this)
      .then(() => {
        if(this.auditInterval) clearInterval(this.auditInterval);

        /**
         * Flint stop event.
         *
         * @event stop
         * @property {string} id - Flint UUID
         */
        this.emit('stop', this.id);

        return when.map(this.bots, bot => {
          bot.stop();
          return when(true);
        });
      })

      .then(() => {
        this.bots = [];
        this.spark = {};
        this.webhook = {};
        this.cardsWebhook = {};
        this.active = false;
        this.initialized = false;
        return when(true);
      });

  } else {
    return when(false);
  }
};

/**
 * Start Flint.
 *
 * @function
 * @memberof Flint
 * @returns {Promise.<Boolean>}
 *
 * @example
 * flint.start();
 */
Flint.prototype.start = function() {

  // if not started...
  if(!this.active) {

    // init storage default storage driver if start is called before
    if(!this.storageActive) {
      // define default storage module
      this.storageDriver(new MemStore());
    }

    // init spark
    this.spark = new Spark(this.options);

    // determine bot identity
    return this.spark.personMe()
      .then(person => this.getPerson(person.emails[0]))

      // get updqated person object
      .then(person => {
        this.person = person;
        this.email = person.email;

        // check if account is bot or user account
        if(this.person.domain === 'sparkbot.io' || this.person.domain === 'webex.bot') {
          this.isBotAccount = true;
          this.isUserAccount = false;
        } else {
          this.isBotAccount = false;
          this.isUserAccount = true;
        }

        return when(person);
      })

      // Configure webhooks or websockets
      .then(() => {
        if (this.options.webhookUrl) {
          // get webhooks
          this.getWebhooks()

            // process webhooks
            .then(webhooks => {

              // remove only webhooks this app created
              if(!this.options.removeWebhooksOnStart) {

                var webhooksToRemove = _.filter(webhooks, webhook => {
                  return (webhook.name == u.base64encode(this.options.webhookUrl.split('/')[2] + ' ' + this.email));
                });

                if(webhooksToRemove instanceof Array && webhooksToRemove.length > 0) {
                  return when.map(webhooksToRemove, webhook => this.spark.webhookRemove(webhook.id));
                } else {
                  return when(true);
                }
              }

              // else, remove all webhooks on start
              else {
                return when.map(webhooks, webhook => this.spark.webhookRemove(webhook.id));
              }
            })

            .then(() => {
              if(this.options.webhookUrl) {
                this.spark.webhookAdd('all', 'all', u.base64encode(this.options.webhookUrl.split('/')[2] + ' ' + this.email))
                  .then(webhook => {
                    this.webhook = webhook;
                    return this.spark.webhookAdd('attachmentActions', 'created', u.base64encode(this.options.webhookUrl.split('/')[2] + ' ' + this.email))
                      .then(webhook => {
                        this.cardsWebhook = webhook;
                        return when(webhook);
                      })
                      .catch(() => {
                        this.webhook = false;
                        return when(false);
                      });
                  })
                  .catch(() => {
                    this.webhook = false;
                    return when(false);
                  });
              } else {
                this.webhook = false;
                return when(false);
              }
            });
        } else {
          // There was no webhookUrl specified so we will use websockets instead
          var webhook = require('./webhook');
          let Websocket = require('./websocket');
          this.websocket = new Websocket(this, webhook);
          return this.websocket.init();
        }
      })

      // start
      .then(() => {
        /**
         * Flint start event.
         *
         * @event start
         * @property {string} id - Flint UUID
         */
        this.emit('start', this.id);
        this.active = true;
        return when(true);
      })

      // setup auditor
      .then(() => {
        this.auditInterval = setInterval(() => {
          this.auditBots();
        }, 1000);
        return when(true);
      })

      // handle errors
      .catch(err => {
        throw err;
      });
  } else {
    return when(false);
  }
};

/**
 * Initialize Flint.
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise.<Boolean>}
 *
 * @example
 * flint.initialize();
 */
Flint.prototype.initialize = function() {
  // spawn bots in existing rooms at startup
  return this.spark.membershipsGet()
    .then(memberships => {

      // create batch
      var batch = _.map(memberships, m => {
        return () => this.spawn(m.roomId);
      });

      // run batch
      return sequence(batch)
        .then(() => when(true))
        .catch(err => {
          this.debug(err.stack);
          return when(true);
        });
    })

    .then(() => {
      /**
       * Flint initialized event.
       *
       * @event initialized
       * @property {string} id - Flint UUID
       */
      this.emit('initialized', this.id);
      this.initialized = true;
      return when(true);
    });

};

/**
 * Restart Flint.
 *
 * @function
 * @memberof Flint
 * @returns {Promise.<Boolean>}
 *
 * @example
 * flint.restart();
 */
Flint.prototype.restart = function() {
  return this.stop()
    .then(stopped => {
      if(stopped) {
        return this.start();
      } else {
        return when(false);
      }
    });
};

/**
 * Audit bot objects to verify they are in sync with the Spark API.
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise.<Bot>}
 *
 * @example
 * flint.auditBots();
 */
Flint.prototype.auditBots = function() {
  // only run if Flint has initialized
  if(!this.initialized) {
    return when(true);
  }

  // increment counter
  this.auditCounter++;

  // reset counter when counter exceeds max
  if(this.auditCounter > this.auditDelay) {
    this.auditCounter = 0;
  }

  // update flint.person
  if(this.auditCounter === 0) {
    this.getPerson(this.person.email)
      .then(person => {
        this.person = person;
      })
      .catch(err => this.debug(err.stack));
  }

  // remove duplicate bots
  if(this.auditCounter % 5 === 0) {
    var uniqBots = _.uniqBy(this.bots, bot => bot.room.id);
    var botsToRemove = _.differenceBy(this.bots, uniqBots, 'id');
    _.forEach(botsToRemove, bot => this.despawn(bot.room.id).catch(() => true));
  }

  // check for zombies
  if(this.auditCounter === (this.auditDelay - 1)) {
    this.getRooms()
      .then(rooms => {
        var roomsToAdd = _.differenceBy(rooms, _.map(this.bots, bot => bot.room), 'id');
        _.forEach(roomsToAdd, room => this.spawn(room.id));
      })
      .catch(() => {
        return when(true);
      });
  }

  // exit rooms where bot is only member
  if(this.auditCounter === (this.auditDelay - 1)) {
    _.forEach(this.bots, bot => {
      if(bot.memberships.length === 0 && bot.isGroup && !bot.isTeam) {
        bot.exit();
      }
    });
  }

  return when.map(this.bots, bot => {
    // if auditDelay < bot auditTrigger, reset bot audit trigger
    if(this.auditDelay <= bot.auditTrigger) {
      bot.auditTrigger = Math.floor((Math.random() * this.auditDelay)) + 1;
    }

    // if bot.auditTrigger matches current count inside auditDelay range
    if(this.initialized && bot.auditTrigger === this.auditCounter) {

      // room
      var room = () => this.getRoom(bot.room.id)
        .then(room => {
          // Fix the occassional old room with missing title
          if(typeof room.title === 'undefined' || room.title.trim() === '') {
            room.title = 'Default title';
          }
          return this.onRoomUpdated(room);
        })
        .catch(err => {
          this.debug(err.stack);
          return when(true);
        });

      // membership
      var membership = () => this.getMembership(bot.membership.id)
        .then(membership => this.onMembershipUpdated(membership))
        .catch(err => {
          this.debug(err.stack);
          return when(true);
        });

      // memberships
      var memberships = () => this.getMemberships(bot.room.id)
        .then(memberships => when.map(memberships, membership => this.onMembershipUpdated(membership)))

        .catch(err => {
          this.debug(err.stack);
          return when(true);
        });

      return sequence([room, membership, memberships]);
    }

    else {
      return when(true);
    }
  });
};

/**
 * Parse a message object.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} message - Message Object
 * @returns {Promise.<Message>}
 */
Flint.prototype.parseMessage = function(message) {

  /**
   * Message Object
   *
   * @namespace Message
   * @property {string} id - Message ID
   * @property {string} personId - Person ID
   * @property {string} personEmail - Person Email
   * @property {string} personAvatar - PersonAvatar URL
   * @property {string} personDomain - Person Domain Name
   * @property {string} personDisplayName - Person Display Name
   * @property {string} roomId - Room ID
   * @property {string} text - Message text
   * @property {array} files - Array of File objects
   * @property {date} created - Date Message created
   */

  message.created = moment(message.created).utc().toDate();
  message.personEmail = _.toLower(message.personEmail);

  // parse message text
  if(message.text) {

    // capture raw message
    message.raw = message.text;

    // trim leading whitespace
    message.text = message.text.trim();

    // replace carriage returns / new lines with a space
    message.text = message.text.replace(/[\n\r]+/g, ' ');

    // remove all consecutive white space characters
    message.text = message.text.replace(/\s\s+/g, ' ');
  }

  return when(true)
    .then(() => {
      return this.getPerson(message.personEmail)
        .then(person => {
          message.personDisplayName = person.displayName;
          message.personDomain = person.domain;
          message.personAvatar = person.avatar || false;
          return when(message);
        })
        .catch(() => {
          message.personDisplayName = message.personEmail;
          message.personDomain = 'unknown';
          return when(message);
        });
    })
    .catch(() => {
      return when(message);
    });
};

/**
 * Parse a File from Message.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} message - Previously parsed Message Object
 * @returns {Promise.<Message>}
 */
Flint.prototype.parseFile = function(message) {

  /**
   * File Object
   *
   * @namespace File
   * @property {string} id - Spark API Content ID
   * @property {string} name - File name
   * @property {string} ext - File extension
   * @property {string} type - Header [content-type] for file
   * @property {buffer} binary - File contents as binary
   * @property {string} base64 - File contents as base64 encoded string
   * @property {string} personId - Person ID of who added file
   * @property {string} personEmail - Person Email of who added file
   * @property {string} personAvatar - PersonAvatar URL
   * @property {string} personDomain - Person Domain Name
   * @property {string} personDisplayName - Person Display Name
   * @property {date} created - Date file was added to room
   */

  // parse message files
  if(message.files && message.files instanceof Array) {
    var parsedMessage = _.clone(message);

    return when.map(parsedMessage.files, url => this.spark.contentByUrl(url))
      .then(files => {
        _.forEach(files, file => {
          file.personId = parsedMessage.personId;
          file.personEmail = parsedMessage.personEmail;
          file.personDisplayName = parsedMessage.personDisplayName;
          file.personAvatar = parsedMessage.personAvatar;
          file.personDomain = parsedMessage.personDomain;
          file.created = parsedMessage.created;
        });
        parsedMessage.files = files;
        return when(parsedMessage);
      })
      .catch(() => {
        return when(message);
      });
  } else {
    return when(message);
  }
};

/**
 * Creates Trigger Object from messageId.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Webhook} messageData - Webhook object from message created webhook
 * @returns {Promise.<Trigger>}
 */
Flint.prototype.getTrigger = function(messageId) {

  /**
   * Trigger Object
   *
   * @namespace Trigger
   * @property {string} id - Message ID
   * @property {(string|regex)} phrase - Matched lexicon phrase
   * @property {string} text - Message Text (or false if no text)
   * @property {string} raw - Unprocessed Message Text (or false if no text)
   * @property {string} html - Message HTML (or false if no html)
   * @property {string} markdown - Message Markdown (or false if no markdown)
   * @property {array} mentionedPeople - Mentioned People (or false if no mentioned)
   * @property {array} files - Message Files (or false if no files in trigger)
   * @property {array} args - Filtered array of words in message text.
   * @property {date} created - Message Created date
   * @property {string} roomId - Room ID
   * @property {string} roomTitle - Room Title
   * @property {string} roomType - Room Type (group or direct)
   * @property {boolean} roomIsLocked - Room Locked/Moderated status
   * @property {string} personId - Person ID
   * @property {string} personEmail - Person Email
   * @property {string} personDisplayName - Person Display Name
   * @property {string} personUsername - Person Username
   * @property {string} personDomain - Person Domain name
   * @property {string} personAvatar - Person Avatar URL
   * @property {object} personMembership - Person Membership object for person
   */
  var trigger = {};

  return this.getMessage(messageId)
    .then(message => {

      trigger.id = message.id;
      trigger.text = message.text || false;
      trigger.raw = message.raw || false;
      trigger.html = message.html || false;
      trigger.markdown = message.markdown || false;
      trigger.args = trigger.text ? trigger.text.split(' ') : [];
      trigger.mentionedPeople = message.mentionedPeople || false;
      trigger.created = message.created;

      var room = this.getRoom(message.roomId)
        .then(room => {

          trigger.roomId = room.id;
          trigger.roomTitle = room.title;
          trigger.roomType = room.type;
          trigger.roomIsLocked = room.isLocked;

          return when(true);
        });

      var person = this.getPerson(message.personEmail)
        .then(person => {

          trigger.personId = person.id;
          trigger.personEmail = person.email;
          trigger.personUsername = person.username;
          trigger.personDomain = person.domain;
          trigger.personDisplayName = person.displayName;
          trigger.personAvatar = person.avatar;

          return when(true);
        });

      var membership = this.getMemberships(message.roomId)
        .then(memberships => _.find(memberships, {'personId': message.personId}))
        .then(membership => {

          trigger.personMembership = membership;

          return when(true);
        });

      var files = this.parseFile(message)
        .then(message => {
           trigger.files = message.files || false;
           return when(true);
        });

      return when.all([room, person, membership, files])
        .then(() => when(trigger));
    });
};

/**
 * Get Rooms
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise.<Array>}
 */
Flint.prototype.getRooms = function() {
  return this.spark.roomsGet()
    .then(rooms => {
      return when.map(rooms, room => {
        room.lastActivity = moment(room.lastActivity).utc().toDate();
        room.created = moment(room.created).utc().toDate();
        room.added = moment().utc().toDate();
        return when(room);
      });
    });
};

/**
 * Get Room Object By ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} roomId - Room ID from Spark API.
 * @returns {Promise.<Room>}
 */
Flint.prototype.getRoom = function(roomId) {
  return this.spark.roomGet(roomId)
    .then(room => {
      room.lastActivity = moment(room.lastActivity).utc().toDate();
      room.created = moment(room.created).utc().toDate();
      room.added = moment().utc().toDate();

      return when(room);
    });
};

/**
 * Get Teams
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise.<Array>}
 */
Flint.prototype.getTeams = function() {
  return this.spark.teamsGet()
    .then(teams => {
      return when.map(teams, team => {
        team.created = moment(team.created).utc().toDate();
        return when(team);
      });
    });
};

/**
 * Get Team Object By ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} teamId - Team ID from Spark API.
 * @returns {Promise.<Team>}
 */
Flint.prototype.getTeam = function(teamId) {
  return this.spark.teamGet(teamId)
    .then(team => {
      team.created = moment(team.created).utc().toDate();
      return when(team);
    });
};

/**
 * Get Team Rooms
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} teamId - Room ID from Spark API
 * @returns {Promise.<Array>}
 */
Flint.prototype.getTeamRooms = function(teamId) {
  return this.spark.roomsByTeam(teamId)
    .then(rooms => {
      return when.map(rooms, room => {
        room.lastActivity = moment(room.lastActivity).utc().toDate();
        room.created = moment(room.created).utc().toDate();
        return when(room);
      });
    });
};

/**
 * Get Person Object By Email
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} personEmail - Person Email of Spark Account
 * @returns {Promise.<Person>}
 */
Flint.prototype.getPerson = function(personEmail) {
  return this.spark.personByEmail(personEmail)
    .then(person => {
      person.created = moment(person.created).utc().toDate();
      person.emails = _.forEach(person.emails, email => _.toLower(email));
      person.email = _.toLower(person.emails[0]);
      person.username = _.split(person.email, '@', 2)[0];
      person.domain = _.split(person.email, '@', 2)[1];
      person.avatar = person.avatar || '';

      return when(person);
    });
};

/**
 * Get Message Object by ID
 *
 * @function
 * @memberof Flint
 * @param {String} messageId - Message ID from Spark API.
 * @returns {Promise.<Message>}
 */
Flint.prototype.getMessage = function(messageId) {
  return this.spark.messageGet(messageId)
    .then(message => this.parseMessage(message));
};

/**
 * Get Files from Message Object by ID
 *
 * @function
 * @memberof Flint
 * @param {String} messageId - Message ID from Spark API.
 * @returns {Promise.<Array>}
 */
Flint.prototype.getFiles = function(messageId) {
  return this.spark.messageGet(messageId)
    .then(message => this.parseMessage(message))
    .then(message => this.parseFile(message))
    .then(message => {
      if(typeof message.files !== undefined && message.files instanceof Array) {
        return when(message.files);
      } else {
        return when.reject(new Error('no files found in message'));
      }
    });
};

/**
 * Get Membership Object by ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} membershipId - Membership ID from Spark API.
 * @returns {Promise.<Membership>}
 */
Flint.prototype.getMembership = function(membershipId) {
  return this.spark.membershipGet(membershipId)
    .then(membership => {
      membership.created = moment(membership.created).utc().toDate();
      membership.personEmail = _.toLower(membership.personEmail);
      membership.email = membership.personEmail;

      return when(membership);
    });
};

/**
 * Get Memberships by Room ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} [roomId] - Room ID from Spark API.
 * @returns {Promise.<Array>}
 * Promise fulfilled with Array of updated Membership objects.
 */
Flint.prototype.getMemberships = function(roomId) {
  if(!roomId) {
    return this.spark.membershipsGet()
      .then(memberships => {
        return when.map(memberships, membership => {
          membership.created = moment(membership.created).utc().toDate();
          membership.personEmail = _.toLower(membership.personEmail);
          membership.email = membership.personEmail;

          return when(membership);
        });
      });
  }

  else {
    return this.spark.membershipsByRoom(roomId)
      .then(memberships => {
        return when.map(memberships, membership => {
          membership.created = moment(membership.created).utc().toDate();
          membership.personEmail = _.toLower(membership.personEmail);
          membership.email = membership.personEmail;

          return when(membership);
        });
      });
  }
};

/**
 * Get Team Membership Object by ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} teamMembershipId - Team Membership ID from Spark API.
 * @returns {Promise.<TeamMembership>}
 */
Flint.prototype.getTeamMembership = function(teamMembershipId) {

  return this.spark.teamMembershipGet(teamMembershipId)
    .then(membership => {
      membership.created = moment(membership.created).utc().toDate();
      membership.personEmail = _.toLower(membership.personEmail);
      membership.email = membership.personEmail;

      return when(membership);
    });
};

/**
 * Get Memberships by Team ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} teamId - Team ID from Spark API.
 * @returns {Promise.<Array>}
 */
Flint.prototype.getTeamMemberships = function(teamId) {
  if(teamId) {
    return this.spark.teamMembershipsGet(teamId)
      .then(teamMemberships => {
        return when.map(teamMemberships, teamMembership => {
          teamMembership.created = moment(teamMembership.created).utc().toDate();
          teamMembership.personEmail = _.toLower(teamMembership.personEmail);
          teamMembership.email = teamMembership.personEmail;

          return when(teamMembership);
        });
      });
  }

  else {
    return when.reject(new Error('missing teamId parameter'));
  }
};

/**
 * Get Webhook Object by ID
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} webhookId - Webhook ID from Spark API.
 * @returns {Promise.<Webhook>}
 */
Flint.prototype.getWebhook = function(webhookId) {
  return this.spark.webhookGet(webhookId)
    .then(webhook => {
      webhook.created = moment(webhook.created).utc().toDate();
      if(typeof webhook.filter === 'string') {
        if(webhook.filter.split('=')[0] === 'roomId') {
          webhook.roomId = webhook.filter.split('=')[1];
        }
      }

      return when(webhook);
    });
};

/**
 * Get Webhooks
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise.<Array>}
 */
Flint.prototype.getWebhooks = function() {
  return this.spark.webhooksGet()
    .then(webhooks => {
      webhooks = _.forEach(webhooks, webhook => {
        webhook.created = moment(webhook.created).utc().toDate();
        if(typeof webhook.filter === 'string') {
          if(webhook.filter.split('=')[0] === 'roomId') {
            webhook.roomId = webhook.filter.split('=')[1];
          }
        }
      });
      return when(webhooks);
    });
};

/**
 * Get Attachement Action by ID
 *
 * @function
 * @memberof Flint
 * @param {String} attachmentActionId - attachmentActionID from Spark API.
 * @returns {Promise.<AttachmentAction>}
 */
Flint.prototype.getAttachmentAction = function(attachmentActionId) {
  return this.spark.attachmentActionGet(attachmentActionId);
};


/**
 * Process a Room create event.
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise}
 */
Flint.prototype.onRoomCreated = function(room) {
  var bot = _.find(this.bots, bot => bot.room.id === room.id);
  if(bot) {
    bot.lastActivity = moment().utc().toDate();
  }
  return when(true);
};

/**
 * Process a Room update event.
 *
 * @function
 * @memberof Flint
 * @private
 * @returns {Promise}
 */
Flint.prototype.onRoomUpdated = function(room) {
  var bot = _.find(this.bots, bot => bot.room.id === room.id);
  if(bot) bot.lastActivity = moment().utc().toDate();

  // if bot exists in monitored room...
  if(bot) {
    //update bot
    bot.room = room;
    bot.isGroup = (room.type === 'group');
    bot.isDirect = (room.type === 'direct');

    // if team
    if(typeof room.teamId !== 'undefined') {
      bot.isTeam = true;
      bot.teamId = room.teamId;
    } else {
      bot.isTeam = false;
      bot.teamId = null;
    }

    // emit event locked
    if(bot.isLocked != room.isLocked && room.isLocked) {
      bot.isLocked = room.isLocked;

      /**
       * Room Locked event.
       *
       * @event roomLocked
       * @property {object} bot - Bot Object
       * @property {string} id - Flint UUID
       */
      this.emit('roomLocked', bot, this.id);
      bot.emit('roomLocked', bot, bot.id);

      return when(true);
    }

    // emit event unLocked
    else if(bot.isLocked != room.isLocked && !room.isLocked) {
      bot.isLocked = room.isLocked;
      /**
       * Room Unocked event.
       *
       * @event roomUnocked
       * @property {object} bot - Bot Object
       * @property {string} id - Flint UUID
       */
      this.emit('roomUnlocked', bot, this.id);
      bot.emit('roomUnlocked', bot, bot.id);
      return when(true);
    }

    else {
      return when(true);
    }
  }

  // else bot does not exist in monitored room
  else {
    return when(true);
  }
};

/**
 * Process a new Membership event.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} membership - Spark Team Membership Object
 * @returns {Promise}
 */
Flint.prototype.onMembershipCreated = function(membership) {
  var bot = _.find(this.bots, bot => bot.room.id === membership.roomId);
  if(bot) bot.lastActivity = moment().utc().toDate();

  // if bot membership added to un-monitored room...
  if(!bot && this.initialized && membership.personEmail === this.person.email) {
    // spawn bot
    return this.spawn(membership.roomId);
  }

  // else if other membership added to monitored room...
  else if(bot) {

    // add new membership to bot.memberships
    bot.memberships.push(membership);

    return this.getPerson(membership.personEmail)
      .then(person => {

        /**
         * Person Enter Room event.
         *
         * @event personEnters
         * @property {object} bot - Bot Object
         * @property {object} person - Person Object
         * @property {string} id - Flint UUID
         */
        this.emit('personEnters', bot, person, this.id);
        bot.emit('personEnters', bot, person, bot.id);
        return when(true);
      });
  }

  // else, bot not found and membership added for other user
  else {
    return when(true);
  }
};

/**
 * Process a updated Membership event.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} membership - Spark Membership Object
 * @returns {Promise}
 */
Flint.prototype.onMembershipUpdated = function(membership) {
  var bot = _.find(this.bots, bot => bot.room.id === membership.roomId);
  if(bot) bot.lastActivity = moment().utc().toDate();

  // if membership updated in monitored room
  if(bot && membership.personEmail === this.person.email) {
    // update bot membership
    bot.membership = membership;

    // emit event Moderator
    if(bot.isModerator != membership.isModerator && membership.isModerator) {
      bot.isModerator = membership.isModerator;

      /**
       * Bot Added as Room Moderator.
       *
       * @event botAddedAsModerator
       * @property {object} bot - Bot Object
       * @property {string} id - Flint UUID
       */
      this.emit('botAddedAsModerator', bot, this.id);
      bot.emit('botAddedAsModerator', bot, bot.id);

      return when(true);
    }

    // emit event not Moderator
    else if(bot.isModerator != membership.isModerator && !membership.isModerator) {
      bot.isModerator = membership.isModerator;

      /**
       * Bot Removed as Room Moderator.
       *
       * @event botRemovedAsModerator
       * @property {object} bot - Bot Object
       * @property {string} id - Flint UUID
       */
      this.emit('botRemovedAsModerator', bot, this.id);
      bot.emit('botRemovedAsModerator', bot, bot.id);

      return when(true);
    }

    else {
      return when(true);
    }
  }

  // else if other membership updated in monitored room
  else if(bot && this.initialized) {
    // update bot room membership
    bot.memberships = _.map(bot.memberships, m => {
      // if membership ...
      if(m.id === membership.id) {

        // get person
        if(m.isModerator != membership.isModerator) {
          this.getPerson(membership.personEmail)
            .then(person => {
              // emit event added Moderator
              if(membership.isModerator) {

                /**
                 * Person Added as Moderator.
                 *
                 * @event personAddedAsModerator
                 * @property {object} bot - Bot Object
                 * @property {object} person - Person Object
                 * @property {string} id - Flint UUID
                 */
                this.emit('personAddedAsModerator', bot, person, this.id);
                bot.emit('personAddedAsModerator', bot, person, bot.id);
              }

              // emit event removed Moderator
              if(!membership.isModerator) {

                /**
                 * Person Removed as Moderator.
                 *
                 * @event personRemovedAsModerator
                 * @property {object} bot - Bot Object
                 * @property {object} person - Person Object
                 * @property {string} id - Flint UUID
                 */
                this.emit('personRemovedAsModerator', bot, person, this.id);
                bot.emit('personRemovedAsModerator', bot, person, bot.id);
              }

            });
        }

        //update membership;
        return membership;
      }

      // if not membership...
      else {
        // do not update membership
        return m;
      }
    });

    return when(true);
  }

  // else, bot not found and membership updated for other user
  else {
    return when(true);
  }
};

/**
 * Process a deleted Membership event.
 *
 * @function
 * @memberof Flint
 * @private
 *
 * @param {Object} membership - Spark Membership Object
 * @returns {Promise}
 */
Flint.prototype.onMembershipDeleted = function(membership) {
  var bot = _.find(this.bots, bot => bot.room.id === membership.roomId);

  // if bot membership deleted in monitored room
  if(bot && membership.personEmail === this.person.email) {
    // despawn bot
    return this.despawn(bot.room.id)
      .then(() => when(true))
      .catch(() => when(false));
  }

  // else if other membership deleted in monitored room...
  else if(bot) {
    // remove bot room membership
    bot.memberships = _.reject(bot.memberships, {'id': membership.id});

    return this.getPerson(membership.personEmail)
      .then(person => {

        /**
         * Person Exits Room.
         *
         * @event personExits
         * @property {object} bot - Bot Object
         * @property {object} person - Person Object
         * @property {string} id - Flint UUID
         */
        this.emit('personExits', bot, person, this.id);
        bot.emit('personExits', bot, person, bot.id);

        return when(true);
      });
  }

  // else, bot not found and membership deleted for other user
  else {
    return when(true);
  }
};

/**
 * Process a new Message event.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} tembership - Spark Team Membership Object
 * @returns {Promise}
 */
Flint.prototype.onMessageCreated = function(message) {
  var bot = _.find(this.bots, bot => bot.room.id === message.roomId);
  if(bot) bot.lastActivity = moment().utc().toDate();

  // if bot found...
  if(bot) {
    return this.getTrigger(message.id)
      .then(trigger => {

        // function to run the action
        function runActions(matched, bot, trigger, id) {
          // process preference logic
          if(matched.length > 1) {
            matched = _.sortBy(matched, match => match.preference);
            var prefLow = matched[0].preference;
            var prefHigh = matched[matched.length - 1].preference;

            if(prefLow !== prefHigh) {
              matched = _.filter(matched, match => (match.preference === prefLow));
            }
          }

          _.forEach(matched, lex => {
            // for regex
            if(lex.phrase instanceof RegExp && typeof lex.action === 'function') {
              // define trigger.args, trigger.phrase
              trigger.args = trigger.text.split(' ');
              trigger.phrase = lex.phrase;

              // run action
              lex.action(bot, trigger, id);
              return true;
            }

            // for string
            else if (typeof lex.phrase === 'string' && typeof lex.action === 'function') {
              // find index of match
              var args = _.toLower(trigger.text).split(' ');
              var indexOfMatch = args.indexOf(lex.phrase) !== -1 ? args.indexOf(lex.phrase) : 0;

              // define trigger.args, trigger.phrase
              trigger.args = trigger.text.split(' ');
              trigger.args = trigger.args.slice(indexOfMatch, trigger.args.length);
              trigger.phrase = lex.phrase;

              // run action
              lex.action(bot, trigger, id);
              return true;
            }

            // for nothing...
            else {
              return false;
            }
          });
        }

        // if mentioned
        if(trigger.mentionedPeople && _.includes(trigger.mentionedPeople, this.person.id)) {

          trigger.args = trigger.text.split(' ');

          /**
           * Bot Mentioned.
           *
           * @event mentioned
           * @property {object} bot - Bot Object
           * @property {object} trigger - Trigger Object
           * @property {string} id - Flint UUID
           */
          this.emit('mentioned', bot, trigger, this.id);
          bot.emit('mentioned', bot, trigger, bot.id);
        }

        // emit message event
        if(trigger.text) {

          /**
           * Message Recieved.
           *
           * @event message
           * @property {object} bot - Bot Object
           * @property {object} trigger - Trigger Object
           * @property {string} id - Flint UUID
           */
          this.emit('message', bot, trigger, this.id);
          bot.emit('message', bot, trigger, bot.id);
        }

        // emit file event
        if(trigger.files) {

          /**
           * File Recieved.
           *
           * @event files
           * @property {object} bot - Bot Object
           * @property {trigger} trigger - Trigger Object
           * @property {string} id - Flint UUID
           */
          this.emit('files', bot, trigger, this.id);
          bot.emit('files', bot, trigger, bot.id);
        }

        // check if message is from bot...
        // using the bot's ID instead of the email guarantees this will work
        // even if the bot's name changes (eg: mybot@sparkbot.io -> mybot@webex.bot)
        if(trigger.personId === bot.person.id) {
          // ignore messages from bot
          return when(false);
        }

        // if trigger text present...
        if(trigger.text) {

          // return matched lexicon entry
          var matched =  _.filter(this.lexicon, lex => {

            // if lex.phrase is regex
            if(lex.phrase && lex.phrase instanceof RegExp && lex.phrase.test(trigger.text)) {
              return true;
            }

            // if lex.phrase is string and this is NOT a bot account
            else if(!this.isBotAccount && lex.phrase && typeof lex.phrase === 'string' && lex.phrase === _.toLower(trigger.text).split(' ')[0]) {
              return true;
            }

            // if lex.phrase is string and this is a bot account
            else if(this.isBotAccount && lex.phrase && typeof lex.phrase === 'string') {
              var regexPhrase = new RegExp('(^| )' + lex.phrase.replace(/([\.\^\$\*\+\?\(\)\[\{\\\|])/g, '\\$1') + '($| )','i');
              return (regexPhrase.test(trigger.text));
            }

            // else, no valid match
            else return false;
          });
        }

        // else trigger.text not present...
        else {
          return when(false);
        }

        // if matched
        if(matched && typeof this.authorize === 'function') {
          // if authorization function exists...
          return when(this.authorize(bot, trigger, this.id))
            .then(authorized => {

              //if authorized
              if(authorized) {
                runActions(matched, bot, trigger, this.id);
                return when(trigger);
              } else {
                this.debug('"%s" was denied running command in room "%s" for account "%s"', trigger.personEmail, trigger.roomTitle, this.email);
                return when(false);
              }
            });
        }

        // else, if matched and no authorization configured, run command
        else if(matched) {
          runActions(matched, bot, trigger, this.id);
          return when(trigger);
        }

        // else, do nothing...
        else {
          return when(false);
        }
      });
    }

    // else, bot not found...
    else {
      return when(false);
    }
};

/**
 * Process a new attachment action event.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {Object} attachmentAction - Spark attachentAction  Object
 * @returns {Promise}
 */
Flint.prototype.onAttachmentActions = function(attachmentAction) {
  var bot = _.find(this.bots, bot => bot.room.id === attachmentAction.roomId);
  if(bot) bot.lastActivity = moment().utc().toDate();

  // if bot found...
  if(bot) {
    this.emit('attachmentAction', bot, attachmentAction, this.id);
  // else, bot not found...
  } else {
    return when(false);
  }
};

/**
 * Spawns a bot in a Spark Room.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} Room ID - The ID for a Spark Room.
 * @returns {Promise.<Boolean>}
 */
Flint.prototype.spawn = function(roomId) {

  // if active...
  if(!this.active) {
    return when(false);
  }

  // validate params
  if(typeof roomId !== 'string') {
    this.debug('A bot for acount "%s" could not spawn as room id not valid', this.email);
    return when(false);
  }

  // validate bot is not already assigned to room
  var foundBot = _.find(this.bots, bot => (bot.room.id === roomId));
  if(foundBot) {
    this.debug('A bot for acount "%s" could not spawn as bot already exists in room', this.email);
    return when(false);
  }

  // create new bot
  var newBot = new Bot(this);

  // get room that bot is spawning in
  return this.getRoom(roomId)
    .then(room => {
      if(room.title == '') {
        room.title = 'Default title';
      }

      newBot.room = room;
      newBot.isDirect = (room.type === 'direct');
      newBot.isGroup = (room.type === 'group');
      newBot.isLocked = room.isLocked;

      return when(room);
    })

    // get team
    .then(room => {
      // if team
      if(typeof room.teamId !== 'undefined') {
        return this.getTeam(room.teamId)
          .then(team => {
            newBot.team = team;
            newBot.isTeam = true;
            return when(room);
          })
          .catch(err => {
            newBot.team = {};
            newBot.isTeam = false;
            return when(room);
          });
      } else {
        newBot.isTeam = false;
        newBot.team = {};
        return when(room);
      }
    })

    // get memberships of room
    .then(room => this.getMemberships(room.id))
    .then(memberships => {

      // get bot membership from room memberships
      var botMembership = _.find(memberships, { 'personEmail': this.person.email });

      // remove bot membership from room memberships
      memberships = _.reject(memberships, { 'personId': this.person.id });

      // assign room memberships to bot
      newBot.memberships = memberships;

      // assign membership properties to bot object
      newBot.membership = botMembership;
      newBot.isModerator = botMembership.isModerator;
      newBot.isMonitor = botMembership.isMonitor;

      // if direct, set recipient
      if(newBot.isDirect) {
        newBot.isDirectTo = memberships[0].personEmail;
      }

      return when(memberships);
    })

    // register and start bot
    .then(() => {

      // start bot
      newBot.start();

      // add bot to array of bots
      this.bots.push(newBot);

      /**
       * Bot Spawned.
       *
       * @event spawn
       * @property {object} bot - Bot Object
       * @property {string} id - Flint UUID
       */
      this.emit('spawn', newBot, this.id);

      return when(true);
    })

    // insert delay
    .delay(this.spark.minTime)

    // catch errors with spawn
    .catch(err => {

      // remove reference
      newBot = {};

      return when(false);
    });
};

/**
 * Despawns a bot in a Spark Room.
 *
 * @function
 * @memberof Flint
 * @private
 * @param {String} Room ID - The ID for a Spark Room.
 * @returns {Promise.<Bot>}
 */
Flint.prototype.despawn = function(roomId) {
  var bot = _.find(this.bots, bot => (bot.room.id === roomId));

  if(bot) {
    // shutdown bot
    bot.stop();

    // remove objects assigned to memory store for this bot
    return this.forgetByRoomId(bot.room.id)
      .then(() => {
        /**
         * Bot Despawned.
         *
         * @event despawn
         * @property {object} bot - Bot Object
         * @property {string} id - Flint UUID
         */
        this.emit('despawn', bot, this.id);

        // remove bot from flint
        this.bots = _.reject(this.bots, { 'id': bot.id });

        return when(true);
      });
  } else {
    return when.reject(new Error('despawn failed to find bot in room'));
  }
};

/**
 * Add action to be performed when bot hears a phrase.
 *
 * @function
 * @memberof Flint
 * @param {Regex|String} phrase - The phrase as either a regex or string. If
 * regex, matches on entire message.If string, matches on first word.
 * @param {Function} action - The function to execute when phrase is matched.
 * Function is executed with 2 variables. Trigger and Bot. The Trigger Object
 * contains information about the person who entered a message that matched the
 * phrase. The Bot Object is an instance of the Bot Class as it relates to the
 * room the message was heard.
 * @param {String} [helpText] - The string of text that describes how this
 * command operates.
 * @param {Number} [preference=0] - Specifies preference of phrase action when
 * overlapping phrases are matched. On multiple matches with same preference,
 * all matched actions are excuted. On multiple matches with difference
 * preference values, only the lower preferenced matched action(s) are executed.
 * @returns {String}
 *
 * @example
 * // using a string to match first word and defines help text
 * flint.hears('/say', function(bot, trigger, id) {
 *   bot.say(trigger.args.slice(1, trigger.arges.length - 1));
 * }, '/say <greeting> - Responds with a greeting');
 *
 * @example
 * // using regex to match across entire message
 * flint.hears(/(^| )beer( |.|$)/i, function(bot, trigger, id) {
 *   bot.say('Enjoy a beer, %s! 🍻', trigger.personDisplayName);
 * });
 */
Flint.prototype.hears = function(phrase, action, helpText, preference) {
  var id = u.genUUID64();

  // parse function args
  var args = Array.prototype.slice.call(arguments);
  phrase = args.length > 0 && (typeof args[0] === 'string' || args[0] instanceof RegExp) ? args.shift() : null;
  action = args.length > 0 && typeof args[0] === 'function' ? args.shift() : null;
  helpText = args.length > 0 && typeof args[0] === 'string' ? args.shift() : null;
  preference = args.length > 0 && typeof args[0] === 'number' ? args.shift() : 0;

  if(typeof phrase === 'string' && action) {
    phrase = _.toLower(phrase);
    this.lexicon.push({ 'id': id, 'phrase': phrase, 'action': action, 'helpText': helpText, 'preference': preference });
    return id;
  }

  else if(phrase instanceof RegExp && action) {
    this.lexicon.push({ 'id': id, 'phrase': phrase, 'action': action, 'helpText': helpText, 'preference': preference });
    return id;
  }

  else {
    throw new Error('Invalid flint.hears() syntax');
  }
};

/**
 * Remove a "flint.hears()" entry.
 *
 * @function
 * @memberof Flint
 * @param {String} id - The "hears" ID.
 * @returns {null}
 *
 * @example
 * // using a string to match first word and defines help text
 * var hearsHello = flint.hears('/flint', function(bot, trigger, id) {
 *   bot.say('Hello %s!', trigger.personDisplayName);
 * });
 * flint.clearHears(hearsHello);
 */
Flint.prototype.clearHears = function(hearsId) {
  this.lexicon = _.reject(this.lexicon, lex => (lex.id === hearsId));
};

/**
 * Display help for registered Flint Commands.
 *
 * @function
 * @param {String} [header=Usage:] - String to use in header before displaying help message.
 * @param {String} [footer=Powered by Flint - https://github.com/nmarus/flint] - String to use in footer before displaying help message.
 * @returns {String}
 *
 * @example
 * flint.hears('/help', function(bot, trigger, id) {
 *   bot.say(flint.showHelp());
 * });
 */
Flint.prototype.showHelp = function(header, footer) {
  header = header ? header : 'Usage:';
  footer = footer ? footer : 'Powered by Flint - https://github.com/nmarus/flint';

  var helpText = '';

  _.forEach(this.lexicon, lex => {
    if(lex.helpText) {
      helpText = helpText + '* ' + lex.helpText + '\n';
    }
  });

  helpText = header + '\n\n' + helpText + '\n' + footer + '\n\n';

  return helpText;
};

/**
 * Attaches authorizer function.
 *
 * @function
 * @memberof Flint
 * @param {Function} Action - The function to execute when phrase is matched
 * to authenticate a user.  The function is passed the bot, trigger, and id and
 * expects a return value of true or false.
 * @returns {Boolean}
 *
 * @example
 * function myAuthorizer(bot, trigger, id) {
 *   if(trigger.personEmail === 'john@test.com') {
 *     return true;
 *   }
 *   else if(trigger.personDomain === 'test.com') {
 *     return true;
 *   }
 *   else {
 *     return false;
 *   }
 * }
 * flint.setAuthorizer(myAuthorizer);
 */
Flint.prototype.setAuthorizer = function(fn) {
  if(typeof fn === 'function') {
    this.authorize = when.lift(fn);
    return true;
  } else {
    this.authorize = null;
    return false;
  }
};
Flint.prototype.authorize = null;

/**
 * Removes authorizer function.
 *
 * @function
 * @memberof Flint
 * @returns {null}
 *
 * @example
 * flint.clearAuthorizer();
 */
Flint.prototype.clearAuthorizer = function() {
  this.authorize = null;
};

/**
 * Defines storage backend.
 *
 * @function
 * @memberof Flint
 * @param {Function} Driver - The storage driver.
 * @returns {null}
 *
 * @example
 * // define memory store (default if not specified)
 * flint.storageDriver(new MemStore());
 */
Flint.prototype.storageDriver = function(driver) {

  // validate storage module store() method
  if(typeof driver.store === 'function') {
    Bot.prototype.store = function(key, value) {
      if (this.active) {
        var id = this.room.id;
        return driver.store.call(driver, id, key, value);
      } else {
        return when(value);
      }
    };
  } else {
    throw new Error('storage module missing store() function');
  }

  // validate storage module recall() method
  if(typeof driver.recall === 'function') {
    Bot.prototype.recall = function(key) {
      if (this.active) {
        var id = this.room.id;
        return driver.recall.call(driver, id, key);
      } else {
        return when(value);
      }
    };
  } else {
    throw new Error('storage module missing recall() function');
  }

  // validate storage module forget() method
  if(typeof driver.forget === 'function') {
    Bot.prototype.forget = function(key) {
      if (this.active) {
        var id = this.room.id;
        return driver.forget.call(driver, id, key);
      } else {
        return when(value);
      }
    };

    Flint.prototype.forgetByRoomId = function(roomId) {
      return driver.forget.call(driver, roomId)
        .catch(err => {
          // ignore errors when called by forgetByRoomId
          return when(true);
        });
    };
  } else {
    throw new Error('storage module missing forget() function');
  }

  // storage defined
  this.storageActive = true;
};

/**
 * Remove objects from memory store associated to a roomId.
 *
 * @function
 * @private
 * @param {String} roomId
 * @returns {Boolean}
 */
Flint.prototype.forgetByRoomId = null;

/**
 * Load a Plugin from a external file.
 * @function
 * @memberof Flint
 * @param {String} path - Load a plugin at given path.
 * @returns {Boolean}
 *
 * @example
 * flint.use('events.js');
 *
 * @example
 * // events.js
 * module.exports = function(flint) {
 *   flint.on('spawn', function(bot) {
 *     console.log('new bot spawned in room: %s', bot.myroom.title);
 *   });
 *   flint.on('despawn', function(bot) {
 *     console.log('bot despawned in room: %s', bot.myroom.title);
 *   });
 *   flint.on('messageCreated', function(message, bot) {
 *     console.log('"%s" said "%s" in room "%s"', message.personEmail, message.text, bot.myroom.title);
 *   });
 * };
 */
Flint.prototype.use = function(pluginPath) {
  if(path.parse(pluginPath).ext === '.js') {
    try {
      require(pluginPath)(this);
      this.debug('Loading flint plugin at "%s"', pluginPath);
      return true;
    }

    catch(err) {
      this.debug('Could not load flint plugin at "%s"', pluginPath);
      return false;
    }
  }
};

module.exports = Flint;

function cleanupListeners(flint) {
  // Cleanup webhooks or websockets
  if (flint.options.webhookUrl) {
    return flint.getWebhooks()
      // get webhooks
      .then(webhooks => {

        // remove all webhooks on stop
        if(!flint.options.removeWebhooksOnStart) {
          var webhooksToRemove = _.filter(webhooks, webhook => {
            return (webhook.name == u.base64encode(flint.options.webhookUrl.split('/')[2] + ' ' + flint.email));
          });

          if(webhooksToRemove instanceof Array && webhooksToRemove.length > 0) {
            return when.map(webhooksToRemove, webhook => flint.spark.webhookRemove(webhook.id))
              .then(() => when(true))
              .catch(() => when(true));
          } else {
            return when(true);
          }
        }

        // else, only remove webhooks this app created
        else {
          return when.map(webhooks, webhook => flint.spark.webhookRemove(webhook.id))
            .then(() => when(true))
            .catch(() => when(true));
        }

      });
  } else {
    return flint.websocket.cleanup()
      .then(() => {
        delete flint.websocket;
        return when(true);
      });
  }
}