mirror of
https://github.com/keanuplayz/TravBot-v3.git
synced 2024-08-15 02:33:12 +00:00
Added more type guards/properties to Command class
This commit is contained in:
parent
f650faee89
commit
63441b4aca
3 changed files with 126 additions and 81 deletions
|
@ -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.
|
||||||
|
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
|
@ -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!`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue