Source: src/images.js

// Copyright 2019 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const SubModule = require('./subModule.js');
const https = require('https');
const auth = require('../auth.js');

/**
 * @classdesc Handles fetching images and GIFs from various APIs.
 * @class
 * @augments SubModule
 */
class Images extends SubModule {
  /**
   * @description Handles fetching images and GIFs from various APIs.
   */
  constructor() {
    super();
    /** @inheritdoc */
    this.myName = 'Images';

    /**
     * @description Cached responses from recent requests to Imgur's API. Mapped
     * by request path.
     * @private
     * @type {object}
     * @default
     */
    this._imgurCache = {};
    /**
     * @description Cached responses from recent requests to Giphy's API. Mapped
     * by request path.
     * @private
     * @type {object}
     * @default
     */
    this._giphyCache = {};
    /**
     * @description Maximum amount of time to cache a request to Imgur before we
     * purge it.
     * @private
     * @type {number}
     * @default 1 Week
     */
    this._imgurCacheDuration = 7 * 24 * 60 * 60 * 1000;
    /**
     * @description Maximum amount of time to cache a request to Giphy before we
     * purge it.
     * @private
     * @type {number}
     * @default 1 Day
     */
    this._giphyCacheDuration = 1 * 24 * 60 * 60 * 1000;

    this._commandCookie = this._commandCookie.bind(this);
    this._commandImgur = this._commandImgur.bind(this);
    this._commandGiphy = this._commandGiphy.bind(this);
  }
  /** @inheritdoc */
  initialize() {
    this.command.on(
        ['imgur', 'image', 'png', 'jpg', 'jpeg'], this._commandImgur);
    this.command.on(['giphy', 'gif', 'giph'], this._commandGiphy);
    this.command.on(['cookie', 'cookies'], this._commandCookie);
  }
  /** @inheritdoc */
  shutdown() {
    this.command.removeListener('imgur');
    this.command.removeListener('giphy');
    this.command.removeListener('cookie');
  }

  /**
   * @description Fetch the default host information for making a request to
   * imgur.
   * @public
   * @static
   * @returns {object} Object to pass to https request.
   */
  static get imgurHost() {
    return {
      protocol: 'https:',
      host: 'api.imgur.com',
      path: '/3/',
      method: 'GET',
      headers: {
        'User-Agent': require('./common.js').ua,
        'Authorization': 'Client-ID ' + auth.imgurID,
        'Content-Type': 'application/json',
      },
    };
  }

  /**
   * @description Fetch the default host information for making a request to
   * giphy.
   * @public
   * @static
   * @returns {object} Object to pass to https request.
   */
  static get giphyHost() {
    return {
      protocol: 'https:',
      host: 'api.giphy.com',
      path: '/v1/',
      method: 'GET',
      headers: {
        'User-Agent': require('./common.js').ua,
        'Content-Type': 'application/json',
      },
    };
  }

  /**
   * @description Performs a get request to Imgur's API at the given path, and
   * caches the result before returning it through the callback. If a cached
   * response is available that isn't too old, that will immediately be returned
   * instead.
   * @private
   * @param {string} path The full API path after the domain and API version.
   * Includes query parameters.
   * @param {Function} cb Callback once completed or failed. First parameter is
   * optional error, second is otherwise the parsed JSON object from Imgur.
   */
  _imgurGet(path, cb) {
    if (this._imgurCache[path]) {
      if (Date.now() - this._imgurCache[path].timestamp <
          this._imgurCacheDuration) {
        cb(null, this._imgurCache[path].data);
        return;
      } else {
        // update timestamp to prevent multiple requests at the same time before
        // the first completes.
        this._imgurCache[path].timestamp = Date.now();
      }
    }
    const host = Images.imgurHost;
    host.path += path;
    const req = https.request(host, (res) => {
      let content = '';
      res.on('data', (chunk) => content += chunk);
      res.on('end', () => {
        if (res.statusCode == 200) {
          try {
            content = JSON.parse(content);
            this._imgurCache[path] = {data: content, timestamp: Date.now()};
          } catch (err) {
            this.error('Imgur Unable to parse response to ' + path);
            console.error(err);
            cb(err);
            return;
          }
          cb(null, content);
        } else {
          this.error('Imgur ' + res.statusCode + ': ' + content);
          console.error(host);
          cb(new Error('Imgur Bad Response'));
        }
      });
    });
    req.end();
  }

  /**
   * @description Performs a get request to Giphy's API at the given path, and
   * caches the result before returning it through the callback. If a cached
   * response is available that isn't too old, that will immediately be returned
   * instead.
   * @private
   * @param {string} path The full API path after the domain and API version.
   * Includes query parameters.
   * @param {Function} cb Callback once completed or failed. First parameter is
   * optional error, second is otherwise the parsed JSON object from Imgur.
   */
  _giphyGet(path, cb) {
    if (this._giphyCache[path]) {
      if (Date.now() - this._giphyCache[path].timestamp <
          this._giphyCacheDuration) {
        cb(null, this._giphyCache[path].data);
        return;
      } else {
        // update timestamp to prevent multiple requests at the same time before
        // the first completes.
        this._giphyCache[path].timestamp = Date.now();
      }
    }
    const host = Images.giphyHost;
    host.path += path;
    const req = https.request(host, (res) => {
      let content = '';
      res.on('data', (chunk) => content += chunk);
      res.on('end', () => {
        if (res.statusCode == 200) {
          try {
            content = JSON.parse(content);
            this._giphyCache[path] = {data: content, timestamp: Date.now()};
          } catch (err) {
            this.error('Giphy Unable to parse response to ' + path);
            console.error(err);
            cb(err);
            return;
          }
          cb(null, content);
        } else {
          this.error('Giphy ' + res.statusCode + ': ' + content);
          console.error(host);
          cb(new Error('Giphy Bad Response'));
        }
      });
    });
    req.end();
  }

  /**
   * @description Searches Imgur for the given query.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @param {number} [pages=1] Number of potential pages to view.
   * @listens Command#imgur
   */
  _commandImgur(msg, pages = 1) {
    const query =
        '?q=' + encodeURIComponent(msg.text + ' ext:png OR jpg OR gif');
    const page = Math.floor(Math.random() * pages);
    const path = 'gallery/search/top/all/' + page + query;
    this._imgurGet(path, (err, content) => {
      if (!err) {
        const list = content.data;
        if (list.length == 0) {
          this.common.reply(msg, 'Failed to search for ' + msg.text.trim());
          return;
        }
        const image = list[Math.floor(Math.random() * list.length)];
        const url = image.images ? image.images[0].link : image.link;
        const embed = new this.Discord.MessageEmbed({title: image.title});
        embed.setFooter(image.link);
        if (image.description) {
          embed.setDescription(image.description);
        } else if (
          image.images && image.images[0] && image.images[0].description) {
          embed.setDescription(image.images[0].description);
        }
        embed.setURL(image.link);
        embed.setImage(url);
        msg.channel.send(embed).catch(
            () => this.common.reply(
                msg, 'Failed to send reply.',
                'Am I able to embed images and links?'));
      }
    });
  }

  /**
   * @description Searches Giphy for the given query.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @param {number} [pages=1] Number of potential pages to view.
   * @param {number} [count=20] Number of results per each page.
   * @listens Command#giphy
   */
  _commandGiphy(msg, pages = 1, count = 20) {
    const page = Math.floor(Math.random() * pages);
    const query = '?api_key=' + auth.giphyKey + '&q=' +
        encodeURIComponent(msg.text) + '&limit=' + count + '&offset=' +
        (page * count);
    const path = 'gifs/search' + query;
    this._giphyGet(path, (err, content) => {
      if (!err) {
        const list = content.data;
        if (list.length == 0) {
          this.common.reply(msg, 'Failed to search for ' + msg.text.trim());
          return;
        }
        const image = list[Math.floor(Math.random() * list.length)];
        const url = image.images.original.url;
        const embed = new this.Discord.MessageEmbed({title: image.title});
        embed.setFooter(image.url);
        embed.setURL(image.url);
        embed.setImage(url);
        msg.channel.send(embed).catch(
            () => this.common.reply(
                msg, 'Failed to send reply.',
                'Am I able to embed images and links?'));
      }
    });
  }

  /**
   * Replies with a picture of a cookie.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#cookie
   * @listens Command#cookies
   */
  _commandCookie(msg) {
    msg.text = 'cookies';
    this._commandGiphy(msg, 100);
  }
}

module.exports = new Images();