Added more type guards/properties to Command class

This commit is contained in:
WatDuhHekBro 2021-04-04 17:28:32 -05:00
parent f650faee89
commit 63441b4aca
3 changed files with 126 additions and 81 deletions

View File

@ -1,10 +1,30 @@
import {parseVars} from "./lib"; import {parseVars, requireAllCasesHandledFor} from "./lib";
import {Collection} from "discord.js"; import {Collection} from "discord.js";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js"; import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js";
import {getPrefix} from "../core/structures"; import {getPrefix} from "../core/structures";
import {SingleMessageOptions} from "./libd"; import {SingleMessageOptions} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
export enum TYPES {
SUBCOMMAND,
USER,
NUMBER,
ANY,
NONE
}
// Callbacks don't work with discriminated unions:
// - https://github.com/microsoft/TypeScript/issues/41759
// - https://github.com/microsoft/TypeScript/issues/35769
// Therefore, there won't by any type narrowing on channel or guild of CommandMenu until this is fixed.
// Otherwise, you'd have to define channelType for every single subcommand, which would get very tedious.
// Just use type assertions when you specify a channel type.
export enum CHANNEL_TYPE {
ANY,
GUILD,
DM
}
interface CommandMenu { interface CommandMenu {
args: any[]; args: any[];
client: Client; client: Client;
@ -12,97 +32,101 @@ interface CommandMenu {
channel: TextChannel | DMChannel | NewsChannel; channel: TextChannel | DMChannel | NewsChannel;
guild: Guild | null; guild: Guild | null;
author: User; author: User;
// According to the documentation, a message can be part of a guild while also not having a
// member object for the author. This will happen if the author of a message left the guild.
member: GuildMember | null; member: GuildMember | null;
} }
interface CommandOptions { interface CommandOptionsBase {
description?: string; description?: string;
endpoint?: boolean; endpoint?: boolean;
usage?: string; usage?: string;
permission?: number; permission?: number;
aliases?: string[]; nsfw?: boolean;
channelType?: CHANNEL_TYPE;
run?: (($: CommandMenu) => Promise<any>) | string; run?: (($: CommandMenu) => Promise<any>) | string;
subcommands?: {[key: string]: Command}; }
interface CommandOptionsEndpoint {
endpoint: true;
}
// Prevents subcommands from being added by compile-time.
interface CommandOptionsNonEndpoint {
endpoint?: false;
subcommands?: {[key: string]: NamedCommand};
user?: Command; user?: Command;
number?: Command; number?: Command;
any?: Command; any?: Command;
} }
enum TYPES { type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint);
SUBCOMMAND, type NamedCommandOptions = CommandOptions & {aliases?: string[]};
USER,
NUMBER,
ANY,
NONE
}
export default class Command { // RegEx patterns used for identifying/extracting each type from a string argument.
const patterns = {
//
};
export class Command {
public readonly description: string; public readonly description: string;
public readonly endpoint: boolean; public readonly endpoint: boolean;
public readonly usage: string; public readonly usage: string;
public readonly permission: number; // -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on. public readonly permission: number; // -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on.
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 readonly nsfw: boolean;
public originalCommandName: string | null; // If the command is an alias, what's the original name? public readonly channelType: CHANNEL_TYPE;
public run: (($: CommandMenu) => Promise<any>) | string; protected run: (($: CommandMenu) => Promise<any>) | string;
public readonly subcommands: Collection<string, Command>; // This is the final data structure you'll actually use to work with the commands the aliases point to. protected readonly subcommands: Collection<string, Command>; // This is the final data structure you'll actually use to work with the commands the aliases point to.
public user: Command | null; protected user: Command | null;
public number: Command | null; protected number: Command | null;
public any: Command | null; protected any: Command | null;
public static readonly CHANNEL_TYPE = CHANNEL_TYPE;
constructor(options?: CommandOptions) { constructor(options?: CommandOptions) {
this.description = options?.description || "No description."; this.description = options?.description || "No description.";
this.endpoint = options?.endpoint || false; this.endpoint = !!options?.endpoint;
this.usage = options?.usage || ""; this.usage = options?.usage ?? "";
this.permission = options?.permission ?? -1; this.permission = options?.permission ?? -1;
this.aliases = options?.aliases ?? []; this.nsfw = !!options?.nsfw;
this.originalCommandName = null; this.channelType = options?.channelType ?? CHANNEL_TYPE.ANY;
this.run = options?.run || "No action was set on this command!"; this.run = options?.run || "No action was set on this command!";
this.subcommands = new Collection(); // Populate this collection after setting subcommands. this.subcommands = new Collection(); // Populate this collection after setting subcommands.
this.user = options?.user || null; this.user = null;
this.number = options?.number || null; this.number = null;
this.any = options?.any || null; this.any = null;
if (options?.subcommands) { if (options && !options.endpoint) {
const baseSubcommands = Object.keys(options.subcommands); this.user = options?.user || null;
this.number = options?.number || null;
this.any = options?.any || null;
// Loop once to set the base subcommands. if (options?.subcommands) {
for (const name in options.subcommands) this.subcommands.set(name, options.subcommands[name]); const baseSubcommands = Object.keys(options.subcommands);
// Then loop again to make aliases point to the base subcommands and warn if something's not right. // Loop once to set the base subcommands.
// 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) this.subcommands.set(name, options.subcommands[name]);
for (const name in options.subcommands) {
const subcmd = options.subcommands[name];
subcmd.originalCommandName = name;
const aliases = subcmd.aliases;
for (const alias of aliases) { // Then loop again to make aliases point to the base subcommands and warn if something's not right.
if (baseSubcommands.includes(alias)) // This shouldn't be a problem because I'm hoping that JS stores these as references that point to the same object.
console.warn( for (const name in options.subcommands) {
`"${alias}" in subcommand "${name}" was attempted to be declared as an alias but it already exists in the base commands! (Look at the next "Loading Command" line to see which command is affected.)` const subcmd = options.subcommands[name];
); subcmd.originalCommandName = name;
else if (this.subcommands.has(alias)) const aliases = subcmd.aliases;
console.warn(
`Duplicate alias "${alias}" at subcommand "${name}"! (Look at the next "Loading Command" line to see which command is affected.)` for (const alias of aliases) {
); if (baseSubcommands.includes(alias))
else this.subcommands.set(alias, subcmd); console.warn(
`"${alias}" in subcommand "${name}" was attempted to be declared as an alias but it already exists in the base commands! (Look at the next "Loading Command" line to see which command is affected.)`
);
else if (this.subcommands.has(alias))
console.warn(
`Duplicate alias "${alias}" at subcommand "${name}"! (Look at the next "Loading Command" line to see which command is affected.)`
);
else this.subcommands.set(alias, subcmd);
}
} }
} }
} }
if (this.user && this.user.aliases.length > 0)
console.warn(
`There are aliases defined for a "user"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
if (this.number && this.number.aliases.length > 0)
console.warn(
`There are aliases defined for a "number"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
if (this.any && this.any.aliases.length > 0)
console.warn(
`There are aliases defined for an "any"-type subcommand, but those aliases won't be used. (Look at the next "Loading Command" line to see which command is affected.)`
);
} }
public execute($: CommandMenu) { public execute($: CommandMenu) {
@ -120,23 +144,18 @@ export default class Command {
} else this.run($).catch(handler.bind($)); } else this.run($).catch(handler.bind($));
} }
// Go through the arguments provided and find the right subcommand, then execute with the given arguments.
// Will return null if it successfully executes, SingleMessageOptions if there's an error (to let the user know what it is). // 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<SingleMessageOptions | null> { public async actualExecute(args: string[], tmp: any): Promise<SingleMessageOptions | null> {
// For debug info, use this.originalCommandName?
// Subcommand Recursion // // Subcommand Recursion //
let command = commands.get(header)!; let command: Command = new Command(); // = commands.get(header)!;
//resolveSubcommand() //resolveSubcommand()
const params: any[] = []; const params: any[] = [];
let isEndpoint = false; let isEndpoint = false;
let permLevel = command.permission ?? 0; let permLevel = command.permission ?? 0;
for (const param of args) { 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); const type = command.resolve(param);
command = command.get(param); command = command.get(param);
permLevel = command.permission ?? permLevel; permLevel = command.permission ?? permLevel;
@ -181,7 +200,7 @@ export default class Command {
return null; return null;
} }
public resolve(param: string): TYPES { private resolve(param: string): TYPES {
if (this.subcommands.has(param)) return TYPES.SUBCOMMAND; if (this.subcommands.has(param)) return TYPES.SUBCOMMAND;
// Any Discord ID format will automatically format to a user ID. // Any Discord ID format will automatically format to a user ID.
else if (this.user && /\d{17,19}/.test(param)) return TYPES.USER; else if (this.user && /\d{17,19}/.test(param)) return TYPES.USER;
@ -191,33 +210,35 @@ export default class Command {
else return TYPES.NONE; else return TYPES.NONE;
} }
public get(param: string): Command { private get(param: string): Command {
const type = this.resolve(param); const type = this.resolve(param);
let command: Command; let command: Command;
switch (type) { switch (type) {
case TYPES.SUBCOMMAND: case TYPES.SUBCOMMAND:
command = this.subcommands.get(param) as Command; command = this.subcommands.get(param)!;
break; break;
case TYPES.USER: case TYPES.USER:
command = this.user as Command; command = this.user!;
break; break;
case TYPES.NUMBER: case TYPES.NUMBER:
command = this.number as Command; command = this.number!;
break; break;
case TYPES.ANY: case TYPES.ANY:
command = this.any as Command; command = this.any!;
break; break;
default: case TYPES.NONE:
command = this; command = this;
break; break;
default:
requireAllCasesHandledFor(type);
} }
return command; return command;
} }
// Returns: [category, command name, command, available subcommands: [type, subcommand]] // Returns: [category, command name, command, available subcommands: [type, subcommand]]
public resolveCommandInfo(args: string[]): [string, string, Command, Collection<string, Command>] { public async resolveInfo(args: string[]): [string, string, Command, Collection<string, Command>] | null {
const commands = await loadableCommands; const commands = await loadableCommands;
let header = args.shift(); let header = args.shift();
let command = commands.get(header); let command = commands.get(header);
@ -253,6 +274,7 @@ export default class Command {
if (permLevel === -1) permLevel = command.permission; if (permLevel === -1) permLevel = command.permission;
// Switch over to doing `$help info <user>`
switch (type) { switch (type) {
case TYPES.SUBCOMMAND: case TYPES.SUBCOMMAND:
header += ` ${command.originalCommandName}`; header += ` ${command.originalCommandName}`;
@ -266,9 +288,11 @@ export default class Command {
case TYPES.ANY: case TYPES.ANY:
header += " <any>"; header += " <any>";
break; break;
default: case TYPES.NONE:
header += ` ${param}`; header += ` ${param}`;
break; break;
default:
requireAllCasesHandledFor(type);
} }
if (type === TYPES.NONE) { if (type === TYPES.NONE) {
@ -284,6 +308,17 @@ export default class Command {
} }
} }
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?
constructor(options?: NamedCommandOptions) {
super(options);
this.aliases = options?.aliases || [];
this.originalCommandName = null;
}
}
// If you use promises, use this function to display the error in chat. // If you use promises, use this function to display the error in chat.
// Case #1: await $.channel.send(""); --> Automatically caught by Command.execute(). // Case #1: await $.channel.send(""); --> Automatically caught by Command.execute().
// Case #2: $.channel.send("").catch(handler.bind($)); --> Manually caught by the user. // Case #2: $.channel.send("").catch(handler.bind($)); --> Manually caught by the user.

View File

@ -224,3 +224,13 @@ export function split<T>(array: T[], lengthOfEachSection: number): T[][] {
return sections; return sections;
} }
/**
* Utility function to require all possible cases to be handled at compile time.
*
* To use this function, place it in the "default" case of a switch statement or the "else" statement of an if-else branch.
* If all cases are handled, the variable being tested for should be of type "never", and if it isn't, that means not all cases are handled yet.
*/
export function requireAllCasesHandledFor(variable: never): never {
throw new Error(`This function should never be called but got the value: ${variable}`);
}

View File

@ -1,6 +1,6 @@
import {Collection} from "discord.js"; import {Collection} from "discord.js";
import glob from "glob"; import glob from "glob";
import Command from "./command"; import {Command, NamedCommand} 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. // 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<string, string[]>(); export const categories = new Collection<string, string[]>();
@ -30,7 +30,7 @@ export const loadableCommands = (async () => {
// 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. // 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; const command = (await import(`../commands/${commandID}`)).default as unknown;
if (command instanceof Command) { if (command instanceof NamedCommand) {
command.originalCommandName = commandName; command.originalCommandName = commandName;
if (commands.has(commandName)) { if (commands.has(commandName)) {
@ -56,7 +56,7 @@ export const loadableCommands = (async () => {
console.log(`Loading Command: ${commandID}`); console.log(`Loading Command: ${commandID}`);
} else { } else {
console.warn(`Command "${commandID}" has no default export which is a Command instance!`); console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
} }
} }
} }