From 6ed4c0988f287a62e616d6fa69194d8a53281ad7 Mon Sep 17 00:00:00 2001 From: WatDuhHekBro <44940783+WatDuhHekBro@users.noreply.github.com> Date: Sun, 4 Apr 2021 22:40:31 -0500 Subject: [PATCH] Implemented rough draft of info resolver method --- src/commands/system/help.ts | 87 ++++++++------- src/core/command.ts | 208 ++++++++++++++++++++++-------------- src/core/handler.ts | 12 +-- src/core/loader.ts | 2 +- 4 files changed, 184 insertions(+), 125 deletions(-) diff --git a/src/commands/system/help.ts b/src/commands/system/help.ts index 6277fde..c772eef 100644 --- a/src/commands/system/help.ts +++ b/src/commands/system/help.ts @@ -1,9 +1,9 @@ -import Command from "../../core/command"; +import {Command, NamedCommand} from "../../core/command"; import {toTitleCase} from "../../core/lib"; import {loadableCommands, categories} from "../../core/loader"; import {getPermissionName} from "../../core/permissions"; -export default new Command({ +export default new NamedCommand({ description: "Lists all commands. If a command is specified, their arguments are listed as well.", usage: "([command, [subcommand/type], ...])", aliases: ["h"], @@ -16,13 +16,7 @@ export default new Command({ for (const header of headers) { if (header !== "test") { - const command = commands.get(header); - - if (!command) - return console.warn( - `Command "${header}" of category "${category}" unexpectedly doesn't exist!` - ); - + const command = commands.get(header)!; output += `\n- \`${header}\`: ${command.description}`; } } @@ -32,44 +26,63 @@ export default new Command({ }, any: new Command({ async run($) { - // [category, commandName, command, subcommandInfo] = resolveCommandInfo(); + // Setup the root command + const commands = await loadableCommands; + let header = $.args.shift() as string; + let command = commands.get(header); + if (!command || header === "test") return $.channel.send(`No command found by the name \`${header}\`.`); + if (!(command instanceof NamedCommand)) + return $.channel.send(`Command is not a proper instance of NamedCommand.`); + if (command.name) header = command.name; + + // Search categories + let category = "Unknown"; + for (const [referenceCategory, headers] of categories) { + if (headers.includes(header)) { + category = toTitleCase(referenceCategory); + break; + } + } + + // Gather info + const result = await command.resolveInfo($.args); + + if (result.type === "error") return $.channel.send(result.message); let append = ""; + command = result.command; - if (usage === "") { + if (command.usage === "") { const list: string[] = []; - command.subcommands.forEach((subcmd, subtag) => { - // Don't capture duplicates generated from aliases. - if (subcmd.originalCommandName === subtag) { - const customUsage = subcmd.usage ? ` ${subcmd.usage}` : ""; - list.push(`- \`${header} ${subtag}${customUsage}\` - ${subcmd.description}`); - } - }); + for (const [tag, subcommand] of result.keyedSubcommandInfo) { + const customUsage = subcommand.usage ? ` ${subcommand.usage}` : ""; + list.push(`- \`${header} ${tag}${customUsage}\` - ${subcommand.description}`); + } - const addDynamicType = (cmd: Command | null, type: string) => { - if (cmd) { - const customUsage = cmd.usage ? ` ${cmd.usage}` : ""; - list.push(`- \`${header} <${type}>${customUsage}\` - ${cmd.description}`); - } - }; - - addDynamicType(command.user, "user"); - addDynamicType(command.number, "number"); - addDynamicType(command.any, "any"); + for (const [type, subcommand] of result.subcommandInfo) { + const customUsage = subcommand.usage ? ` ${subcommand.usage}` : ""; + list.push(`- \`${header} ${type}${customUsage}\` - ${subcommand.description}`); + } append = "Usages:" + (list.length > 0 ? `\n${list.join("\n")}` : " None."); - } else append = `Usage: \`${header} ${usage}\``; + } else { + append = `Usage: \`${header} ${command.usage}\``; + } - 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"; + let aliases = "N/A"; - $.channel.send( - `Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${selectedCategory}\`\nPermission Required: \`${getPermissionName( - permLevel - )}\` (${permLevel})\nDescription: ${command.description}\n${append}`, + if (command instanceof NamedCommand) { + 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. + aliases = formattedAliases.join(", ") || "None"; + } + + return $.channel.send( + `Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${category}\`\nPermission Required: \`${getPermissionName( + result.permission + )}\` (${result.permission})\nDescription: ${command.description}\n${append}`, {split: true} ); } diff --git a/src/core/command.ts b/src/core/command.ts index 20b7f90..79b1693 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -1,4 +1,4 @@ -import {parseVars, requireAllCasesHandledFor} from "./lib"; +import {parseVars} from "./lib"; import {Collection} from "discord.js"; import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, GuildChannel} from "discord.js"; import {getPrefix} from "../core/structures"; @@ -82,6 +82,36 @@ interface ExecuteCommandMetadata { channelType: CHANNEL_TYPE; } +interface CommandInfo { + readonly type: "info"; + readonly command: Command; + readonly subcommandInfo: Collection; + readonly keyedSubcommandInfo: Collection; + readonly permission: number; + readonly nsfw: boolean; + readonly channelType: CHANNEL_TYPE; + readonly args: string[]; +} + +interface CommandInfoError { + readonly type: "error"; + readonly message: string; +} + +interface CommandInfoMetadata { + permission: number; + nsfw: boolean; + channelType: CHANNEL_TYPE; + args: string[]; + usage: string; +} + +export const defaultMetadata = { + permission: 0, + nsfw: false, + channelType: CHANNEL_TYPE.ANY +}; + export class Command { public readonly description: string; public readonly endpoint: boolean; @@ -90,7 +120,7 @@ export class Command { public readonly nsfw: boolean | null; // null (default) indicates to inherit public readonly channelType: CHANNEL_TYPE | null; // null (default) indicates to inherit protected run: (($: CommandMenu) => Promise) | string; - protected readonly subcommands: Collection; // This is the final data structure you'll actually use to work with the commands the aliases point to. + protected readonly subcommands: Collection; // This is the final data structure you'll actually use to work with the commands the aliases point to. protected user: Command | null; protected number: Command | null; protected any: Command | null; @@ -124,7 +154,7 @@ export class Command { // This shouldn't be a problem because I'm hoping that JS stores these as references that point to the same object. for (const name in options.subcommands) { const subcmd = options.subcommands[name]; - subcmd.originalCommandName = name; + subcmd.name = name; const aliases = subcmd.aliases; for (const alias of aliases) { @@ -153,7 +183,7 @@ export class Command { const param = args.shift(); // If there are no arguments left, execute the current command. Otherwise, continue on. - if (!param) { + if (param === undefined) { // See if there is anything that'll prevent the user from executing the command. // 1. Does this command specify a required channel type? If so, does the channel type match? @@ -218,13 +248,9 @@ export class Command { // If the current command is an endpoint but there are still some arguments left, don't continue. if (this.endpoint) return {content: "Too many arguments!"}; - // If the current command's permission level isn't -1 (inherit), then set the permission metadata equal to that. + // Update inherited properties if the current command specifies a property. if (this.permission !== -1) metadata.permission = this.permission; - - // If the current command has an NSFW setting specified, set it. if (this.nsfw !== null) metadata.nsfw = this.nsfw; - - // If the current command doesn't inherit its channel type, set it. if (this.channelType !== null) metadata.channelType = this.channelType; // Resolve the value of the current command's argument (adding it to the resolved args), @@ -257,11 +283,97 @@ export class Command { // Note: Do NOT add a return statement here. In case one of the other sections is missing // a return statement, there'll be a compile error to catch that. } + + // What this does is resolve the resulting subcommand as well as the inherited properties and the available subcommands. + public async resolveInfo(args: string[]): Promise { + return this.resolveInfoInternal(args, {...defaultMetadata, args: [], usage: ""}); + } + + private async resolveInfoInternal( + args: string[], + metadata: CommandInfoMetadata + ): Promise { + const param = args.shift(); + + // If there are no arguments left, return the data or an error message. + if (param === undefined) { + const keyedSubcommandInfo = new Collection(); + const subcommandInfo = new Collection(); + + // Get all the subcommands of the current command but without aliases. + for (const [tag, command] of this.subcommands.entries()) { + // Don't capture duplicates generated from aliases. + if (tag === command.name) { + keyedSubcommandInfo.set(tag, command); + } + } + + // Then get all the generic subcommands. + if (this.user) subcommandInfo.set("", this.user); + if (this.number) subcommandInfo.set("", this.number); + if (this.any) subcommandInfo.set("", this.any); + + return { + type: "info", + command: this, + keyedSubcommandInfo, + subcommandInfo, + ...metadata + }; + } + + // Update inherited properties if the current command specifies a property. + if (this.permission !== -1) metadata.permission = this.permission; + if (this.nsfw !== null) metadata.nsfw = this.nsfw; + if (this.channelType !== null) metadata.channelType = this.channelType; + if (this.usage !== "") metadata.usage = this.usage; + + // Then test if anything fits any hardcoded values, otherwise check if it's a valid keyed subcommand. + if (param === "") { + if (this.user) { + metadata.args.push(""); + return this.user.resolveInfoInternal(args, metadata); + } else { + return { + type: "error", + message: `No subcommand found by the argument list: \`${metadata.args.join(" ")}\`` + }; + } + } else if (param === "") { + if (this.number) { + metadata.args.push(""); + return this.number.resolveInfoInternal(args, metadata); + } else { + return { + type: "error", + message: `No subcommand found by the argument list: \`${metadata.args.join(" ")}\`` + }; + } + } else if (param === "") { + if (this.any) { + metadata.args.push(""); + return this.any.resolveInfoInternal(args, metadata); + } else { + return { + type: "error", + message: `No subcommand found by the argument list: \`${metadata.args.join(" ")}\`` + }; + } + } else if (this.subcommands?.has(param)) { + metadata.args.push(param); + return this.subcommands.get(param)!.resolveInfoInternal(args, metadata); + } else { + return { + type: "error", + message: `No subcommand found by the argument list: \`${metadata.args.join(" ")}\`` + }; + } + } } export class NamedCommand extends Command { public readonly aliases: string[]; // This is to keep the array intact for parent Command instances to use. It'll also be used when loading top-level aliases. - public originalCommandName: string | null; // If the command is an alias, what's the original name? + private originalCommandName: string | null; // If the command is an alias, what's the original name? constructor(options?: NamedCommandOptions) { super(options); @@ -269,74 +381,14 @@ export class NamedCommand extends Command { this.originalCommandName = null; } - // Returns: [category, command name, command, available subcommands: [type, subcommand]] - public async resolveInfo(args: string[]): [string, string, Command, Collection] | null { - // For debug info, use this.originalCommandName? (if it exists?) - const commands = await loadableCommands; - let header = args.shift(); - let command = commands.get(header); + public get name(): string { + if (this.originalCommandName === null) throw new Error("originalCommandName must be set before accessing it!"); + else return this.originalCommandName; + } - 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 over to doing `$help info ` - switch (type) { - case TYPES.SUBCOMMAND: - header += ` ${command.originalCommandName}`; - break; - case TYPES.USER: - header += " "; - break; - case TYPES.NUMBER: - header += " "; - break; - case TYPES.ANY: - header += " "; - break; - case TYPES.NONE: - header += ` ${param}`; - break; - default: - requireAllCasesHandledFor(type); - } - - if (type === TYPES.NONE) { - invalid = true; - break; - } - } - - if (invalid) { - $.channel.send(`No command found by the name \`${header}\`!`); - return; - } + public set name(value: string) { + if (this.originalCommandName !== null) + throw new Error(`originalCommandName cannot be set twice! Attempted to set the value to "${value}".`); + else this.originalCommandName = value; } } diff --git a/src/core/handler.ts b/src/core/handler.ts index cff1c41..9a7582d 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -3,7 +3,7 @@ import {loadableCommands} from "./loader"; import {Permissions, Message} from "discord.js"; import {getPrefix} from "./structures"; import {Config} from "./structures"; -import {CHANNEL_TYPE} from "./command"; +import {defaultMetadata} from "./command"; // For custom message events that want to cancel the command handler on certain conditions. const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot]; @@ -12,12 +12,6 @@ export function addInterceptRule(handler: (message: Message) => boolean) { interceptRules.push(handler); } -const defaultMetadata = { - permission: 0, - nsfw: false, - channelType: CHANNEL_TYPE.ANY -}; - // Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined. // Note: guild.available will never need to be checked because the message starts in either a DM channel or an already-available guild. client.on("message", async (message) => { @@ -51,7 +45,7 @@ client.on("message", async (message) => { // Send the arguments to the command to resolve and execute. const result = await command.execute(args, menu, { header, - args, + args: [...args], ...defaultMetadata }); @@ -83,7 +77,7 @@ client.on("message", async (message) => { // Send the arguments to the command to resolve and execute. const result = await command.execute(args, menu, { header, - args, + args: [...args], ...defaultMetadata }); diff --git a/src/core/loader.ts b/src/core/loader.ts index 8333c1b..6c6fa7f 100644 --- a/src/core/loader.ts +++ b/src/core/loader.ts @@ -31,7 +31,7 @@ export const loadableCommands = (async () => { const command = (await import(`../commands/${commandID}`)).default as unknown; if (command instanceof NamedCommand) { - command.originalCommandName = commandName; + command.name = commandName; if (commands.has(commandName)) { console.warn(