Source: twitch.js

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

delete require.cache[require.resolve('./locale/Strings.js')];
const Strings = require('./locale/Strings.js');

/**
 * @description Manages Twitch related commands.
 * @listens Command#twitch
 * @augments SubModule
 */
class Twitch extends SubModule {
  /**
   * @description SubModule managing Twitch related commands.
   */
  constructor() {
    super();
    /** @inheritdoc */
    this.myName = 'Twitch';
    /** @inheritdoc */
    this.postPrefix = 'twitch ';

    /**
     * @description Instance of locale string manager.
     * @private
     * @type {Strings}
     * @default
     * @constant
     */
    this._strings = new Strings('twitch');
    this._strings.purge();

    this._commandTwitch = this._commandTwitch.bind(this);
    this._commandSubscribe = this._commandSubscribe.bind(this);
    this._commandUnSubscribe = this._commandUnSubscribe.bind(this);
    this._commandResub = this._commandResub.bind(this);
    this._resubCheck = this._resubCheck.bind(this);
    this.webhookHandler = this.webhookHandler.bind(this);
    this._handleChannelDelete = this._handleChannelDelete.bind(this);
    this._handleGuildDelete = this._handleGuildDelete.bind(this);
  }
  /** @inheritdoc */
  initialize() {
    const perms = {
      validOnlyInGuild: true,
      defaultDisabled: true,
      permissions: this.Discord.PermissionsBitField.Flags.ManageChannels |
          this.Discord.PermissionsBitField.Flags.ManageMessages |
          this.Discord.PermissionsBitField.Flags.ManageGuild,
    };
    this.command.on(
        new this.command.SingleCommand(['twitch'], this._commandTwitch, perms, [
          new this.command.SingleCommand(
              [
                'add', 'subscribe', 'sub', 'notify', 'notifications', 'on',
                'enable', 'alert', 'a', 'notification',
              ],
              this._commandSubscribe, perms),
          new this.command.SingleCommand(
              [
                'remove', 'unsubscribe', 'unsub', 'stop', 'off', 'disable',
                'delete', 'del', 'd', 'silent', 'mute',
              ],
              this._commandUnSubscribe, perms),
          new this.command.SingleCommand(['resub'], this._commandResub, perms),
        ]));
    this.client.on('channelDelete', this._handleChannelDelete);
    this.client.on('guildDelete', this._handleGuildDelete);

    if (this.client.shard) {
      /**
       * @description Inject webhook handler into client for easier shard
       * broadcasts. Set to undefined once shutdown.
       * @public
       * @see {@link Twitch.webhookHandler}
       */
      this.client.twitchWebhookHandler = this.webhookHandler;
    }

    // Attempt to re-subscribe to necessary alerts every 12 hours, prior to them
    // expiring.
    if (!this.client.shard || this.client.shard.ids[0] === 0) {
      this.interval = setInterval(this._resubCheck, 12 * 60 * 60 * 1000);
      this.timeout = setTimeout(this._resubCheck, 1 * 60 * 60 * 1000);
    }
  }
  /** @inheritdoc */
  shutdown() {
    clearInterval(this.interval);
    clearTimeout(this.timeout);
    this.command.removeListener('twitch');
    this.client.twitchWebhookHandler = undefined;
    this.client.removeListener('channelDelete', this._handleChannelDelete);
    this.client.removeListener('guildDelete', this._handleGuildDelete);
  }

  /**
   * @description Get default host information for requesting to subscribe or
   * unsubscribe from a webhook.
   * @public
   * @static
   * @returns {object} Object to pass into https request.
   */
  static get subHost() {
    return {
      protocol: 'https:',
      host: 'api.twitch.tv',
      path: '/helix/webhooks/hub',
      method: 'POST',
      headers: {
        'User-Agent': require('./common.js').ua,
        'Client-ID': auth.twitchID,
        'Content-Type': 'application/json',
      },
    };
  }

  /**
   * @description Get default host information for API request to fetch user
   * information.
   * @public
   * @static
   * @returns {object} Object to pass into https request.
   */
  static get loginHost() {
    return {
      protocol: 'https:',
      host: 'api.twitch.tv',
      path: '/helix/users?login=',
      method: 'GET',
      headers: {
        'User-Agent': require('./common.js').ua,
        'Client-ID': auth.twitchID,
      },
    };
  }

  /**
   * @description Fetch the user data from a twitch username.
   * @private
   * @param {string} login The user's login name.
   * @param {Function} cb Callback with optional error, otherwise user data
   * object from Twitch.
   */
  _fetchUser(login, cb) {
    const toSend = global.sqlCon.format(
        'SELECT * from TwitchUsers WHERE login=? AND bot=?',
        [login, this.client.user.id]);
    global.sqlCon.query(toSend, (err, rows) => {
      if (err) {
        this.error('Failed to fetch users from TwitchUsers');
        console.error(err);
        cb('Database Query Error');
        return;
      }
      const user = rows[0];
      if (user) {
        if (Date.now() - new Date(user.lastModified).getTime() <
            24 * 60 * 60 * 1000) {
          cb(null, user);
          return;
        }
      }
      const host = Twitch.loginHost;
      host.path += encodeURIComponent(login);
      const req = https.request(host, (res) => {
        let content = '';
        res.on('data', (chunk) => content += chunk);
        res.on('end', () => {
          if (res.statusCode == 200) {
            this._parseTwitchUserResponse(content, cb);
          } else {
            this.error(res.statusCode + ': ' + content);
            console.error(host);
            cb(res.statusCode + ' from Twitch');
          }
        });
      });
      req.end();
    });
  }

  /**
   * @description Parse the data received from the request for user data.
   * @private
   * @param {string} content The received information from Twitch.
   * @param {Function} cb Callback.
   */
  _parseTwitchUserResponse(content, cb) {
    try {
      const parsed = JSON.parse(content);
      const data = parsed.data && parsed.data[0];
      if (!data) {
        cb(null, null);
        return;
      }
      const toSend = global.sqlCon.format(
          'INSERT INTO TwitchUsers SET id=?, login=?, displayName=?, ' +
              'bot=? ON DUPLICATE KEY UPDATE login=?, displayName=?',
          [
            data.id, data.login, data.display_name, this.client.user.id,
            data.login, data.display_name,
          ]);
      global.sqlCon.query(toSend, (err) => {
        if (err) {
          this.error(
              'Failed to update TwitchUser data after fetching user: ' +
              data.login + ' ' + data.id);
          console.log(err);
        }
      });
      cb(null, {
        id: data.id,
        login: data.login,
        displayName: data.display_name,
        lastModified: new Date().toUTCString(),
        streamChangedState: 0,
      });
    } catch (err) {
      this.error('Failed to parse response from Twitch');
      console.error(err, content);
      cb('Bad Response');
      return;
    }
  }

  /**
   * @description Fallback twitch command if no options specified.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#twitch
   */
  _commandTwitch(msg) {
    const toSend = global.sqlCon.format(
        'SELECT * FROM TwitchDiscord JOIN TwitchUsers ON twitchId=id ' +
            'WHERE guild=? AND TwitchDiscord.bot=? AND TwitchUsers.bot=? ' +
            'ORDER BY channel',
        [msg.guild.id, this.client.user.id, this.client.user.id]);
    global.sqlCon.query(toSend, (err, rows) => {
      if (err) {
        this.error(
            'Failed to fetch TwitchDiscord data for guild: ' + msg.guild.id);
        console.error(err);
        this._strings.reply(this.common, msg, 'error');
        return;
      }
      if (!rows || rows.length === 0) {
        this._strings.reply(
            this.common, msg, 'noAlertsTitle', 'help',
            `${msg.prefix}${this.postPrefix}`);
        return;
      }

      let last = null;
      const out = rows.map((el) => {
        let str;
        if (!last) {
          str = `<#${el.channel}>: ${el.displayName}`;
        } else if (last != el.channel) {
          str = `\n<#${el.channel}>: ${el.displayName}`;
        } else {
          str = `, ${el.displayName}`;
        }
        last = el.channel;
        return str;
      });
      this.common.reply(msg, 'Twitch Alerts', out.join(''));
    });
  }

  /**
   * @description Subscribe to notifications for a streamer in a text channel.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#twitch_add
   */
  _commandSubscribe(msg) {
    // Count checking is not specific to a single bot. The limit is a global
    // limit.
    const toSend = global.sqlCon.format(
        'SELECT COUNT(*) AS count FROM TwitchDiscord WHERE guild=?',
        [msg.guild.id]);
    global.sqlCon.query(toSend, (err, rows) => {
      if (err) {
        this.error(
            'Failed to fetch number of subscriptions for guild: ' +
            msg.guild.id);
        console.error(err);
        this._strings.reply(this.common, msg, 'error');
        return;
      }
      if (rows[0].count >= 10) {
        this._strings.reply(this.common, msg, 'maxSubscriptions');
        return;
      }
      const text = msg.text.trim();
      if (text.length <= 2) {
        this._strings.reply(this.common, msg, 'subNoUsername');
        return;
      }
      if (!text.match(/^(#)?[a-zA-Z0-9][\w]{2,24}$/)) {
        this._strings.reply(this.common, msg, 'subBadUsername');
        return;
      }
      this._fetchUser(text, (err, user) => {
        if (err) {
          this._strings.reply(this.common, msg, 'error');
          return;
        } else if (!user) {
          this._strings.reply(this.common, msg, 'unknownUser');
          return;
        }
        const toSend = global.sqlCon.format(
            'INSERT INTO TwitchDiscord SET channel=?, twitchId=?, guild=?, ' +
                'type=?, bot=?',
            [
              msg.channel.id, user.id, msg.guild && msg.guild.id,
              'Stream Changed', this.client.user.id,
            ]);
        global.sqlCon.query(toSend, (err) => {
          if (err && err.code !== 'ER_DUP_ENTRY') {
            this.error('Failed to update TwitchDiscord subscribe request.');
            console.error(err);
            this._strings.reply(this.common, msg, 'error');
          } else {
            this._strings.reply(
                this.common, msg, 'subscribed', 'fillOne', user.displayName);
          }
        });
        if (!user.streamChangedState ||
            new Date(user.expiresAt).getTime() >=
                Date.now() - 24 * 60 * 60 * 1000) {
          this.subscribeToUser(user);
        }
      });
    });
  }

  /**
   * @description Unsubscribe from notifications for a streamer in a text
   * channel.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#twitch_remove
   */
  _commandUnSubscribe(msg) {
    const text = msg.text.trim();
    if (text.length <= 2) {
      this._strings.reply(this.common, msg, 'subNoUsername');
      return;
    }
    if (!text.match(/^(#)?[a-zA-Z0-9][\w]{2,24}$/)) {
      this._strings.reply(this.common, msg, 'subBadUsername');
      return;
    }
    this._fetchUser(text, (err, user) => {
      if (err) {
        this._strings.reply(this.common, msg, 'error');
        return;
      } else if (!user) {
        this._strings.reply(this.common, msg, 'unknownUser');
        return;
      }
      const toSend = global.sqlCon.format(
          'DELETE FROM TwitchDiscord WHERE channel=? AND twitchId=? AND ' +
              'type=? AND bot=?',
          [
            msg.channel.id,
            user.id,
            'Stream Changed',
            this.client.user.id,
          ]);
      global.sqlCon.query(toSend, (err) => {
        if (err) {
          this.error('Failed to update TwitchDiscord unsubscribe request.');
          console.error(err);
        } else {
          this._strings.reply(
              this.common, msg, 'notSubscribed', 'fillOne', text);
          return;
        }
      });
    });
  }

  /**
   * @description Subscribe to webhook requests for a given user.
   * @public
   * @param {object} user The user data of which to subscribe to.
   */
  subscribeToUser(user) {
    const toSend = global.sqlCon.format(
        'UPDATE TwitchUsers SET streamChangedState=1 WHERE id=? AND ' +
            'streamChangedState<2 AND bot=?',
        [user.id, this.client.user.id]);
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        this.error('Failed to update streamChangedState.');
        console.error(err);
        return;
      }
      const host = Twitch.subHost;
      const body = {
        'hub.lease_seconds': 10 * 24 * 60 * 60,
        // 'hub.lease_seconds': 0,
        'hub.callback': this.common.isRelease ?
            'https://www.spikeybot.com/webhook/twitch/' :
            'https://www.spikeybot.com/dev/webhook/twitch/',
        'hub.mode': 'subscribe',
        'hub.topic': `https://api.twitch.tv/helix/streams?user_id=${user.id}`,
        'hub.user_id': user.id,
        'hub.secret': auth.twitchSubSecret,
      };
      const req = https.request(host, (res) => {
        if (res.statusCode != 202) {
          this.error(res.statusCode + ' during Twitch Subscribe request.');
          console.error(host, body);
          let content = '';
          res.on('data', (c) => content += c);
          res.on('end', () => {
            if (content.length > 0) this.error(content);
          });
          const toSend = global.sqlCon.format(
              'UPDATE TwitchUsers SET streamChangedState=0 WHERE id=? AND ' +
                  'streamChangedState<2 AND bot=?',
              [user.id, this.client.user.id]);
          global.sqlCon.query(toSend, (err) => {
            if (err) {
              this.error('Failed to update streamChangedState.');
              console.error(err);
              return;
            }
          });
        }
      });
      req.end(JSON.stringify(body));
    });
  }

  /**
   * @description Format and send messages to all available channels that were
   * specified and on the current shard, using the provided data from the
   * webhook.
   * @public
   * @param {string[]} channels Array of all channel IDs this message is to be
   * sent in. IDs not on this shard will be ignored.
   * @param {{
   *   data: Array.<{
   *     user_id: string,
   *     user_name: string,
   *     game_id: string,
   *     type: string,
   *     title, string,
   *     viewer_count: number,
   *     started_at: string,
   *     language: string,
   *     thumbnail_url: string
   *   }>
   * }} data Data received from Twitch webhook.
   */
  webhookHandler(channels, data) {
    if (typeof channels === 'string') channels = JSON.parse(channels);
    if (typeof data === 'string') data = JSON.parse(data);
    data = data.data[0];
    this._injectWebhookMetadata(data, (data) => {
      const pF = this.Discord.PermissionsBitField.Flags;
      channels.forEach((cId) => {
        const chan = this.client.channels.resolve(cId);
        if (!chan) return;
        const perms = chan.guild && !chan.permissionsFor(chan.guild.members.me);
        if (perms && !perms.has(pF.SendMessages)) {
          return;
        }
        const locale = this.bot.getLocale &&
            this.bot.getLocale(chan.guild && chan.guild.id);
        const message = this._formatMessage(data, locale);
        if (perms && !perms.has(pF.EmbedLinks)) {
          const needEmbed = this._strings.get('noPermEmbed', locale);
          chan.send({content: '```' + message.title + '```\n' + needEmbed});
        } else {
          chan.send({embeds: [message]});
        }
      });
    });
  }

  /**
   * @description Fetch other metadata related to this webhook request that
   * might be available, such as game information.
   * @private
   * @param {object} data Data object from {@link webhookHandler}.
   * @param {Function} cb Callback with the same object that was passed in as
   * the only parameter.
   */
  _injectWebhookMetadata(data, cb) {
    const total = 1;
    let done = 0;
    const check = function() {
      if (++done < total) return;
      cb(data);
    };
    if (!data.game_id || data.game_id.length == 0) {
      check();
    } else {
      const toSend = global.sqlCon.format(
          'SELECT * FROM TwitchGames WHERE id=?', [data.game_id]);
      global.sqlCon.query(toSend, (err, rows) => {
        if (err) {
          this.common.error(
              'Failed to fetch TwitchGames database info: ' + data.game_id);
          console.error(err);
          check();
          return;
        }
        if (rows) data.game = rows[0];
        check();
      });
    }
  }

  /**
   * @description Format the Twitch webhook into a Discord message to send.
   * @private
   * @param {object} data Webhook data from Twitch.
   * @param {string} [locale] Locale for formatting strings.
   * @returns {Discord~EmbedBuilder} Formatted embed message.
   */
  _formatMessage(data, locale) {
    const embed = new this.Discord.EmbedBuilder();
    embed.setTitle(
        this._strings.get('streamLiveTitle', locale, data.user_name));
    if (data.started_at) {
      const date = new Date(data.started_at).getTime();
      if (date > 0) embed.setTimestamp(date);
    }
    const url = `https://twitch.tv/${data.user_name}`;
    embed.setURL(url);
    let thumb = data.thumbnail_url.replace(/\{width\}/g, 1280)
        .replace(/\{height\}/g, 720);
    if (thumb.indexOf('?') > -1) {
      thumb = `${thumb}&id=${data.id}`;
    } else {
      thumb = `${thumb}?id=${data.id}`;
    }
    embed.setImage(thumb);
    embed.setColor([145, 71, 255]);
    embed.setDescription(`${data.title}\n${url}`);
    if (data.game) {
      embed.setFooter({text: data.game.name});
      embed.setThumbnail(
          data.game.thumbnailUrl.replace(/\{width\}/g, 600)
              .replace(/\{height\}/g, 800));
    }
    return embed;
  }

  /**
   * @description Trigger the bot to check for need to resubscribe to channel
   * subscriptions. Specifying the `--force` flag will force all resubscriptions
   * to be attempted even if they aren't expired yet.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#twitch_resub
   */
  _commandResub(msg) {
    if (!this.common.trustedIds.includes(msg.author.id)) {
      this.comon.reply(msg, 'Sorry, but you can\'t do that.');
      return;
    }
    const force = msg.text.indexOf('--force') > -1;
    this._resubCheck(force);
    this.common.reply(msg, `Triggered resub (forced: ${force})`);
  }

  /**
   * @description Check our database for webhook subscriptions that are about to
   * expire, and re-subscribe to them.
   * @private
   * @param {boolean} [force=false] Force all subscriptions to be resubscribed,
   * even if they have not expired yet. THIS CAN LEAD TO EXCEEDING RATE LIMITS.
   * This will not check or limit the number of resubs that occur. Only use
   * inteligently.
   */
  _resubCheck(force) {
    let str;
    if (force) {
      str = 'SELECT DISTINCT id,streamChangedState,login,lastModified,' +
          'displayName,expiresAt FROM TwitchUsers INNER JOIN TwitchDiscord ' +
          'ON id=twitchId WHERE TwitchUsers.bot=? AND TwitchDiscord.bot=?';
    } else {
      str = 'SELECT DISTINCT id,streamChangedState,login,lastModified,' +
          'displayName,expiresAt FROM TwitchUsers INNER JOIN TwitchDiscord ' +
          'ON id=twitchId WHERE TwitchUsers.bot=? AND TwitchDiscord.bot=? ' +
          'AND (TIMESTAMPDIFF(DAY, expiresAt, NOW()) > -2 OR ' +
          'TIMESTAMPDIFF(DAY, expiresAt, NOW()) IS NULL)';
    }
    const toSend =
        global.sqlCon.format(str, [this.client.user.id, this.client.user.id]);
    global.sqlCon.query(toSend, (err, rows) => {
      if (err) {
        this.error('Failed to fetch twitch alerts about to expire.');
        console.error(err);
        return;
      }
      if (rows.length === 0) return;
      this.debug(`Resubbing: ${rows.map((el) => el.login).join(',')}`);
      rows.forEach((el) => this.subscribeToUser(el));
    });
  }

  /**
   * @description Handle a Discord channel being deleted.
   * @private
   * @param {Discord~Channel} channel The deleted Discord channel.
   * @listens Discord~Client#ChannelDelete
   */
  _handleChannelDelete(channel) {
    const toSend = global.sqlCon.format(
        'DELETE FROM TwitchDiscord WHERE channel=?', [channel.id]);
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        this.error(
            'Failed to purge deleted channel from TwitchDiscord: ' +
            channel.id);
        console.error(err);
      }
    });
  }

  /**
   * @description Handle a Discord guild being deleted (usually bot being
   * kicked).
   * @private
   * @param {Discord~Guild} guild The deleted Discord guild.
   * @listens Discord~Client#GuildDelete
   */
  _handleGuildDelete(guild) {
    const toSend = global.sqlCon.format(
        'DELETE FROM TwitchDiscord WHERE guild=?', [guild.id]);
    global.sqlCon.query(toSend, (err) => {
      if (err) {
        this.error(
            'Failed to purge deleted guild from TwitchDiscord: ' + guild.id);
        console.error(err);
      }
    });
  }
}
module.exports = new Twitch();