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,52 +32,70 @@ 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 = null; | ||||||
|  |         this.number = null; | ||||||
|  |         this.any = null; | ||||||
|  | 
 | ||||||
|  |         if (options && !options.endpoint) { | ||||||
|             this.user = options?.user || null; |             this.user = options?.user || null; | ||||||
|             this.number = options?.number || null; |             this.number = options?.number || null; | ||||||
|             this.any = options?.any || null; |             this.any = options?.any || null; | ||||||
|  | @ -88,21 +126,7 @@ export default class Command { | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 |         } | ||||||
|         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…
	
	Add table
		Add a link
		
	
		Reference in a new issue