@skullbite/hibiscus
Version:
Command Handler for Eris
458 lines (452 loc) • 22.3 kB
JavaScript
const Eris = require("eris")
const CommandContext = require("./context.js")
const { Command, Group } = require("./command.js")
const Converter = require("./converter.js")
const Errors = require("./errors")
const { MessageCollector, ReactionCollector } = require("eris-collector")
const errors = require("./errors")
function escapeRegex(str) {
return str.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&')
}
class Bot extends Eris.Client {
constructor(token, options, commandOptions) {
super(token, options)
if (!options) throw new Error("Hibiscus client is missing eris options.")
if (!commandOptions) throw new Error("Hibiscus client is missing command options.")
const { prefix, usePrefixSpaces, ownerID } = commandOptions
this.commandOptions = {
prefix: prefix,
usePrefixSpaces: usePrefixSpaces || false,
ownerID: ownerID || ''
}
if (!prefix || prefix === [] || prefix === "") throw new Error("Empty Prefix Caught.")
// TODO: migrate to Eris.Collection
this.commands = new Map()
this.categories = new Map()
this.cooldowns = new Map()
this.once("shardPreReady", () => {
this.preReady = true
if (!this.getCommand("help")) this.addCommand(this.defaultHelp)
try {
typeof this.commandOptions.prefix === "function" &&
(this.commandOptions.prefix.length === 0 || this.commandOptions.prefix.length === 1)
? this.commandOptions.prefix = this.commandOptions.prefix.bind(this)() : {}
}
catch { }
let pre = this.commandOptions.prefix
switch (typeof pre) {
case "string":
this.commandOptions.prefix = escapeRegex(pre)
break
case "object":
this.commandOptions.prefix = pre.map(p => escapeRegex(p))
break
default:
break
}
})
this.on("messageCreate", async (m) => await this.processCommands(m))
}
async processCommands(msg) {
if (msg.author.bot) return
let prefix, re, p
switch (typeof this.commandOptions.prefix) {
case 'string':
re = `${this.commandOptions.prefix}`
break
case 'object':
re = `${this.commandOptions.prefix.map(e => e).join("|")}`
break
case 'function':
let pre = await this.commandOptions.prefix.bind(this)(this, msg)
switch (typeof pre) {
case 'string':
re = `${pre}`
break
case 'function':
p = pre.bind(this)()
re = `^${typeof p === 'string' ? p : p.join('|')}`
break
case 'object':
re = `${pre.map(e => escapeRegex(e.replace(/ /g, "\\s?").replace(/\?/g, "\?"))).join('|')}`
break
default:
throw new Error(`Prefix function output must be string or object (array). Not ${typeof pre}.`)
}
default:
throw new Error(`Prefix must be string, object (array) or function. Not ${typeof this.commandOptions.prefix}`)
}
let prefixArray = msg.content.match(new RegExp(`(${re})${this.commandOptions.usePrefixSpaces ? '\\s*?' : ''}`, "i"))
if (!prefixArray) return
prefix = prefixArray[0]
if (!prefix) return
let args = msg.content.slice(prefix.length).split(' ')
const name = args.shift().toLowerCase()
let command = this.commands.get(name) || Array.from(this.commands.values()).filter((command) => command.aliases && command.aliases.includes(name))
if (!command || command.length === 0) return
let cmd = command instanceof Array ? command[0] : command
let ctx = new CommandContext(msg, this, cmd, {}, prefix)
if (cmd.subcommands && args.length > 0) {
let subcmd = cmd.getSubcommand(args.shift().toLowerCase())
if (subcmd) {
ctx.invokedSubcommand = subcmd
cmd = subcmd
}
}
if (cmd.guildOnly && !msg.guildID) {
let err = new Errors.NoPrivate("NO_PRIVATE")
return this.emit("commandError", ctx, err)
}
if (cmd.neededBotPerms) {
if (ctx.guild) {
let missingBotPerms = []
try {
for (let i = 0; i < cmd.neededBotPerms; i++) {
if (!ctx.guild.me.permission.has(cmd.neededBotPerms[i])) throw new Errors.MissingBotPerms("MISSING_BOT_PERMS")
}
}
catch (e) {
return this.emit("commandError", ctx, e)
}
}
}
if (cmd.neededMemberPerms) {
if (ctx.guild) {
let missingMemberPerms = []
try {
for (let i = 0; i < cmd.neededMemberPerms; i++) {
if (!ctx.author.permission.has(cmd.neededUserPerms[i])) throw new Errors.MissingMemberPerms("MISSING_MEMBER_PERMS")
}
}
catch (e) {
return this.emit("commandError", ctx, e)
}
}
}
if (cmd.category) {
const checks = this.getCategory(cmd.category).globalChecks
if (checks) {
for (let check of checks) {
try {
if (!check) throw new TypeError(`Check must be a function. Not ${typeof check}.`)
let res
if (check.constructor.name === "AsyncFunction") res = await check(ctx)
else res = check(ctx)
if (!res) throw new Errors.CheckFailure(check.name)
}
catch (e) {
return this.emit("commandError", ctx, e)
}
}
}
}
if (cmd.checks.length) {
for (let check of cmd.checks) {
try {
if (!check) throw new TypeError(`Check must be a function. Not ${typeof check}.`)
let res
if (check.constructor.name === "AsyncFunction") res = await check(ctx)
else res = check(ctx)
if (!res) throw new Errors.CheckFailure(check.name)
}
catch (e) {
return this.emit("commandError", ctx, e)
}
}
}
const timer = Number(new Date())
this.emit("beforeCommandExecute", ctx)
let checkArgs = () => {
if (!cmd.args) return {}
const properArgs = {}
let requiredArgs = new Array(cmd.args).reduce((a, c) => a + c.required ? 1 : 0, 0)
if (args.length < requiredArgs) throw new Errors.MissingArguments("MISSING_ARGS")
if (args.length > cmd.args.length && !cmd.args[cmd.args.length - 1].useRest) throw new Errors.InvalidArguments("INVALID_ARGS")
for (let i = 0; i < cmd.args.length; i++) {
let argg, argument = cmd.args[i], toUse = argument.useRest ? args.slice(i).join(" ") : args[i]
switch (argument.type) {
case "str" || undefined:
argg = String(toUse)
break
case "num":
argg = Number(toUse)
break
case "member":
try { argg = Converter.memberConverter(ctx, toUse) }
catch { argg = undefined }
break
case "user":
try { argg = Converter.userConverter(ctx, toUse) }
catch { argg = undefined }
break
case "channel":
try { argg = Converter.channelConverter(ctx, toUse) }
catch { argg = undefined }
break
}
if (argument.required && (Number.isNaN(argg) || argg === "" || argg === "undefined" || ((argument.type === "member" || argument.type === "user") && !argg))) throw new Errors.InvalidArguments("INVALID_ARGS")
properArgs[argument.name] = argg
if (argument.useRest) break
}
ctx.args = properArgs
}
try { checkArgs() }
catch (e) {
return this.emit("commandError", ctx, e)
}
if (cmd.cooldown) {
let times = this.cooldowns.get(cmd.name)
if (!times) {
this.cooldowns.set(cmd.name, new Map())
times = this.cooldowns.get(cmd.name)
}
const now = Number(new Date())
if (times.has(msg.author.id)) {
const expires = Number(times.get(msg.author.id)) + (cmd.cooldown * 1000)
if (now < expires) {
const left = (expires - now) / 1000
return this.emit("commandCooldown", ctx, left)
}
}
times.set(msg.author.id, new Date())
setTimeout(() => times.delete(msg.author.id), cmd.cooldown)
}
this.emit("commandExecute", ctx)
try {
await cmd.exec.bind(this)(ctx).then(() => {
this.emit("afterCommandExecute", ctx, timer)
})
}
catch (e) {
let err = new Errors.ExecutionError("EXEC_ERR")
err.orginal = e
this.emit("commandError", ctx, err)
}
}
async paginate(ctx, options) {
let msg,
// allSameType = (arr) => new Set(arr.map((x) => typeof x)).size <= 1,
currentPage = 0,
bot = (ID) => {
let u = Converter.userConverter(ctx, ID)
return u ? u.bot : false
}
let opts = {
pages: options.pages ? options.pages : [],
type: options.type ? options.type : "reaction",
timeout: options.timeout ? options.timeout : 1000*40,
authorOnly: options.authorOnly || !options.authorOnly ? options.authorOnly : true
}
/* TODO: Proper error handling for incorrect/unmatching types
if (!allSameType(opts.pages.map(i => typeof i))) throw new errors.ExecutionError("Types in pages aren't matching. They can only be string or object types.")
if (!opts.pages.map(i => typeof i).includes("string") || !opts.pages.map(i => typeof i).includes("object")) throw new errors.ExecutionError("Page types can only be string or object types.") */
msg = await ctx.send(opts.pages[currentPage])
if (opts.type === "reaction") {
let emojiList = ["⏪", "◀", "⏹", "▶", "⏩"]
if (ctx.guild) {
if (ctx.guild.me.permission.has("addReactions")) emojiList.map(a => msg.addReaction(a))
else throw new errors.MissingBotPerms("Cannot add reactions. Missing permissions.")
}
else {
emojiList.map(a => msg.addReaction(a))
}
let filter = (m, emoji, userID) => emojiList.includes(emoji.name) && opts.authorOnly ? userID === ctx.author.id : !bot(userID)
let c = new ReactionCollector(this, msg, filter, { time: opts.timeout })
c.on("collect", (m, e, uID) => {
switch (e.name) {
case "⏪":
currentPage = 0
msg.edit(opts.pages[currentPage])
break
case "◀":
if (currentPage === 0) return
currentPage--
msg.edit(opts.pages[currentPage])
break
case "⏹":
try { msg.removeReactions() }
catch { msg.delete() }
c.stop()
break
case "▶":
if (currentPage === opts.pages.length - 1) return
currentPage++
msg.edit(opts.pages[currentPage])
break
case "⏩":
currentPage = opts.pages.length - 1
msg.edit(opts.pages[currentPage])
break
default: return
}
try { msg.removeReaction(e.name, uID) }
catch { }
})
}
else if (opts.type === "message") {
let textList = ["first", "previous", "stop", "next", "last"]
let menu = await ctx.send(`**First** | **Previous** | **Stop** | **Next** | **Last**`)
let filter = (m) => textList.includes(m.content.toLowerCase()) && opts.authorOnly ? m.author.id === ctx.author.id : !bot(m.author.id)
let c = new MessageCollector(this, ctx.channel, filter, { time: opts.timeout })
c.on("collect", m => {
switch (m.content.toLowerCase()) {
case "first":
currentPage = 0
msg.edit(opts.pages[currentPage])
break
case "previous":
if (currentPage === 0) return
currentPage--
msg.edit(opts.pages[currentPage])
break
case "stop":
currentPage--
c.stop()
case "next":
if (currentPage === opts.pages.length - 1) return
currentPage++
msg.edit(opts.pages[currentPage])
break
case "last":
currentPage = opts.pages.length - 1
msg.edit(opts.pages[currentPage])
break
default: return
}
try { m.delete() }
catch { }
})
c.on("end", () => {
menu.delete()
})
}
else throw new errors.ExecutionError("Paginator type must be 'message' or 'reaction'.")
}
getHelp(ctx, command = undefined) {
let argList = new Array(), cmd, parentCmd, parentStr
cmd = command ? command : ctx.invokedSubcommand ? ctx.invokedSubcommand : ctx.command
if (cmd.parent) parentCmd = this.getCommand(cmd.parent)
parentStr = parentCmd ? parent.aliases.length ? `[${parent.name}|${parent.aliases.join('|')} ` : parent.name+" " : ""
let usage = cmd.aliases.length ? ctx.prefix + `${parentStr}[${cmd.name}|${cmd.aliases.join('|')}]` : ctx.prefix + parentStr + cmd.name
if (!cmd.args) return usage
cmd.args.map(a => {
a.required ? argList.push(`<${a.name}>`) : argList.push(`[${a.name}]`)
})
return usage + " " + argList.join(" ")
}
loadCategory(category) {
if (this.getCategory(category.name)) throw new Error("Category name has already been registered.")
category.commands.map(cmd => this.addCommand(cmd))
this.categories.set(category.name, category)
}
unloadCategory(name) {
const category = this.getCategory(name)
if (!category) return
category.commands.map(a => {
this.commands.delete(a.name)
})
this.categories.delete(name)
if (category.path) delete require.cache[require.resolve(category.path)]
}
reloadCategory(name) {
let cat = this.getCategory(name)
this.unloadCategory(name)
if (!cat.path) throw new Error("No path to reload from.")
this.loadCategory(require(cat.path))
}
getCategory(name) { return this.categories.get(name) }
addCommand(cmd) {
let check = this.getCommand(cmd.name)
if (check) {
if (check.category) {
this.unloadCategory(check.category)
}
throw new Error("Command name/alias has already been registed.")
}
if (!cmd.exec) {
if (!cmd.subcommands) throw new Error("Command is missing \"exec\" function.")
else {
if (!cmd.subcommands.size) throw new Error(`${cmd.name}: No subcommands.`)
let sub = Array.from(cmd.subcommands.values()).map(a => `> \`${a.name}\` — ${a.description ? a.description : "No Description."}`)
cmd.setExec(async function(ctx) {
ctx.send(`\`🌺\` Subcommands | \`${cmd.name} (${cmd.subcommands.size})\`\n${sub.join("\n")}`)
})
}
}
if (cmd.aliases) {
if (new Set(cmd.aliases).size !== cmd.aliases.length) throw new Error("Command aliases already registered.")
}
this.commands.set(cmd.name, cmd)
if (cmd.cooldown) this.cooldowns.set(cmd.name, new Map())
}
removeCommand(name) {
const cmd = this.getCommand(name)
if (!cmd.length) return
this.commands.delete(name)
if (!cmd.category || !cmd.path) return
delete require.cache[require.resolve(cmd.path)]
}
reloadCommand(name) {
const tempCmd = this.getCommand(name)
if (!tempCmd.length) throw new Error("No command found.")
if (!tempCmd.path) throw new Error("No path to reload from.")
this.removeCommand(tempCmd.name)
try {
this.addCommand(name, test)
}
catch (e) {
this.commands.set(tempCmd.name, tempCmd)
throw e
}
}
getCommand(q) {
q = q.split(" ")
let cmd = this.commands.get(q[0]) || Array.from(this.commands.values()).filter((command) => command.aliases && command.aliases.includes(q[0]))
if (!cmd) return null
if (q.length > 1 && cmd && cmd.subcommands) cmd = cmd.getSubcommand(q[1])
return cmd instanceof Array ? cmd[0] : cmd
}
loadEvent(name, path) {
const event = require(path)
if (!event) throw new Error("No event found.")
this.on(name, event.bind(this))
}
unloadEvent(name) {
this.on(name, () => { })
// delete require.cache[require.resolve()]
}
defaultHelp = new Command({ name: "help" })
.setArgs([{ name: "cm", type: "str", useRest: true }])
.setExec(async function (ctx) {
let cats
if (!ctx.args.cm || ctx.args.cm === "undefined") {
let non = Array.from(this.commands.values()).filter((command) => !command.category)
cats = Array.from(this.categories.values()).map((x) => `> ${x.name} (${x.commands.length} Command${x.commands.length === 1 ? "" : 's'})`)
if (non.length) cats.push(`> no-category (${non.length} Command${non.length === 1 ? "" : 's'})`)
await ctx.send(`\`🌺\` Categories\n${cats.join('\n')}\n\n> Get command or category help with \`${ctx.prefix}help [command or module name]\``.replace(new RegExp(`<@!?${this.user.id}>`, "g"), `@${ctx.guild ? ctx.guild.me.nick ? ctx.guild.me.nick : this.user.username : this.user.username}`))
}
else {
let cmd = this.getCommand(ctx.args.cm)
if (!cmd) {
let category = this.getCategory(ctx.args.cm)
if (category) {
let cmds = category.commands.map(x => {
if (!x.hidden) return `> \`${x.name}\` — ${x.description ? x.description : "No Description."}`
})
if (!cmds.length) cmds.push("> No visible commands availible.")
return ctx.send(`\`🌺\` Category | ${category.name}\n${cmds.join('\n')}\n\n> Get command help with \`${ctx.prefix}help [command name]\``.replace(new RegExp(`<@!?${this.user.id}>`, "g"), `@${ctx.guild ? ctx.guild.me.nick ? ctx.guild.me.nick : this.user.username : this.user.username}`))
}
if (ctx.args.cm === 'no-category') {
let cmds = Array.from(this.commands.values()).filter((command) => !command.category).map((x) => {
if (!x.hidden) return `> \`${x.name}\` — ${x.description ? x.description : "No Description."}`
})
if (!cmds.length) cmds.push("> No visible commands availible.")
return ctx.send(`\`🌺\` Category | no-category\n${cmds.join('\n')}\n\n> Get command help with \`${ctx.prefix}help [command name]\``.replace(new RegExp(`<@!?${this.user.id}>`, "g"), `@${ctx.guild ? ctx.guild.me.nick ? ctx.guild.me.nick : this.user.username : this.user.username}`))
}
return ctx.send(`\`❌\` No command or category found.`)
}
return ctx.send(`\`🌺\` Command Help | \`${cmd.parent ? this.getCommand(cmd.parent).name+" "+cmd.name : cmd.name}\`\n> Description: ${cmd.description ? cmd.description : "No Description."}\n> Usage: \`${this.getHelp(ctx, cmd)}\`${cmd.cooldown ? `\n> Cooldown: ${cmd.cooldown}ms` : ""}${cmd.subcommands ? `\n> Subcommands: ${Array.from(cmd.subcommands.values()).map(a => a.name).join(", ")}` : ""}`)
}
})
}
module.exports = Bot