diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index 8ef9fe2..6277fde 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -1,6 +1,6 @@ import Command from "../../core/command"; import {toTitleCase} from "../../core/lib"; -import {loadableCommands, categories} from "../../core/command"; +import {loadableCommands, categories} from "../../core/loader"; import {getPermissionName} from "../../core/permissions"; export default new Command({ @@ -32,69 +32,7 @@ export default new Command({ }, any: new Command({ async run($) { - const commands = await loadableCommands; - let header = $.args.shift() as string; - let command = commands.get(header); - - if (!command || header === "test") { - $.channel.send(`No command found by the name \`${header}\`!`); - return; - } - - if (command.originalCommandName) header = command.originalCommandName; - else console.warn(`originalCommandName isn't defined for ${header}?!`); - - let permLevel = command.permission ?? 0; - let usage = command.usage; - let invalid = false; - - let selectedCategory = "Unknown"; - - for (const [category, headers] of categories) { - if (headers.includes(header)) { - if (selectedCategory !== "Unknown") - console.warn( - `Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.` - ); - else selectedCategory = toTitleCase(category); - } - } - - for (const param of $.args) { - const type = command.resolve(param); - command = command.get(param); - permLevel = command.permission ?? permLevel; - - if (permLevel === -1) permLevel = command.permission; - - switch (type) { - case Command.TYPES.SUBCOMMAND: - header += ` ${command.originalCommandName}`; - break; - case Command.TYPES.USER: - header += " "; - break; - case Command.TYPES.NUMBER: - header += " "; - break; - case Command.TYPES.ANY: - header += " "; - break; - default: - header += ` ${param}`; - break; - } - - if (type === Command.TYPES.NONE) { - invalid = true; - break; - } - } - - if (invalid) { - $.channel.send(`No command found by the name \`${header}\`!`); - return; - } + // [category, commandName, command, subcommandInfo] = resolveCommandInfo(); let append = ""; @@ -123,18 +61,10 @@ export default new Command({ append = "Usages:" + (list.length > 0 ? `\n${list.join("\n")}` : " None."); } else append = `Usage: \`${header} ${usage}\``; - let aliases = "None"; - - if (command.aliases.length > 0) { - aliases = ""; - - for (let i = 0; i < command.aliases.length; i++) { - const alias = command.aliases[i]; - aliases += `\`${alias}\``; - - if (i !== command.aliases.length - 1) aliases += ", "; - } - } + const formattedAliases: string[] = []; + for (const alias of command.aliases) formattedAliases.push(`\`${alias}\``); + // Short circuit an empty string, in this case, if there are no aliases. + const aliases = formattedAliases.join(", ") || "None"; $.channel.send( `Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${selectedCategory}\`\nPermission Required: \`${getPermissionName( diff --git a/src/core/command.ts b/src/core/command.ts index c13b3a3..4cfb777 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -2,7 +2,8 @@ import {parseVars} from "./lib"; import {Collection} from "discord.js"; import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js"; import {getPrefix} from "../core/structures"; -import glob from "glob"; +import {SingleMessageOptions} from "./libd"; +import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; interface CommandMenu { args: any[]; @@ -27,7 +28,7 @@ interface CommandOptions { any?: Command; } -export enum TYPES { +enum TYPES { SUBCOMMAND, USER, NUMBER, @@ -47,7 +48,6 @@ export default class Command { public user: Command | null; public number: Command | null; public any: Command | null; - public static readonly TYPES = TYPES; constructor(options?: CommandOptions) { this.description = options?.description || "No description."; @@ -120,6 +120,67 @@ export default class Command { } else this.run($).catch(handler.bind($)); } + // Will return null if it successfully executes, SingleMessageOptions if there's an error (to let the user know what it is). + public async actualExecute(args: string[], tmp: any): Promise { + // Subcommand Recursion // + let command = commands.get(header)!; + //resolveSubcommand() + const params: any[] = []; + let isEndpoint = false; + let permLevel = command.permission ?? 0; + + for (const param of args) { + if (command.endpoint) { + if (command.subcommands.size > 0 || command.user || command.number || command.any) + console.warn("An endpoint cannot have subcommands!"); + isEndpoint = true; + break; + } + + const type = command.resolve(param); + command = command.get(param); + permLevel = command.permission ?? permLevel; + + if (type === TYPES.USER) { + const id = param.match(/\d+/g)![0]; + try { + params.push(await message.client.users.fetch(id)); + } catch (error) { + return message.channel.send(`No user found by the ID \`${id}\`!`); + } + } else if (type === TYPES.NUMBER) params.push(Number(param)); + else if (type !== TYPES.SUBCOMMAND) params.push(param); + } + + if (!message.member) + return console.warn("This command was likely called from a DM channel meaning the member object is null."); + + if (!hasPermission(message.member, permLevel)) { + const userPermLevel = getPermissionLevel(message.member); + return message.channel.send( + `You don't have access to this command! Your permission level is \`${getPermissionName( + userPermLevel + )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( + permLevel + )}\` (${permLevel}).` + ); + } + + if (isEndpoint) return message.channel.send("Too many arguments!"); + + command.execute({ + args: params, + author: message.author, + channel: message.channel, + client: message.client, + guild: message.guild, + member: message.member, + message: message + }); + + return null; + } + public resolve(param: string): TYPES { if (this.subcommands.has(param)) return TYPES.SUBCOMMAND; // Any Discord ID format will automatically format to a user ID. @@ -154,84 +215,73 @@ export default class Command { return command; } -} -// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command. -export const categories = new Collection(); + // Returns: [category, command name, command, available subcommands: [type, subcommand]] + public resolveCommandInfo(args: string[]): [string, string, Command, Collection] { + const commands = await loadableCommands; + let header = args.shift(); + let command = commands.get(header); -/** Returns the cache of the commands if it exists and searches the directory if not. */ -export const loadableCommands = (async () => { - const commands = new Collection(); - // Include all .ts files recursively in "src/commands/". - const files = await globP("src/commands/**/*.ts"); - // Extract the usable parts from "src/commands/" if: - // - The path is 1 to 2 subdirectories (a or a/b, not a/b/c) - // - Any leading directory isn't "modules" - // - The filename doesn't end in .test.ts (for jest testing) - // - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates - const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/; - const lists: {[category: string]: string[]} = {}; + if (!command || header === "test") { + $.channel.send(`No command found by the name \`${header}\`!`); + return; + } - for (const path of files) { - const match = pattern.exec(path); + if (command.originalCommandName) header = command.originalCommandName; + else console.warn(`originalCommandName isn't defined for ${header}?!`); - if (match) { - const commandID = match[1]; // e.g. "utilities/info" - const slashIndex = commandID.indexOf("/"); - const isMiscCommand = slashIndex !== -1; - const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous"; - const commandName = isMiscCommand ? commandID.substring(slashIndex + 1) : commandID; // e.g. "info" - // If the dynamic import works, it must be an object at the very least. Then, just test to see if it's a proper instance. - const command = (await import(`../commands/${commandID}`)).default as unknown; + let permLevel = command.permission ?? 0; + let usage = command.usage; + let invalid = false; - if (command instanceof Command) { - command.originalCommandName = commandName; + let selectedCategory = "Unknown"; - if (commands.has(commandName)) { + for (const [category, headers] of categories) { + if (headers.includes(header)) { + if (selectedCategory !== "Unknown") console.warn( - `Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!` + `Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.` ); - } else { - commands.set(commandName, command); - } - - for (const alias of command.aliases) { - if (commands.has(alias)) { - console.warn( - `Top-level alias "${alias}" from command "${commandID}" already exists either as a command or alias!` - ); - } else { - commands.set(alias, command); - } - } - - if (!(category in lists)) lists[category] = []; - lists[category].push(commandName); - - console.log(`Loading Command: ${commandID}`); - } else { - console.warn(`Command "${commandID}" has no default export which is a Command instance!`); + else selectedCategory = toTitleCase(category); } } - } - for (const category in lists) { - categories.set(category, lists[category]); - } + for (const param of args) { + const type = command.resolve(param); + command = command.get(param); + permLevel = command.permission ?? permLevel; - return commands; -})(); + if (permLevel === -1) permLevel = command.permission; -function globP(path: string) { - return new Promise((resolve, reject) => { - glob(path, (error, files) => { - if (error) { - reject(error); - } else { - resolve(files); + switch (type) { + case TYPES.SUBCOMMAND: + header += ` ${command.originalCommandName}`; + break; + case TYPES.USER: + header += " "; + break; + case TYPES.NUMBER: + header += " "; + break; + case TYPES.ANY: + header += " "; + break; + default: + header += ` ${param}`; + break; } - }); - }); + + if (type === TYPES.NONE) { + invalid = true; + break; + } + } + + if (invalid) { + $.channel.send(`No command found by the name \`${header}\`!`); + return; + } + } } // If you use promises, use this function to display the error in chat. diff --git a/src/core/handler.ts b/src/core/handler.ts index 2a9fe42..d0cba3c 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -1,28 +1,17 @@ import {client} from "../index"; -import Command, {loadableCommands} from "./command"; -import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; +import {loadableCommands} from "./loader"; import {Permissions, Message} from "discord.js"; import {getPrefix} from "./structures"; import {Config} from "./structures"; -/////////// -// Steps // -/////////// -// 1. Someone sends a message in chat. -// 2. Check if bot, then load commands. -// 3. Check if "...". If not, check if "@...". Resolve prefix and cropped message (if possible). -// 4. Test if bot has permission to send messages. -// 5. Once confirmed as a command, resolve the subcommand. -// 6. Check permission level and whether or not it's an endpoint. -// 7. Execute command if all successful. - -// For custom message events that want to cancel this one on certain conditions. +// For custom message events that want to cancel the command handler on certain conditions. const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot]; export function addInterceptRule(handler: (message: Message) => boolean) { interceptRules.push(handler); } +// Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined. client.on("message", async (message) => { for (const shouldIntercept of interceptRules) { if (shouldIntercept(message)) { @@ -30,139 +19,57 @@ client.on("message", async (message) => { } } - const commands = await loadableCommands; - - let prefix = getPrefix(message.guild); - const originalPrefix = prefix; - let exitEarly = !message.content.startsWith(prefix); - const clientUser = message.client.user; - let usesBotSpecificPrefix = false; - - // If the client user exists, check if it starts with the bot-specific prefix. - if (clientUser) { - // If the prefix starts with the bot-specific prefix, go off that instead (these two options must mutually exclude each other). - // The pattern here has an optional space at the end to capture that and make it not mess with the header and args. - const matches = message.content.match(new RegExp(`^<@!?${clientUser.id}> ?`)); - - if (matches) { - prefix = matches[0]; - exitEarly = false; - usesBotSpecificPrefix = true; - } - } - - // If it doesn't start with the current normal prefix or the bot-specific unique prefix, exit the thread of execution early. - // Inline replies should still be captured here because if it doesn't exit early, two characters for a two-length prefix would still trigger commands. - if (exitEarly) return; - - const [header, ...args] = message.content.substring(prefix.length).split(/ +/); - - // If the message is just the prefix itself, move onto this block. - if (header === "" && args.length === 0) { - // I moved the bot-specific prefix to a separate conditional block to separate the logic. - // And because it listens for the mention as a prefix instead of a free-form mention, inline replies (probably) shouldn't ever trigger this unintentionally. - if (usesBotSpecificPrefix) { - message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${originalPrefix}\`.`); - return; - } - } - - if (!commands.has(header)) return; - + // Continue if the bot has permission to send messages in this channel. if ( - message.channel.type === "text" && - !message.channel.permissionsFor(message.client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES) + message.channel.type === "dm" || + message.channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES) ) { - let status; + const text = message.content; + const prefix = getPrefix(message.guild); - if (message.member?.hasPermission(Permissions.FLAGS.ADMINISTRATOR)) - status = - "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended."; - else - status = - "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong."; - - return message.author.send( - `I don't have permission to send messages in ${message.channel.toString()}. ${status}` - ); - } - - console.log( - `${message.author.username}#${message.author.discriminator} executed the command "${header}" with arguments "${args}".` - ); - - // Subcommand Recursion // - let command = commands.get(header)!; - //resolveSubcommand() - - if (!message.member) - return console.warn("This command was likely called from a DM channel meaning the member object is null."); - - if (!hasPermission(message.member, permLevel)) { - const userPermLevel = getPermissionLevel(message.member); - return message.channel.send( - `You don't have access to this command! Your permission level is \`${getPermissionName( - userPermLevel - )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( - permLevel - )}\` (${permLevel}).` - ); - } - - if (isEndpoint) return message.channel.send("Too many arguments!"); - - // Execute with dynamic library attached. // - // The purpose of using $.bind($) is to clone the function so as to not modify the original $. - // The cloned function doesn't copy the properties, so Object.assign() is used. - // Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one. - command.execute({ - args: params, - author: message.author, - channel: message.channel, - client: message.client, - guild: message.guild, - member: message.member, - message: message - }); -}); - -// Takes a base command and a list of string parameters and returns: -// - The resolved subcommand -// - The resolved parameters -// - Whether or not an endpoint has been broken -// - The permission level required -async function resolveSubcommand(command: Command, args: string[]): [Command, any[], boolean, number] { - const params: any[] = []; - let isEndpoint = false; - let permLevel = command.permission ?? 0; - - for (const param of args) { - if (command.endpoint) { - if (command.subcommands.size > 0 || command.user || command.number || command.any) - console.warn("An endpoint cannot have subcommands!"); - isEndpoint = true; - break; + // First, test if the message is just a ping to the bot. + if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) { + message.channel.send(`${message.author}, my prefix on this guild is \`${prefix}\`.`); } + // Then check if it's a normal command. + else if (text.startsWith(prefix)) { + const [header, ...args] = text.substring(prefix.length).split(/ +/); + const commands = await loadableCommands; - const type = command.resolve(param); - command = command.get(param); - permLevel = command.permission ?? permLevel; + if (commands.has(header)) { + const command = commands.get(header)!; - if (type === Command.TYPES.USER) { - const id = param.match(/\d+/g)![0]; - try { - params.push(await message.client.users.fetch(id)); - } catch (error) { - return message.channel.send(`No user found by the ID \`${id}\`!`); + // Send the arguments to the command to resolve and execute. + // TMP[MAKE SURE TO REPLACE WITH command.execute WHEN FINISHED] + const result = await command.actualExecute(args, { + author: message.author, + channel: message.channel, + client: message.client, + guild: message.guild, + member: message.member, + message: message + }); + + // If something went wrong, let the user know (like if they don't have permission to use a command). + if (result) { + message.channel.send(result); + } } - } else if (type === Command.TYPES.NUMBER) params.push(Number(param)); - else if (type !== Command.TYPES.SUBCOMMAND) params.push(param); + } + } else { + message.author.send( + `I don't have permission to send messages in ${message.channel}. ${ + message.member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR) + ? "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended." + : "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong." + }` + ); } -} +}); client.once("ready", () => { if (client.user) { - console.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`); + console.ready(`Logged in as ${client.user.tag}.`); client.user.setActivity({ type: "LISTENING", name: `${Config.prefix}help` diff --git a/src/core/loader.ts b/src/core/loader.ts new file mode 100644 index 0000000..a7f7d7d --- /dev/null +++ b/src/core/loader.ts @@ -0,0 +1,103 @@ +import {Collection} from "discord.js"; +import glob from "glob"; +import Command from "./command"; + +// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command. +export const categories = new Collection(); + +/** Returns the cache of the commands if it exists and searches the directory if not. */ +export const loadableCommands = (async () => { + const commands = new Collection(); + // Include all .ts files recursively in "src/commands/". + const files = await globP("src/commands/**/*.ts"); + // Extract the usable parts from "src/commands/" if: + // - The path is 1 to 2 subdirectories (a or a/b, not a/b/c) + // - Any leading directory isn't "modules" + // - The filename doesn't end in .test.ts (for jest testing) + // - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates + const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/; + const lists: {[category: string]: string[]} = {}; + + for (const path of files) { + const match = pattern.exec(path); + + if (match) { + const commandID = match[1]; // e.g. "utilities/info" + const slashIndex = commandID.indexOf("/"); + const isMiscCommand = slashIndex !== -1; + const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous"; + const commandName = isMiscCommand ? commandID.substring(slashIndex + 1) : commandID; // e.g. "info" + // If the dynamic import works, it must be an object at the very least. Then, just test to see if it's a proper instance. + const command = (await import(`../commands/${commandID}`)).default as unknown; + + if (command instanceof Command) { + command.originalCommandName = commandName; + + if (commands.has(commandName)) { + console.warn( + `Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!` + ); + } else { + commands.set(commandName, command); + } + + for (const alias of command.aliases) { + if (commands.has(alias)) { + console.warn( + `Top-level alias "${alias}" from command "${commandID}" already exists either as a command or alias!` + ); + } else { + commands.set(alias, command); + } + } + + if (!(category in lists)) lists[category] = []; + lists[category].push(commandName); + + console.log(`Loading Command: ${commandID}`); + } else { + console.warn(`Command "${commandID}" has no default export which is a Command instance!`); + } + } + } + + for (const category in lists) { + categories.set(category, lists[category]); + } + + return commands; +})(); + +function globP(path: string) { + return new Promise((resolve, reject) => { + glob(path, (error, files) => { + if (error) { + reject(error); + } else { + resolve(files); + } + }); + }); +} + +// Gathers a list of categories and top-level commands. +// Returns: new Collection() +/*export async function getCommandList(): Promise> { + const categorizedCommands = new Collection(); + const commands = await loadableCommands; + + for (const [category, headers] of categories) { + const commandList: Command[] = []; + + for (const header of headers) { + if (header !== "test") { + // If this is somehow undefined, it'll show up as an error when implementing a help command. + commandList.push(commands.get(header)!); + } + } + + categorizedCommands.set(toTitleCase(category), commandList); + } + + return categorizedCommands; +}*/