Source: cmdScheduling.js

// Copyright 2018-2020 Campbell Crowley. All rights reserved.
// Author: Campbell Crowley (dev@campbellcrowley.com)
const fs = require('fs');
const MessageMaker = require('./lib/MessageMaker.js');

require('./subModule.js')
    .extend(CmdScheduling);  // Extends the SubModule class.

/**
 * @classdesc Provides interface for scheduling a specific time or interval for
 * a command to be run.
 * @class
 * @augments SubModule
 * @listens Command#schedule
 * @listens Command#sch
 * @listens Command#sched
 * @listens Command#scheduled
 */
function CmdScheduling() {
  const self = this;

  /** @inheritdoc */
  this.myName = 'CmdScheduling';

  /** @inheritdoc */
  this.initialize = function() {
    const adminOnlyOpts = new self.command.CommandSetting({
      validOnlyInGuild: true,
      defaultDisabled: true,
      permissions: self.Discord.PermissionsBitField.Flags.ManageRoles |
          self.Discord.PermissionsBitField.Flags.ManageGuild |
          self.Discord.PermissionsBitField.Flags.BanMembers,
    });
    self.command.on(
        new self.command.SingleCommand(
            ['schedule', 'sch', 'sched', 'scheduled'], commandSchedule,
            adminOnlyOpts));

    const now = Date.now();
    self.client.guilds.cache.forEach((g) => {
      self.common.readFile(
          `${self.common.guildSaveDir}${g.id}${saveSubDir}`, (err, data) => {
            if (err && err.code == 'ENOENT') return;
            if (err) {
              self.warn('Failed to load scheduled command: ' + g.id);
              return;
            }
            try {
              const parsed = JSON.parse(data);
              if (!parsed && parsed.length !== 0) {
                self.warn('Failed to parse scheduled commands: ' + g.id);
                return;
              }
              if (!schedules[g.id]) schedules[g.id] = [];
              for (let i = 0; i < parsed.length; i++) {
                if (parsed[i].bot != self.client.user.id) continue;
                if (parsed[i].time < now) {
                  while (parsed[i].repeatDelay > 0 &&
                         parsed[i].time < now - parsed[i].repeatDelay) {
                    parsed[i].time += parsed[i].repeatDelay;
                  }
                }
                registerScheduledCommand(new ScheduledCommand(parsed[i]), g.id);
              }
            } catch (err) {
              self.error('Failed to parse data for guild commands: ' + g.id);
              console.error(err);
            }
          });
    });

    longInterval = setInterval(reScheduleCommands, maxTimeout);
  };
  /**
   * @inheritdoc
   * @fires CmdScheduling#shutdown
   * */
  this.shutdown = function() {
    self.command.deleteEvent('schedule');
    if (longInterval) clearInterval(longInterval);
    for (const i in schedules) {
      if (!schedules[i] || !schedules[i].length) continue;
      schedules[i] = schedules[i].filter((el) => el.cancel(false) && false);
    }
    fireEvent('shutdown');
    listeners = {};
  };
  /**
   * @override
   * @inheritdoc
   */
  this.save = function(opt) {
    self.client.guilds.cache.forEach((g) => {
      if (!schedulesUpdated[g.id]) return;
      delete schedulesUpdated[g.id];
      if (!schedules[g.id]) schedules[g.id] = [];
      schedules[g.id] = schedules[g.id].filter((el) => !el.complete);
      const data = schedules[g.id].map((el) => el.toJSON());
      writeSaveData(g.id, data, opt);
    });
  };

  /**
   * Write save data for a guild.
   *
   * @private
   *
   * @param {string|number} i The guild ID.
   * @param {object} data The data to write.
   * @param {string} [opt='sync'] See {@link save}.
   */
  function writeSaveData(i, data, opt) {
    const dir = `${self.common.guildSaveDir}${i}`;
    const filename = `${dir}${saveSubDir}`;
    if (opt === 'async') {
      self.common.readAndParse(filename, (err, parsed) => {
        if (!err && parsed && parsed.length > 0) {
          data =
              parsed.filter((el) => el.bot != self.client.user.id).concat(data);
        }
        const finalData = JSON.stringify(data);
        self.common.mkAndWrite(filename, dir, finalData, (err) => {
          if (err) {
            self.error('Failed to write file: ' + filename);
            console.error(err);
          }
        });
      });
    } else {
      try {
        const rec = fs.readFileSync(filename);
        const parsed = JSON.parse(rec);
        if (parsed && parsed.length > 0) {
          data =
              parsed.filter((el) => el.bot != self.client.user.id).concat(data);
        }
      } catch (err) {
        // No data exists.
      }
      self.common.mkAndWriteSync(filename, dir, JSON.stringify(data));
    }
  }

  /**
   * Interval that runs every maxTimeout milliseconds in order to re-schedule
   * commands that were beyond the max timeout duration.
   *
   * @private
   * @type {Interval}
   */
  let longInterval;

  /**
   * The maximum amount of time to set a Timeout for. The JS limit is 24 days
   * (iirc), after which, Timeouts do not work properly.
   *
   * @private
   * @constant
   * @default 14 Days
   * @type {number}
   */
  const maxTimeout = 14 * 24 * 60 * 60 * 1000;

  /**
   * The filename in the guild directory to save the scheduled commands.
   *
   * @private
   * @constant
   * @default
   * @type {string}
   */
  const saveSubDir = '/scheduledCmds.json';

  /**
   * The possible characters that can make up an ID of a scheduled command.
   *
   * @private
   * @constant
   * @default
   * @type {string}
   */
  const idChars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';

  /**
   * The color to use for embeds sent from this submodule.
   *
   * @private
   * @constant
   * @default
   * @type {number[]}
   */
  const embedColor = [50, 255, 255];

  /**
   * Minimum allowable amount of time in milliseconds from when the scheduled
   * command is registered to when it runs.
   *
   * @public
   * @constant
   * @default 10 Seconds
   * @type {number}
   */
  this.minDelay = 10000;

  /**
   * Minimum allowable amount of time in milliseconds from when the scheduled
   * command is run to when it run may run again.
   *
   * @public
   * @constant
   * @default 30 Seconds
   * @type {number}
   */
  this.minRepeatDelay = 30000;

  /**
   * Currently registered event listeners, mapped by event name.
   *
   * @private
   * @type {object.<Array.<Function>>}
   */
  let listeners = {};

  /**
   * @description All of the guilds that have updated their schedules since last
   * save.
   * @private
   * @type {object.<boolean>}
   * @default
   */
  const schedulesUpdated = {};
  /**
   * All of the currently loaded commands to run. Mapped by Guild ID, then
   * sorted arrays by time to run next command.
   *
   * @private
   * @type {object.<Array.<CmdScheduling.ScheduledCommand>>}
   */
  const schedules = {};

  /**
   * @classdesc Stores information about a specific command that is scheduled.
   * @class
   *
   * @public
   * @param {string|object} cmd The command to run, or an object instance of
   * this class (exported using toJSON, then parsed into an object).
   * @param {string|number|Discord~TextChannel} channel The channel or channel
   * id of where to run the command.
   * @param {string|number|Discord~Message} message The message or message id
   * that created this scheduled command.
   * @param {number} time The unix timestamp at which to run the command.
   * @param {?number} repeatDelay The delay in milliseconds at which to run the
   * command again, or null if it does not repeat.
   *
   * @property {string} cmd The command to run.
   * @property {number|string} bot The id of the bot instantiating this command.
   * @property {Discord~TextChannel} channel The channel or channel id of where
   * to run the command.
   * @property {string|number} channelId The id of the channel where the message
   * was sent.
   * @property {?Discord~Message} message The message that created this
   * scheduled command, or null if the message was deleted.
   * @property {string|number} messageId The id of the message sent.
   * @property {number} time The unix timestamp at which to run the command.
   * @property {number} [repeatDelay=0] The delay in milliseconds at which to
   * run the command again. 0 to not repeat.
   * @property {string} id Random base 36, 3-character long id of this command.
   * @property {boolean} complete True if the command has been run, and will not
   * run again.
   * @property {Timeout} timeout The current timeout registered to run the
   * command.
   * @property {Discord~GuildMember} member The author of this ScheduledCommand.
   * @property {string|number} memberId The id of the member.
   */
  function ScheduledCommand(cmd, channel, message, time, repeatDelay = 0) {
    const myself = this;
    if (typeof cmd === 'object') {
      channel = cmd.channel;
      message = cmd.message;
      time = cmd.time;
      repeatDelay = cmd.repeatDelay;
      this.id = cmd.id;
      this.member = cmd.member;
      cmd = cmd.cmd;
    } else {
      this.member = message.member;
      this.id = '';
    }
    if (!this.id || this.id.length < 3) {
      this.id = '';
      for (let i = 0; i < 3; i++) {
        this.id += idChars.charAt(Math.floor(Math.random() * idChars.length));
      }
    }
    this.cmd = cmd;
    this.channel = channel;
    this.channelId = typeof channel === 'object' ? channel.id : channel;
    this.message = message;
    this.messageId = typeof message === 'object' ? message.id : message;
    this.time = time;
    this.repeatDelay = repeatDelay;
    this.memberId =
        typeof this.member === 'object' ? this.member.id : this.member;

    this.bot = self.client.user.id;
    this.complete = false;

    let runAfterRefs = false;
    let isFetching = false;

    /**
     * Update channel and message with their associated IDs.
     *
     * @private
     */
    function getReferences() {
      if (!myself.channel || typeof myself.channel !== 'object') {
        myself.channel = self.client.channels.resolve(myself.channelId);
      }
      if (!myself.channel || typeof myself.channel !== 'object' ||
          myself.channel.deleted) {
        self.debug(
            'Cancelling command due to channel not existing: ' +
            myself.channelId + '@' + myself.memberId + ': ' + myself.cmd);
        myself.cancel();
        return;
      }
      if (!myself.message || typeof myself.message !== 'object') {
        myself.message = myself.channel.messages.resolve(myself.messageId);
        if (!myself.message && !isFetching) {
          isFetching = true;
          myself.channel.messages.fetch(myself.messageId)
              .then((msg) => {
                if (!msg) throw new Error();
                myself.message = msg;
                myself.member = msg.member;
                myself.memberId = msg.member.id;
                if (runAfterRefs) myself.go();
              })
              .catch(() => {
                self.debug(
                    'Failed to find message: ' + myself.channelId + '@' +
                    myself.memberId + ' ' + myself.messageId + ': ' +
                    myself.cmd);
                myself.message = makeMessage(
                    myself.memberId, myself.channel.guild.id, myself.channel.id,
                    myself.cmd);
                myself.member = myself.message.member;
                myself.memberId = myself.member.id;
                if (runAfterRefs) myself.go();
              });
        } else if (!isFetching) {
          myself.member = myself.message.member;
          myself.memberId = myself.message.member.id;
          if (runAfterRefs) myself.go();
        }
      }
      if (!myself.member || typeof myself.member !== 'object') {
        myself.member = myself.channel.members.get(myself.memberId);
        if (!myself.member && !isFetching) {
          isFetching = true;
          myself.channel.guild.members.fetch(myself.memberId)
              .then((m) => myself.member = m)
              .catch((err) => {
                self.error(
                    'Failed to find member with id: ' + myself.memberId +
                    ' in guild: ' + myself.channel.guild.id);
                console.error(err);
              });
        }
      }
    }

    /**
     * Trigger the command to be run immediately. Automatically fired at the
     * scheduled time. Does not cancel the normally scheduled command.
     * Re-schedules the command if the command should repeat.
     *
     * @public
     */
    this.go = function() {
      if (myself.complete) {
        self.error('Command triggered after being completed!', myself.id);
        clearTimeout(myself.timeout);
        return;
      }
      const now = Date.now();
      runAfterRefs = false;
      getReferences();
      if (!myself.channel || !myself.channel.send) {
        self.error(
            'ScheduledCmdFailed No Channel: ' + myself.channel.id +
            '@' + myself.memberId + ' ' + myself.cmd);
        myself.complete = true;
        clearTimeout(myself.timeout);
        return;
      } else if (!myself.message) {
        self.error(
            'ScheduledCmdWarning No Message: ' + myself.channel.guild.id + '#' +
            myself.channel.id + '@' + myself.memberId + ' ' + myself.cmd);
        runAfterRefs = true;
        return;
      } else if (!myself.message.channel || !myself.message.channel.send) {
        self.warn(
            'ScheduledCmdWarning No Message Channel: ' +
            myself.channel.guild.id + '#' + myself.channel.id + '@' +
            myself.memberId + ' ' + myself.cmd);
        myself.message.channel = myself.channel;
      }
      if (!myself.message.guild.members ||
          typeof myself.message.guild.members.resolve !== 'function') {
        self.error(
            'ScheduledCmdFailed No Members Channel: ' +
            myself.channel.guild.id + '#' + myself.channel.id + '@' +
            myself.memberId + ' ' + myself.cmd);
        return;
      } else if (!myself.message.channel.permissionsFor(self.client.user)
          .has(self.Discord.PermissionsBitField.Flags.SendMessages)) {
        self.error(
            'ScheduledCmdWarning No perm SEND_MESSAGES: ' +
            myself.channel.guild.id + '#' + myself.channel.id + '@' +
            myself.memberId + ' ' + myself.cmd);
        return;
      } else if (!myself.message.channel.permissionsFor(self.client.user)
          .has(self.Discord.PermissionsBitField.Flags.ViewChannel)) {
        self.error(
            'ScheduledCmdWarning No perm VIEW_CHANNEL: ' +
            myself.channel.guild.id + '#' + myself.channel.id + '@' +
            myself.memberId + ' ' + myself.cmd);
        return;
      }
      myself.message.content = myself.cmd;
      myself.message.fabricated = true;
      myself.message.disableMention = true;
      const cmd = self.command.find(myself.cmd, myself.message);
      if (!cmd) {
        self.error(
            'Unknown ScheduledCmd: ' + myself.message.channel.id + '@' +
            myself.message.author.id + ' ' + myself.cmd + ' ' +
            myself.message.content);
        return;
      }
      if (cmd.getFullName() === self.command.find('sch').getFullName()) {
        self.error(
            'Recursive ScheduledCmd: ' + myself.message.channel.id + '@' +
            myself.message.author.id + ' ' + myself.message.content);
        return;
      }
      self.debug(
          'ScheduledCmd: ' + myself.message.channel.id + '@' +
          myself.message.author.id + ' ' + myself.message.content);
      try {
        self.command.trigger(myself.message);
      } catch (err) {
        self.error(
            'Failed to trigger ScheduledCmd: ' + myself.message.channel.id +
            '@' + myself.message.author.id + ' ' + myself.message.content);
        console.error(err);
      }
      // If the command was fired at the scheduled time, or if it was fired
      // manually and the the scheduled time is in less than a second, then
      // consider the scheduled command to have been completed.
      if (myself.time - 1000 <= now) {
        clearTimeout(myself.timeout);
        if (myself.repeatDelay > 0) {
          myself.complete = false;
          myself.time += myself.repeatDelay;
          sortGuildCommands(myself.message.guild.id);
          myself.setTimeout();
        } else {
          myself.complete = true;
        }
        schedulesUpdated[myself.message.guild.id] = true;
      }
    };

    /**
     * Cancel this command and remove Timeout.
     *
     * @public
     * @param {boolean} [markComplete=true] Should we mark this command as
     *     completed after cancelling.
     */
    this.cancel = function(markComplete = true) {
      clearTimeout(myself.timeout);
      if (markComplete) myself.complete = true;
    };

    /**
     * Schedule the Timeout event to call the command at the scheduled time. If
     * the scheduled time to run the command is more than 2 weeks in the future,
     * the command is not scheduled, and this function must be called manually
     * (less than 2 weeks) before the scheduled time for the command to run.
     *
     * @public
     */
    this.setTimeout = function() {
      if (myself.complete) {
        return;  // Command was completed, and should no longer run.
      }
      if (myself.time - Date.now() <= maxTimeout) {
        clearTimeout(myself.timeout);
        try {
          myself.timeout = setTimeout(myself.go, myself.time - Date.now());
        } catch (err) {
          self.error(
              'ScheduledCmd Failed: ' + myself.channelId + '@' +
              myself.memberId + ' ' + myself.cmd);
          return;
        }
        self.debug(
            'ScheduledCmd Scheduled: ' + myself.channelId + '@' +
            myself.memberId + ' ' + myself.cmd);
      }
    };

    /**
     * Export the relevant data to recreate this object, as a JSON object.
     *
     * @public
     * @returns {object} JSON formatted object.
     */
    this.toJSON = function() {
      return {
        bot: self.client.user.id,
        cmd: myself.cmd,
        time: myself.time,
        repeatDelay: myself.repeatDelay,
        id: myself.id,
        channel: myself.channelId,
        message: myself.messageId,
        member: myself.memberId,
      };
    };

    getReferences();
    setTimeout(() => this.setTimeout());
  }
  this.ScheduledCommand = ScheduledCommand;

  /**
   * Register a created {@link CmdScheduling.ScheduledCommand}.
   *
   * @private
   * @fires CmdScheduling#commandRegistered
   *
   * @param {CmdScheduling.ScheduledCommand} sCmd The ScheduledCommand object to
   * register.
   * @param {string} [gId] Guild ID if message has not yet been found, or to
   * override the ID found in the given object.
   * @returns {boolean} True if succeeded, False if too close to existing
   * command.
   */
  function registerScheduledCommand(sCmd, gId) {
    if (!gId) gId = sCmd.message.guild.id;
    if (!schedules[gId]) {
      schedules[gId] = [sCmd];
    } else {
      for (let i = 0; i < schedules[gId].length; i++) {
        if (Math.abs(schedules[gId][i].time - sCmd.time) < 5000) {
          sCmd.cancel();
          return false;
        }
      }
      schedules[gId].push(sCmd);
    }
    if (sCmd.message) {
      schedulesUpdated[gId] = true;
      fireEvent('commandRegistered', sCmd, sCmd.message.guild.id);
    }
    return true;
  }
  /**
   * Register a created {@link CmdScheduling.ScheduledCommand}.
   *
   * @public
   * @see {@link CmdScheduling~registerScheduledCommand}
   */
  this.registerScheduledCommand = registerScheduledCommand;

  /**
   * Allow user to schedule command to be run, or view currently scheduled
   * commands.
   *
   * @private
   * @type {commandHandler}
   * @param {Discord~Message} msg Message that triggered command.
   * @listens Command#schedule
   */
  function commandSchedule(msg) {
    if (!msg.text || !msg.text.trim()) {
      replyWithSchedule(msg);
      return;
    } else if (msg.text.match(/(cancel|remove|delete)/)) {
      cancelAndReply(msg);
      return;
    }

    const splitCmd = msg.text.trim().split(msg.prefix);
    if (!splitCmd || splitCmd.length < 2 || splitCmd[0].trim().length == 0) {
      self.common.reply(
          msg,
          'Oops! Please ensure you have formatted that correctly.\nI wasn\'t' +
              ' able to understand it properly.');
      return;
    }

    let delay = splitCmd.splice(0, 1)[0];
    const cmd = msg.prefix + splitCmd.join(msg.prefix);
    let repeat = 0;

    const invalid = self.command.validate(cmd.split(/\s/)[0], msg);
    if (invalid) {
      self.common.reply(
          msg, 'That command doesn\'t seem to be a usable command.\n' + cmd,
          invalid);
      return;
    }

    if (self.command.find(splitCmd[0]).getFullName() ===
        self.command.find('sch').getFullName()) {
      self.common.reply(msg, 'Commands may not be recursive.', invalid);
      return;
    }


    if (delay.match(/every|repeat/)) {
      const splitTimes = delay.match(/^(.*?)(every|repeat)(.*)$/);
      delay = splitTimes[1];
      repeat = splitTimes[3];
    }

    delay = stringToMilliseconds(delay);

    /* if (delay < self.minDelay) {
      self.common.reply(msg, 'Sorry, but delays must be more than 10 seconds.');
      return;
    } */

    repeat = stringToMilliseconds(repeat);

    if (repeat && repeat < self.minRepeatDelay) {
      self.common.reply(
          msg, 'Sorry, but repeat delays must be more than 30 seconds.');
      return;
    }

    const newCmd =
        new ScheduledCommand(cmd, msg.channel, msg, delay + Date.now(), repeat);

    if (!registerScheduledCommand(newCmd)) {
      self.common.reply(
          msg, 'Sorry, but commands must be separated by at least 5 seconds.');
      return;
    }

    const embed = new self.Discord.EmbedBuilder();
    embed.setTitle('Created Scheduled Command (' + newCmd.id + ')');
    embed.setColor(embedColor);
    let desc = 'Runs in ' + formatDelay(delay);
    if (repeat) {
      desc += '\nRepeats every ' + formatDelay(repeat);
    }
    embed.setDescription(desc);
    embed.addFields([
      {name: 'To cancel:', value: `\`${msg.prefix}sch cancel ${newCmd.id}\``},
    ]);
    embed.setFooter({text: cmd});

    msg.channel.send({content: self.common.mention(msg), embeds: [embed]});
  }

  /**
   * Sort all scheduled commands in a guild by the next time they will run.
   *
   * @private
   * @param {string|number} id The guild id of which to sort the commands.
   */
  function sortGuildCommands(id) {
    const c = schedules[id];
    if (!c) return;

    let unsorted = true;
    while (unsorted) {
      unsorted = false;
      let spliced;
      for (let i = 1; i < c.length; i++) {
        if (!spliced && c[i - 1].time > c[0].time) {
          spliced = c.splice(i - 1, 1)[0];
          if (c[c.length - 1].time < spliced.time) {
            c.push(spliced);
            spliced = null;
            i--;
          }
        } else if (spliced && c[i].time < spliced.time) {
          c.splice(i + 1, 0, spliced);
          unsorted = true;
          break;
        }
      }
    }
  }

  /**
   * Given a user-inputted string, convert to a number of milliseconds. Input
   * can be on most common time units up to a week.
   *
   * @private
   *
   * @param {string} str The input string to parse.
   * @returns {number} Number of milliseconds parsed from string.
   */
  function stringToMilliseconds(str) {
    let sum = 0;
    str = (str + '')
        .replace(/\b(and|repeat|every|after|in)\b/g, '')
        .trim()
        .toLowerCase();

    const reg = /([0-9.]+)([^a-z]*)([a-z]*)/g;
    let res;
    while ((res = reg.exec(str)) !== null) {
      sum += numberToUnit(res[1], res[3]);
    }
    if (!sum && str) {
      sum = numberToUnit(1, str);
    }
    /**
     * Convert a number and a unit to the corresponding number of milliseconds.
     *
     * @private
     * @param {number} num The number associated with the unit.
     * @param {string} unit The current unit associated with the num.
     * @returns {number} The given number in milliseconds.
     */
    function numberToUnit(num, unit) {
      switch (unit) {
        case 's':
        case 'sec':
        case 'second':
        case 'seconds':
          return num * 1000;
        case 'm':
        case 'min':
        case 'minute':
        case 'minutes':
          return num * 60 * 1000;
        case 'h':
        case 'hr':
        case 'hour':
        case 'hours':
          return num * 60 * 60 * 1000;
        case 'd':
        case 'dy':
        case 'day':
        case 'days':
          return num * 24 * 60 * 60 * 1000;
        case 'w':
        case 'wk':
        case 'week':
        case 'weeks':
          return num * 7 * 24 * 60 * 60 * 1000;
        default:
          return 0;
      }
    }
    return sum;
  }

  /**
   * Returns an array of references to scheduled commands in a guild.
   *
   * @public
   *
   * @param {string|number} gId The guild id of which to get the commands.
   * @returns {null|CmdScheduling.ScheduledCommand[]} Null if none, or the array
   * of ScheduledCommands.
   */
  function getScheduledCommandsInGuild(gId) {
    let list = schedules[gId];
    if (!list) return null;
    list = list.filter((el) => !el.complete);
    if (!list || list.length == 0) return null;
    return list;
  }
  this.getScheduledCommandsInGuild = getScheduledCommandsInGuild;


  /**
   * Find all scheduled commands for a certain guild, and reply to the message
   * with the list of commands.
   *
   * @private
   * @param {Discord~Message} msg The message to reply to.
   */
  function replyWithSchedule(msg) {
    const embed = new self.Discord.EmbedBuilder();
    embed.setTitle('Scheduled Commands');
    embed.setColor(embedColor);
    let list = getScheduledCommandsInGuild(msg.guild.id);
    if (!list) {
      embed.setDescription('No commands are scheduled.');
    } else {
      const n = Date.now();
      list = list.map((el) => {
        return '**' + el.id + '**: In ' + formatDelay(el.time - n) +
            (el.repeatDelay ?
                 (', repeats every ' + formatDelay(el.repeatDelay)) :
                 '') +
            (el.message && ' by <@' + el.message.author.id + '>: ') + el.cmd;
      });
      embed.setDescription(list.join('\n'));
    }
    if (msg.author.id == self.common.spikeyId) {
      const keys = Object.keys(schedules);
      let total = 0;
      keys.forEach((k) => {
        total += Object.keys(schedules[k]).length;
      });
      embed.setFooter({text: total});
    }
    msg.channel.send({content: self.common.mention(msg), embeds: [embed]})
        .catch((err) => {
          self.error('Failed to send reply in channel: ' + msg.channel.id);
          console.error(err);
        });
  }

  /**
   * Cancel a scheduled command in a guild.
   *
   * @private
   * @fires CmdScheduling#commandCancelled
   *
   * @param {string|number} gId The guild id of which to cancel the command.
   * @param {string|number} cmdId The ID of the command to cancel.
   * @returns {?CmdScheduling.ScheduledCommand} Null if failed, or object that
   * was cancelled.
   */
  function cancelCmd(gId, cmdId) {
    const list = schedules[gId];
    if (!list || list.length == 0) return null;
    if (!cmdId) return null;
    cmdId = `${cmdId}`.toUpperCase();
    for (let i = 0; i < list.length; i++) {
      if (list[i].complete) continue;
      if (list[i].id == cmdId) {
        const removed = list.splice(i, 1)[0];
        removed.cancel();
        schedulesUpdated[gId] = true;
        fireEvent(
            'commandCancelled', removed.id,
            removed.channel && removed.channel.guild.id);
        return removed;
      }
    }
    return null;
  }
  /**
   * Cancel a scheduled command in a guild.
   *
   * @public
   * @see {@link CmdScheduling~cancelCmd}
   */
  this.cancelCmd = cancelCmd;

  /**
   * Find a scheduled command with the given ID, and remove it from commands to
   * run.
   *
   * @private
   * @param {Discord~Message} msg The message to reply to.
   */
  function cancelAndReply(msg) {
    const embed = new self.Discord.EmbedBuilder();
    embed.setColor(embedColor);
    const list = schedules[msg.guild.id];
    if (!list || list.length == 0) {
      embed.setTitle('Cancelling Failed');
      embed.setDescription('There are no scheduled commands in this guild.');
    } else {
      let idSearch = msg.text.match(/(cancel|remove|delete)\W+(\w{3,})\b/);
      if (!idSearch) {
        embed.setTitle('Cancelling Failed');
        embed.setDescription('Please specify a scheduled command ID.');
      } else {
        idSearch = idSearch[2];
        const removed = cancelCmd(msg.guild.id, idSearch);
        if (!removed) {
          embed.setTitle('Cancelling Failed');
          embed.setDescription(
              'Unable to find scheduled command with ID: ' + idSearch);
        } else {
          embed.setTitle('Cancelling Succeeded');
          embed.setDescription(
              'Removed scheduled command ID: ' + idSearch + ', ' + removed.cmd);
        }
      }
    }

    msg.channel.send({content: self.common.mention(msg), embeds: [embed]})
        .catch((err) => {
          self.error('Failed to send reply in channel: ' + msg.channel.id);
          console.error(err);
        });
  }

  /**
   * Reschedule all future commands that are beyond maxTimeout.
   */
  function reScheduleCommands() {
    for (const g in schedules) {
      if (!schedules[g] || !schedules[g].length) continue;
      for (let i = 0; i < schedules[g].length; i++) {
        let abort = false;
        for (let j = 0; j < schedules[g].length; j++) {
          if (i == j) continue;
          if (Math.abs(schedules[g][i].time - schedules[g][j].time) < 5000) {
            abort = true;
            break;
          }
        }
        if (abort) {
          schedules[g][i].cancel();
          schedules[g].splice(i, 1);
          schedulesUpdated[g] = true;
        } else {
          schedules[g][i].setTimeout();
        }
      }
    }
  }

  /**
   * Format a duration in milliseconds into a human readable string.
   *
   * @private
   *
   * @param {number} msecs Duration in milliseconds.
   * @returns {string} Formatted string.
   */
  function formatDelay(msecs) {
    let output = '';
    let unit = 7 * 24 * 60 * 60 * 1000;
    if (msecs >= unit) {
      const num = Math.floor(msecs / unit);
      output += num + ' week' + (num == 1 ? '' : 's') + ', ';
      msecs -= num * unit;
    }
    unit /= 7;
    if (msecs >= unit) {
      const num = Math.floor(msecs / unit);
      output += num + ' day' + (num == 1 ? '' : 's') + ', ';
      msecs -= num * unit;
    }
    unit /= 24;
    if (msecs >= unit) {
      const num = Math.floor(msecs / unit);
      output += num + ' hour' + (num == 1 ? '' : 's') + ', ';
      msecs -= num * unit;
    }
    unit /= 60;
    if (msecs >= unit) {
      const num = Math.floor(msecs / unit);
      output += num + ' minute' + (num == 1 ? '' : 's') + ', ';
      msecs -= num * unit;
    }
    unit /= 60;
    if (msecs >= unit) {
      const num = Math.round(msecs / unit);
      output += num + ' second' + (num == 1 ? '' : 's') + '';
    }
    return output.replace(/,\s$/, '');
  }

  /**
   * Register an event handler for the given name with the given handler.
   *
   * @public
   * @param {string} name The event name to listen for.
   * @param {Function} handler The function to call when the event is fired.
   */
  this.on = function(name, handler) {
    if (typeof handler !== 'function') {
      throw (new Error('Handler must be a function.'));
    }
    if (!listeners[name]) listeners[name] = [];
    listeners[name].push(handler);
  };

  /**
   * Remove an event handler for the given name.
   *
   * @public
   * @param {string} name The event name to remove the handler for.
   * @param {Function} [handler] THe specific handler to remove, or null for
   * all.
   */
  this.removeListener = function(name, handler) {
    if (!listeners[name]) return;
    if (!handler) {
      delete listeners[name];
    } else {
      for (let i = 0; i < listeners[name].length; i++) {
        if (listeners[name][i] == handler) {
          listeners[name].splice(i, 1);
        }
      }
      if (listeners[name].length == 0) delete listeners[name];
    }
  };

  /**
   * @description Fires a given event with the associated data.
   *
   * @private
   * @param {string} name The name of the event to fire.
   * @param {*} data The arguments to pass into the function calls.
   */
  function fireEvent(name, ...data) {
    for (let i = 0; listeners[name] && i < listeners[name].length; i++) {
      try {
        listeners[name][i](...data);
      } catch (err) {
        self.error('Error in firing event: ' + name);
        console.error(err);
      }
    }
  }
  /**
   * Forms a Discord~Message similar object from given IDs.
   *
   * @private
   * @param {string} uId The id of the user who wrote this message.
   * @param {string} gId The id of the guild this message is in.
   * @param {string} cId The id of the channel this message was 'sent' in.
   * @param {string} msg The message content.
   * @returns {?MessageMaker} The created message-like object, or null if
   * invalid channel.
   */
  function makeMessage(uId, gId, cId, msg) {
    if (!cId) return null;
    const message = new MessageMaker(self, uId, gId, cId, msg);
    return message.guild ? message : null;
  }
}
module.exports = new CmdScheduling();