bot.js

const request = require('request');
const url = require('url');
const npmlog = require('npmlog');
const parsers = require('./parsers');
const async = require('async');
const dummy = require('dummy-object');
const Server = require('./server');
const Processor = require('./processor');

/**
 * Communicates with the API and executes Processors on incoming updates
 */
class Bot {

  /**
   * @param {object}      options
   * @param {?String}     options.apiUrl   URL to the bot's API or null if any
   *                                       request's url should be able to be
   *                                       used
   * @param {?Server}     [options.server] Server object, or null if no server
   *                                       should be set. Leave blank or pass
   *                                       undefined if the server should be
   *                                       generated automatically.
   * @param {string}      [options.route]  The default route: e.g. if url is
   *                                       https://bot.com/hook?url=https://...
   *                                       pass '/webhook'
   * @param {Requirement} [options.parser] The default parser. Used to parse the
   *                                       body from a HTTP request. As long as
   *                                       a Handler's parser option is not
   *                                       null, this Requirement will be added
   *                                       to all Handlers.
   * @param {?object}     [options.log]    Logger object containing npmlog's
   *                                       methods. Pass null to disable. If
   *                                       none is passed, npmlog will be used
   */
  constructor({ apiUrl, server, route, parser, log, verbose } = {}) {
    this.setApiUrl(apiUrl);

    // Set defaults
    this.route = route || /.*/;
    this.parser = parser || parsers.json;
    this.verbose = typeof verbose === 'boolean' ? verbose : false;
    if (log === null) {
      this.log = dummy;
    } else {
      this.log = log || npmlog;
    }

    if (server === undefined) {
      server = new Server(undefined, { log: this.log });
    }

    if (server !== null) this.setServer(server);

    this.handlers = {};
    this.logPrefix = 'bot';
  }

  /** @private */
  setServer(server) {
    this.server = server;
    server.addBot(this);
  }

  /**
   * Invokes the server's listen method
   *
   * @param {number} port Port to listen on
   * @param {string} host Hostname for the server
   *
   * @see https://nodejs.org/api/http.html
   */
  listen(...args) {
    this.server.listen(...args);
  }

  /**
   * Verifys the value is valid and throws an error if it's not
   *
   * @private
   * @param {string|RegExp|null}
   */
  setApiUrl(value) {
    // value is set, but not a string
    if (value && typeof value !== 'string') {
      throw new Error('The passed API URL must be a string or RegExp or null ' +
        `and not ${typeof value}.`);
    }
    // apiUrl might also be null, allowing the bot to use any given url
    if (typeof value === 'string') {
      const parsed = url.parse(value);
      if (!parsed.host || !parsed.path) {
        throw new Error('Given API URL is invalid!');
      }
      if (parsed.path[parsed.path.length - 1] !== '/') {
        throw new Error("API URL must end with '/'");
      }
    }
    this.apiUrl = value || null;
  }

  /**
   * Handles the data received from the API
   *
   * @param {string}         data      The body supplied by the API
   * @param {string}         passedUrl URL supplied by the API
   * @param {string}         route     The request's route
   * @param {ServerResponse} res       Response to be sent to the API
   */
  handle(data, passedUrl, route, res) {
    if (this.verbose) {
      this.log.info(this.logPrefix, `Handling route '${route}'`);
    }

    if (this.apiUrl && passedUrl !== this.apiUrl) {
      res.writeHead(403, { 'Content-Type': 'text/plain' });
      return res.end("API URL did not match with the bot's");
    }

    async.each(this.handlers, (handler, done) => {
      // check if route can be used
      if (this.checkRoute(route, handler.route || this.route)) {
        new Processor({ data, bot: this, handler, apiUrl: passedUrl, route })
          .handle((err) => {
            if (process.env.TGBOT_VERBOSE) {
              this.log.info(`${this.logPrefix} handler`,
                `Handler '${handler.name}' ${err ? 'failed' : 'succeeded'}`);
            }
            done();
          });
      } else {
        // call the callable if route is not usable
        done();
      }
    }, (err) => {
      if (err) this.log.error(err);
      res.end();
    });
  }

  /**
   * Checks if current http request's route is usable by the handler
   *
   * @private
   * @param {string}        current Current http request's route
   * @param {string|RegExp} route   Handler's route or Bot's default route
   */
  checkRoute(current, route) {
    if (route instanceof RegExp) {
      return route.test(current);
    }
    return route === current;
  }

  /**
   * Register handler
   *
   * @param {Handler} handler                Handler object
   * @param {object}  [options]              options for registering
   * @param {boolean} [options.forceReplace] Forces to replace Handler with same
   *                                         name
   */
  register(handler, options = {}) {
    this.log.info(this.logPrefix, `Registering handler '${handler.name}'`);
    if (options.forceReplace // force to proceed the replace
      || !this.handlers[handler.name] // or handler does not yet exist
      || this.handlers[handler.name].replaceable) { // or marked as replaceable
      // add an object with the key [handler.name] to the handlers object
      this.handlers[handler.name] = handler;
    } else {
      throw new Error(`The Handler ${handler.name} does already exist, ` +
        'is not marked as replaceable and the forceReplace option was not set');
    }
  }

  /**
   * Callback containing the API's response data
   *
   * @callback Bot~response
   * @param {object} error
   * @param {object} response
   * @param {String} body
   */

  /**
   * Sends an update to the API
   *
   * @param {string}       method           Is appended to the API's URL
   * @param {object}       update           The update object that should be
   *                                        sent
   * @param {Bot~response} callback         Callback containing error, response
   *                                        and body
   * @param {object}       [options]        Options object
   * @param {boolean}      [options.silent] Will not log the response if true
   * @param {string}       [options.apiUrl] Custom API URL
   */
  send(method, update, callback, { apiUrl, silent } = {}) {
    request({
      url: (apiUrl || this.apiUrl) + method,
      method: 'POST',
      json: update
    }, (err, res, body) => {
      if (!silent) this.log.http(this.logPrefix, 'Response', body);
      if (callback) callback(err, res, body);
    });
  }
}


module.exports = Bot;