Source: web/stats.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const http = require('http');
const https = require('https');
const auth = require('../../auth.js');
require('../subModule.js').extend(WebStats);  // Extends the SubModule class.

// TODO: Update this to support master/slave with stats API.

/**
 * @classdesc Handles sending the bot's stats to http client requests, and
 * discordbots.org.
 * @class
 * @augments SubModule
 */
function WebStats() {
  const self = this;

  this.myName = 'Stats';

  const app = http.createServer(handler);
  /** @inheritdoc */
  this.initialize = function() {
    if (self.common.isSlave) {
      self.debug('Starting in minimal mode.');
    } else {
      app.listen(self.common.isRelease ? 8016 : 8017, '127.0.0.1');
    }
    postTimeout =
        setTimeout(postUpdatedCount, self.common.isRelease ? 30000 : 5000);
  };
  /** @inheritdoc */
  this.shutdown = function() {
    if (app) app.close();
    if (postTimeout) clearTimeout(postTimeout);
  };

  app.on('error', (err) => {
    if (err.code === 'EADDRINUSE') {
      this.debug(
          'Stats failed to bind to port, starting in minimal mode. (' +
          err.port + ')');
      if (app) app.close();
    } else {
      this.error('Webhooks failed to bind to port for unknown reason.', err);
    }
  });

  /**
   * The timestamp at which the stats were last requested.
   *
   * @private
   * @default
   * @type {number}
   */
  let cachedTime = 0;
  /**
   * The amount of time the cached data is considered fresh. Anything longer
   * than this must be re-fetched.
   *
   * @private
   * @constant
   * @default 5 Minutes
   * @type {number}
   */
  const cachedLifespan = 5 * 60 * 1000; // 5 minutes

  /**
   * The object storing the previously received stats values.
   *
   * @private
   * @default
   * @type {Main~getAllStats~values}
   */
  let cachedStats = {};

  /**
   * The amount frequency at which we will post our stats to discordbots.org.
   *
   * @private
   * @constant
   * @default 12 Hours
   * @type {number}
   */
  const postFrequency = 12 * 60 * 60 * 1000; // 12 Hours

  /**
   * The next scheduled event at which to post our stats.
   *
   * @private
   * @type {Timeout}
   */
  let postTimeout;

  const ua = require('../common.js').ua;

  /**
   * The request information for updating our server count on bot list websites.
   *
   * @private
   * @default
   * @constant
   */
  const apiHosts = [
    {
      protocol: 'https:',
      host: 'top.gg',
      path: '/api/bots/{id}/stats',
      method: 'POST',
      headers: {
        'Authorization': auth.discordBotsOrgToken,
        'content-type': 'application/json',
        'User-Agent': ua,
      },
      _data: {
        'server_count': 'guildCount',
        'shard_id': 'shardId',
        'shard_count': 'shardCount',
      },
      _allShards: false,
    },
    /* {
      protocol: 'https:',
      host: 'discordbotlist.com',
      path: '/api/bots/{id}/stats',
      method: 'POST',
      headers: {
        'Authorization': `Bot ${auth.discordBotListComToken}`,
        'content-type': 'application/json',
        'User-Agent': ua,
      },
      _data: {
        'guilds': 'shardGuildCount',
        'shard_id': 'shardId',
        'users': 'shardUserCount',
      },
      _allShards: true,
    }, */
    {
      protocol: 'https:',
      host: 'discord.bots.gg',
      path: '/api/v1/bots/{id}/stats',
      method: 'POST',
      headers: {
        'Authorization': auth.discordBotsGGToken,
        'content-type': 'application/json',
        'User-Agent': ua,
      },
      _data: {
        'guildCount': 'shardGuildCount',
        'shardCount': 'shardCount',
        'shardId': 'shardId',
      },
      _allShards: true,
    },
    {
      protocol: 'https:',
      host: 'bots.ondiscord.xyz',
      path: '/bot-api/bots/{id}/guilds',
      method: 'POST',
      headers: {
        'Authorization': auth.botsOnDiscordXYZKey,
        'content-type': 'application/json',
        'User-Agent': ua,
      },
      _data: {
        'guildCount': 'guildCount',
      },
      _allShards: false,
    },
  ];

  /**
   * Handler for all http requests. Always replies to res with JSON encoded bot
   * stats.
   *
   * @private
   * @param {http.IncomingMessage} req The client's request.
   * @param {http.ServerResponse} res Our response to the client.
   */
  function handler(req, res) {
    const ip = req.headers['x-forwarded-for'] || req.connection.remoteAddress ||
        'ERROR';
    if (req.url.indexOf('/webhook') > -1) {
      res.writeHead(501);
      res.end();
      self.common.log('Requested Webhook that doesn\'t exist yet.', ip);
    } else if (req.url.indexOf('/stats/shield') == 0) {
      getStats((stats) => {
        if (!stats) {
          res.writeHead(500, {'content-type': 'application/json'});
          res.end(
              JSON.stringify({code: 500, message: 'Internal Server Error'}));
          self.common.log('Failed to send stats (500): ' + req.url, ip);
        } else {
          res.writeHead(200, {'content-type': 'application/json'});
          const filteredStats = {
            schemaVersion: 1,
            label: 'SpikeyBot Servers',
            message: stats.numGuilds + '',
            color: 'purple',
            cacheSeconds: Math.floor(cachedLifespan / 1000),
          };
          res.end(JSON.stringify(filteredStats));
          self.common.log('Sent stats: ' + req.url, ip);
        }
      });
    } else {
      getStats((stats) => {
        if (!stats) {
          res.writeHead(500, {'content-type': 'application/json'});
          res.end(
              JSON.stringify({code: 500, message: 'Internal Server Error'}));
          self.common.log('Failed to send stats (500): ' + req.url, ip);
        } else {
          res.writeHead(200, {'content-type': 'application/json'});
          res.end(JSON.stringify(stats));
          self.common.log('Sent stats: ' + req.url, ip);
        }
      });
    }
  }

  /**
   * @description Fetch the bot's stats.
   * @see {@link Main~getAllStats~values}
   *
   * @private
   * @param {object} cb The bot's stats as an object.
   */
  function getStats(cb) {
    if (cachedTime + cachedLifespan < Date.now()) {
      cachedTime = Date.now();
      self.bot.getStats((values) => {
        if (values) cachedStats = values;
        else values = cachedStats;
        cb(values);
      });
    } else {
      cb(cachedStats);
    }
  }

  /**
   * Send our latest guild count to discordbots.org via https post request.
   *
   * @private
   */
  function postUpdatedCount() {
    if (postTimeout) clearTimeout(postTimeout);
    getStats((values) => {
      if (!values || !values.numGuilds) {
        self.warn('Unable to post guild count due to failure to fetch stats.');
        return;
      }
      if (!self.client.shard || self.client.shard.ids[0] === 0) {
        self.log('Current Guild Count: ' + values.numGuilds);
      }
      if (self.client.shard) {
        sendRequest({
          guildCount: values.numGuilds,
          userCount: values.numMembers,
          shardId: values.reqShard,
          shardCount: values.numShards,
          shardGuildCount: values.shardGuilds[values.reqShard],
          shardUserCount: values.shardUsers[values.reqShard],
        });
      } else {
        sendRequest({
          guildCount: values.numGuilds,
          userCount: values.numMembers,
        });
      }
    });
    /**
     * Send the request after we have fetched our stats.
     *
     * @private
     * @param {{server_count: number, shards: number[], shard_id: number,
     * shard_count: number}} data The data to send in our request.
     */
    function sendRequest(data) {
      apiHosts.forEach((apiHost) => {
        if (!apiHost._allShards && data.shardId > 0) return;

        const pairs = Object.entries(apiHost._data);
        const body = {};
        pairs.forEach((el) => body[el[0]] = data[el[1]]);

        const host = Object.assign({}, apiHost);
        delete host._data;
        delete host._allShards;
        if (self.client.user) {
          host.path = host.path.replace('{id}', self.client.user.id);
        }

        if (!self.common.isRelease || !host.headers.Authorization ||
            !self.client.user) {
          self.debug(
              'NOOP POST: ' + host.host + host.path + ' ' +
              JSON.stringify(body));
          return;
        }

        const req = https.request(host, (res) => {
          let content = '';
          res.on('data', (chunk) => content += chunk);
          res.on('end', () => {
            if (postTimeout) clearTimeout(postTimeout);
            postTimeout = setTimeout(postUpdatedCount, postFrequency);
            if (res.statusCode == 200 || res.statusCode == 204) {
              self.log('Successfully posted guild count to ' + apiHost.host);
            } else {
              self.error(
                  'Failed to post guild count to ' + apiHost.host + ' (' +
                  res.statusCode + ')');
              console.error(host, body, content);
            }
          });
        });
        req.end(JSON.stringify(body));
        req.on('error', console.error);
      });
    }
  }
}

module.exports = new WebStats();