core/Bridge.js

let Promise
try {
  Promise = require('bluebird')
} catch (err) {
  Promise = global.Promise
}

const path = require('path')
const fs = require('fs')

const { requireRecursive, isDir } = require('../util')

/**
 * Middleware manager for commands
 * @prop {Array} tasks An array of middleware
 * @prop {Array} collectors An array of message collectors
 */
class Bridge {
  /**
   * Creates a new Bridge instance
   * @arg {Client} client Client instance
   */
  constructor (client) {
    this.tasks = []
    this.collectors = []
    this._cached = []
    this._client = client
    this._commander = client.plugins.get('commands')
  }

  /**
   * Registers middleware
   * @arg {String|Object|Array} middleware An object, array or relative path to a folder or file to load middleware from
   * @returns {Client}
   */
  register (middleware) {
    switch (typeof middleware) {
      case 'string': {
        const filepath = path.isAbsolute(middleware) ? middleware : path.join(process.cwd(), middleware)
        if (!fs.existsSync(filepath)) {
          throw new Error(`Folder path ${filepath} does not exist`)
        }
        this._cached.push(filepath)
        const mw = isDir(filepath) ? requireRecursive(filepath) : require(filepath)
        return this.register(mw)
      }
      case 'object': {
        if (Array.isArray(middleware)) {
          for (const mw of middleware) {
            this.push(mw)
          }
          return this
        }
        for (const key in middleware) {
          this.push(middleware[key])
        }
        return this
      }
      default: {
        throw new Error('Path supplied is not an object or string')
      }
    }
  }

  /**
   * Methods that parses messages and adds properties to a context container
   * @typedef {Object} Middleware
   * @prop {String} name Name of middleware
   * @prop {Number} priority Priority level of the middleware
   * @prop {Promise(Container)} process Middleware process
   */

  /**
   * Inserts new middleware to the task queue according to ascending priority (lower numbers are earlier in queue)
   * @arg {Middleware} middleware Middleware object
   */
  push (Middleware) {
    const middleware = typeof Middleware === 'function' ? new Middleware(this) : Middleware
    this.tasks.push(middleware)
    this.tasks.sort((a, b) => a.priority - b.priority)

    /**
     * Fires when a middleware is registered
     *
     * @event Client#bridge:registered
     * @type {Object}
     * @prop {String} name Middleware name
     * @prop {Number} index Location of middleware in the tasks chain
     * @prop {Number} count Number of loaded middleware tasks
     */
    this._client.emit('bridge:registered', {
      name: middleware.name,
      index: this.tasks.indexOf(middleware),
      count: this.tasks.length
    })
    return this
  }

  /**
   * Creates a message collector
   * @arg {Object} options Collector options
   * @arg {String} options.filter The filter function to pass all messages through
   * @arg {String} [options.channel] The channel ID to filter messages from
   * @arg {String} [options.author] The author ID to filter messages from
   * @arg {Number} [options.tries=10] Max number of attempts to filter a message
   * @arg {Number} [options.time=60] Max length of time to wait for messages, in seconds
   * @arg {Number} [options.matches=10] Max number of successful filtered messages
   * @returns {Collector} Message collector object
   */
  collect (options = {}) {
    const { tries = 10, time = 60, matches = 10, channel, author, filter } = options

    /**
     * Message collector object, intended for menus
     * @namespace Collector
     * @type {Object}
     */
    const collector = {
      /**
       * An array of collected messages
       * @memberof Collector
       * @type {Array}
       */
      collected: [],
      _tries: 0,
      _matches: 0,
      _listening: false,
      _ended: false,
      _timer: time ? setTimeout(() => {
        collector._ended = {
          reason: 'timeout',
          arg: time,
          collected: collector.collected
        }
      }, time * 1000) : null
    }
    /**
     * Stop collecting messages
     * @memberof Collector
     * @method
     */
    collector.stop = () => {
      collector._listening = false
      this.collectors.splice(this.collectors.indexOf(collector), 1)
    }
    /**
     * Resolves when message is collected, and rejects when collector has ended
     * @memberof Collector
     * @returns {Promise<external:"Eris.Message">}
     */
    collector.next = () => {
      return new Promise((resolve, reject) => {
        collector._resolve = resolve
        if (collector._ended) {
          collector.stop()
          reject(collector._ended)
        }
        collector._listening = true
      })
    }
    /**
     * Pass a message object to be filtered
     * @memberof Collector
     * @method
     * @returns {Boolean}
     */
    collector.passMessage = msg => {
      if (!collector._listening) return false
      if (author && author !== msg.author.id) return false
      if (channel && channel !== msg.channel.id) return false
      if (typeof filter === 'function' && !filter(msg)) return false

      collector.collected.push(msg)
      if (collector.collected.size >= matches) {
        collector._ended = { reason: 'maxMatches', arg: matches }
      } else if (tries && collector.collected.size === tries) {
        collector._ended = { reason: 'max', arg: tries }
      }
      collector._resolve(msg)
      return true
    }
    this.collectors.push(collector)
    return collector
  }

  /**
   * Destroy all tasks and collectors
   */
  destroy () {
    this.tasks = []
    this.collectors = []
  }

  /**
   * Remove middleware by name and returns it if found
   * @arg {String|Boolean} name Middleware name, will remove all if true
   * @returns {?Middleware}
   */
  unregister (name) {
    if (name === true) {
      return this.tasks.splice(0)
    }
    const middleware = this.tasks.find(mw => mw.name === name)
    if (!middleware) return null
    this.tasks.splice(this.tasks.indexOf(middleware, 1))
    return middleware
  }

  /**
   * Reloads middleware files (only those that have been added from by file path)
   * @returns {Client}
   */
  reload () {
    for (const filepath of this._cached) {
      this._client.unload(filepath)
      this._cached.shift()
      this.register(filepath)
    }
    return this
  }

  /** Starts running the bridge */
  run () {
    this._client.on('messageCreate', msg => {
      if (this._client.selfbot && msg.author.id !== this._client.user.id) return
      else if (msg.author.id === this._client.user.id || msg.author.bot) return

      this.handle({
        msg: msg,
        client: this._client,
        logger: this._client.logger,
        admins: this._client.admins,
        commands: this._commander,
        modules: this._client.plugins.get('modules'),
        middleware: this
      }).catch(err => {
        if (err && this._client.logger) {
          this._client.logger.error('Failed to handle message in Bridge -', err)
        }
      })
    })
  }

  /** Stops running the bridge */
  stop () {
    this._client.removeAllListeners('messageCreate')
  }

  /**
   * Context container holding a message object along with added properties and objects
   * @typedef {Object} Container
   * @prop {external:"Eris.Message"} msg The message object
   * @prop {Client} client The client object
   * @prop {String} trigger The command trigger<br />
   * At least one middleware should add this into the container; default middleware does it for you
   */

  /**
   * Collects and executes messages after running them through middleware
   * @arg {Container} container The message container
   * @returns {Promise<Container>}
   */
  async handle (container) {
    const { msg } = container
    for (let collector of this.collectors) {
      const collected = collector.passMessage(msg)
      if (collected) return Promise.reject()
    }
    for (const task of this.tasks) {
      const result = await task.process(container)
      if (!result) return Promise.reject()
      container = result
    }
    if (!container.trigger) return Promise.reject()
    this._commander.execute(container.trigger, container)
    return container
  }
}

module.exports = Bridge