orator/index.js

const { logger, get } = require('../../util')

class Orator {
  /**
   * A class handling all message based communications.
   * @param {string}        defaultPrefix The default command prefix.
   * @param {OratorOptions} oratorOptions The OratorOptions.
   */
  constructor (defaultPrefix, options = {}) {
    const {
      deleteInvoking = false,
      deleteResponse = false,
      deleteResponseDelay = 10000
    } = options
    /**
     * @type {string}
     */
    this.defaultPrefix = options.defaultPrefix || defaultPrefix
    this.deleteInvoking = deleteInvoking
    this.deleteResponse = deleteResponse
    this.deleteResponseDelay = deleteResponseDelay
    this._requiredSendPermissions = [ 'readMessages', 'sendMessages' ]
  }

  set permissions (permissions) {
    if (permissions) {
      this._permissions = [ ...permissions.values() ]
        .sort((a, b) => a.level - b.level)
    }
  }

  /**
   * @type {Array<Permission>}
   */
  get permissions () {
    return this._permissions
  }

  /**
   * Try to delete a message.
   * @param   {ExtendedUser}       me  The bot user {@link https://abal.moe/Eris/docs/ExtendedUser|(link)}.
   * @param   {Message}            msg The message to delete {@link https://abal.moe/Eris/docs/Message|(link)}.
   * @returns {Promise<void>|void}
   */
  deleteMessage (me, msg) {
    const permissions = msg.channel.permissionsOf(me.id)
    if (permissions.has('manageMessages') || msg.author.id === me.id) {
      return msg.delete()
        .catch((error) => {
          logger.error(`Failed to delete: ${error}`)
        })
    }
  }

  replyToMessage (me, msg, content, file) {
    if (msg.channel.type === 1) {
      return this.createDirectMessage(me, msg, content, file)
    }

    return this.createMessage(me, msg.channel, content, file)
  }

  /**
   * Try to send a message.
   * @param   {ExtendedUser}               me      The bot user {@link https://abal.moe/Eris/docs/ExtendedUser|(link)}.
   * @param   {TextChannel}                channel The channel to send the message in {@link https://abal.moe/Eris/docs/TextChannel|(link)}.
   * @param   {string|any}                 content The content of the message.
   * @param   {any}                        file    The file to send (if any).
   * @returns {Promise<Message|void>|void}
   */
  createMessage (me, channel, content, file) {
    const permissions = channel.permissionsOf(me.id)
    if (this._requiredSendPermissions.every((perm) => permissions.has(perm))) {
      return channel.createMessage(content, file)
        .catch((error) => {
          logger.warn(`Failed to send: ${error}`)
        })
    }
  }

  /**
   * Try to send a message.
   * @param   {ExtendedUser}  me      The bot user {@link https://abal.moe/Eris/docs/ExtendedUser|(link)}.
   * @param   {Message}       msg     The message that prompted the DM {@link https://abal.moe/Eris/docs/Message|(link)}.
   * @param   {string|any}    content The content of the message.
   * @param   {any}           file    The file to send (if any).
   * @returns {Promise<Message | undefined>}
   */
  createDirectMessage (me, msg, content, file, notify = true) {
    return msg.author.getDMChannel()
      .then((dm) => dm.createMessage(content, file))
      .then(async (success) => 'DM sent.')
      .catch(async (error) => {
        logger.warn(`Could not open DM: ${error}`)
        return {
          content,
          file
        }
      })
      .then((response) => {
        if (msg.channel.type === 0 && notify) {
          return this.createMessage(
            me, msg.channel, response.content || response, response.file
          )
        }
      })
  }

  /**
   * Process a message read by the bot.
   * @param {DataClient} bot The bot object.
   * @param {Message}    msg The message to process {@link https://abal.moe/Eris/docs/Message|(link)}.
   */
  async processMessage (bot, msg) {
    if (!msg.content || this._isBotMessage(bot.user, msg)) {
      return
    }

    const context = await this._parseParamsForCommand(
      this._cleanParams(msg.content),
      msg,
      bot
    )

    if (context) {
      return this._tryToExecute(bot, context)
        .then(({ context, response }) =>
          this._processCommandResponse(bot, context, response)
            .catch((error) => {
              logger.error(`error processing command response: ${error.stack}`)
            })
        )
        .catch((error) => {
          logger.error(`error processing message: ${error.stack}`)
        })
    }
  }

  async _parseParamsForCommand (params, msg, bot) {
    const first = params.shift()
    let cmd
    if (first === bot.user.id && params.length > 0) {
      cmd = params.shift()
    } else if (first.startsWith(bot.user.id)) {
      cmd = first.substring(bot.user.id.length)
    }

    if (!cmd) {
      const dbGuild = msg.channel.guild
        ? await bot.dbm.newQuery('guild').get(msg.channel.guild.id)
        : undefined
      const prefix = (dbGuild && dbGuild.get('prefix')) || this.defaultPrefix

      if (params.length === 0 && first === bot.user.id) {
        this._sendHelp(prefix, msg, bot.user)
        return
      } else if (first === prefix) {
        cmd = params.shift()
      } else if (first.startsWith(prefix)) {
        cmd = first.substring(prefix.length)
      }
    }

    const command = bot.findCommand(cmd)
    if (command) {
      return { command, msg, params, channel: msg.channel }
    }
  }

  _cleanParams (content) {
    return [ ...content.matchAll(/(".+?"|'.+?')|[\S]+/g) ]
      .map(
        ([ match, group ]) => (group ? group.slice(1, -1) : match)
          .replace(/<[@|#][&|!]?([0-9]+)>/g, (match, capture) => capture)
          .replace(/[\uFE00-\uFE0F]/g, '')
      )
  }

  async _tryToExecute (bot, context) {
    const {
      command,
      params
    } = context

    if (params.length < command.parameters.length) {
      return this._badCommand(context, 'insufficient parameters!')
    }

    const permissionStatus = await this.hasPermission(bot, context)

    if (!permissionStatus.ok) {
      return this._badCommand(
        context,
        permissionStatus.message
      )
    }

    for (const middleware of command.middleware) {
      try {
        await middleware.run(bot, context)
      } catch (e) {
        logger.error('MIDDLEWARE ERROR:', e)
        return this._badCommand(
          context,
          middleware.failMessage || 'There was an error, report this!'
        )
      }
    }

    const subContext = this._checkSubCommand(bot, context)

    if (subContext) {
      return this._tryToExecute(bot, subContext)
    }

    let response
    try {
      response = await command.run(bot, context)
    } catch (error) {
      logger.error('Command error:', error)
      response =
        'There was an error processing your request, please try again later.'
    }

    return {
      context,
      response
    }
  }

  /**
   * Check if a command can be executed in the given context.
   * @param   {DataClient}       bot     The DataClient.
   * @param   {CommandContext}   context The CommandContext.
   * @returns {Promise<boolean>}
   */
  async hasPermission (bot, context) {
    const {
      command,
      msg
    } = context

    if (command.dmOnly) {
      if (msg.channel.guild) {
        return {
          ok: false,
          message: 'only allowed in a dm'
        }
      }
    } else if (command.guildOnly && !msg.channel.guild) {
      return {
        ok: false,
        message: 'only allowed in a guild'
      }
    }

    if (!command.permission) {
      return {
        ok: true,
        message: 'no permissions'
      }
    }

    const criteria = [
      command.permission,
      ...(this.permissions || []).filter(
        (permission) => permission.level > command.permission.level
      )
    ]

    for (const criterion of criteria) {
      if (await criterion.run(bot, context)) {
        return {
          ok: true,
          message: `user has permission level ${criterion.level}`
        }
      }
    }

    return {
      ok: false,
      message: get(
        command,
        [ 'permission', 'reason' ],
        'You do not have the required permissions.'
      )
    }
  }

  _checkSubCommand (bot, context) {
    const {
      params,
      command: {
        subCommands: commands
      }
    } = context

    const subCommand = bot.findCommand(params[0], commands)
    if (subCommand) {
      return { ...context, command: subCommand, params: params.slice(1) }
    }
  }

  async _processCommandResponse (bot, context, response) {
    const {
      msg,
      command
    } = context

    if (response) {
      const content = {
        content: typeof response === 'string'
          ? response
          : response.content || '',
        embed: response.embed
      }

      const newMessage = await (
        response.dm
          ? this.createDirectMessage(bot.user, msg, content, response.file)
          : this.createMessage(bot.user, msg.channel, content, response.file)
      )

      if (command.postHook) {
        command.postHook(bot, context, newMessage)
      }

      const shouldDeleteResponse = command.deleteResponse != null
        ? command.deleteResponse
        : this.deleteResponse
      if (newMessage && shouldDeleteResponse && !response.badCommand) {
        setTimeout(
          () => this.deleteMessage(bot.user, newMessage),
          command.deleteResponseDelay != null
            ? command.deleteResponseDelay
            : this.deleteResponseDelay
        )
      }
    }
    const shouldDeleteInvoking = command.deleteInvoking != null
      ? command.deleteInvoking
      : this.deleteInvoking
    if (shouldDeleteInvoking) {
      this.deleteMessage(bot.user, msg)
    }
  }

  /**
   * Create and delete a response message based on a bad command invocation.
   * @private
   * @param   {CommandContext} context The CommandContext.
   * @param   {string}         issue A message describing the issue with the command.
   */
  async _badCommand (context, issue) {
    return {
      context,
      response: {
        content: `${context.msg.author.mention} ${issue}`,
        badCommand: true,
        dm: context.msg.channel.type !== 0
      }
    }
  }

  /**
   * Check a message to see if it invokes a command.
   * @private
   * @param   {ExtendedUser} me     The bot user {@link https://abal.moe/Eris/docs/ExtendedUser|(link)}.
   * @param   {Message}      msg    The message to check for a command {@link https://abal.moe/Eris/docs/Message|(link)}.
   * @param   {string}       prefix The designated command prefix for the given guild.
   * @return  {boolean}             Whether or not this message is invoking a command.
   */
  _isBotMessage (me, msg) {
    return msg.author.id === me.id || msg.author.bot
  }

  /**
   * Check a message to see if it mentions the bot.
   * @private
   * @param  {ExtendedUser} me  The bot user {@link https://abal.moe/Eris/docs/ExtendedUser|(link)}.
   * @param  {Message}      msg The message to check for mention {@link https://abal.moe/Eris/docs/Message|(link)}.
   * @return {boolean}          Whether or not this message mentions the bot.
   */
  _isMentioned (me, msg) {
    return msg.mentions.some((user) => user.id === me.id)
  }

  /**
   * Check if a message was sent in a guild.
   * @private
   * @param   {Message} msg The message to check {@link https://abal.moe/Eris/docs/Message|(link)}.
   * @return  {boolean}     Whether or not the message was sent in a guild.
   */
  _isGuild (msg) {
    return !!msg.channel.guild
  }

  /**
   * Send a help message in chat.
   * @private
   * @param   {string}  prefix The prefix used in the server the message was sent.
   * @param   {Message} msg    The message needing help {@link https://abal.moe/Eris/docs/Message|(link)}.
   */
  _sendHelp (prefix, msg, me) {
    return this.replyToMessage(
      me,
      msg,
      `Hello! The prefix is \`${prefix}\`, try \`${prefix}help\``
    )
  }
}

/**
 * @typedef  OratorOptions
 * @property {string}  [defaultPrefix]             The default command prefix.
 * @property {boolean} [deleteInvoking=false]      Default behavior for whether or not the bot should delete the message that invoked a command.
 * @property {boolean} [deleteResponse=false]      Default behavior for whether or not the bot should delete the message response from a command.
 * @property {number}  [deleteResponseDelay=10000] Default behavior for how many miliseconds to wait before deleting the bots response from a command.
 */

module.exports = Orator