2021-04-03 09:58:20 +00:00
import { Collection } from "discord.js" ;
import glob from "glob" ;
2021-04-10 07:38:46 +00:00
import path from "path" ;
2021-04-10 04:06:16 +00:00
import { NamedCommand , CommandInfo } from "./command" ;
2021-04-10 07:38:46 +00:00
import { loadableCommands , categoryTransformer } from "./interface" ;
2021-04-03 09:58:20 +00:00
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
2021-04-10 04:06:16 +00:00
const categories = new Collection < string , string [ ] > ( ) ;
2021-04-03 09:58:20 +00:00
2021-04-10 07:38:46 +00:00
// This will go through all the .js files and import them. Because the import has to be .js (and cannot be .ts), there's no need for a custom filename checker in the launch settings.
// This will avoid the problems of being a node module by requiring absolute imports, which the user will pass in as a launch parameter.
export async function loadCommands ( commandsDir : string ) : Promise < Collection < string , NamedCommand > > {
// Add a trailing separator so that the reduced filename list will reliably cut off the starting part.
// "C:/some/path/to/commands" --> "C:/some/path/to/commands/" (and likewise for \)
commandsDir = path . normalize ( commandsDir ) ;
if ( ! commandsDir . endsWith ( path . sep ) ) commandsDir += path . sep ;
2021-04-10 04:06:16 +00:00
const commands = new Collection < string , NamedCommand > ( ) ;
2021-04-03 09:58:20 +00:00
// Include all .ts files recursively in "src/commands/".
2021-04-10 07:38:46 +00:00
const files = await globP ( path . join ( commandsDir , "**" , "*.js" ) ) ; // This stage filters out source maps (.js.map).
// Because glob will use / regardless of platform, the following regex pattern can rely on / being the case.
const filesClean = files . map ( ( filename ) = > filename . substring ( commandsDir . length ) ) ;
// Extract the usable parts from commands directory if:
2021-04-03 09:58:20 +00:00
// - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
// - Any leading directory isn't "modules"
2021-04-10 07:38:46 +00:00
// - The filename doesn't end in .test.js (for jest testing)
// - The filename cannot be the hardcoded top-level "template.js", reserved for generating templates
const pattern = /^(?!template\.js)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.js$/ ;
2021-04-03 09:58:20 +00:00
const lists : { [ category : string ] : string [ ] } = { } ;
2021-04-10 07:38:46 +00:00
for ( let i = 0 ; i < files . length ; i ++ ) {
const match = pattern . exec ( filesClean [ i ] ) ;
if ( ! match ) continue ;
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"
// This try-catch block MUST be here or Node.js' dynamic require() will silently fail.
try {
2021-04-03 09:58:20 +00:00
// 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.
2021-04-10 07:38:46 +00:00
const command = ( await import ( files [ i ] ) ) . default as unknown ;
2021-04-03 09:58:20 +00:00
2021-04-04 22:28:32 +00:00
if ( command instanceof NamedCommand ) {
2021-04-10 16:30:27 +00:00
const isNameOverridden = command . isNameSet ( ) ;
if ( ! isNameOverridden ) command . name = commandName ;
const header = command . name ;
2021-04-03 09:58:20 +00:00
2021-04-10 16:30:27 +00:00
if ( commands . has ( header ) ) {
2021-04-03 09:58:20 +00:00
console . warn (
2021-04-10 16:30:27 +00:00
` Command " ${ header } " already exists! Make sure to make each command uniquely identifiable across categories! `
2021-04-03 09:58:20 +00:00
) ;
} else {
2021-04-10 16:30:27 +00:00
commands . set ( header , command ) ;
2021-04-03 09:58:20 +00:00
}
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 ] = [ ] ;
2021-04-10 16:30:27 +00:00
lists [ category ] . push ( header ) ;
2021-04-03 09:58:20 +00:00
2021-04-10 16:30:27 +00:00
if ( isNameOverridden ) console . log ( ` Loaded Command: " ${ commandID } " as " ${ header } " ` ) ;
else console . log ( ` Loaded Command: ${ commandID } ` ) ;
2021-04-03 09:58:20 +00:00
} else {
2021-04-04 22:28:32 +00:00
console . warn ( ` Command " ${ commandID } " has no default export which is a NamedCommand instance! ` ) ;
2021-04-03 09:58:20 +00:00
}
2021-04-10 07:38:46 +00:00
} catch ( error ) {
2021-04-10 12:51:32 +00:00
console . error ( error ) ;
2021-04-03 09:58:20 +00:00
}
}
for ( const category in lists ) {
categories . set ( category , lists [ category ] ) ;
}
return commands ;
2021-04-10 07:38:46 +00:00
}
2021-04-03 09:58:20 +00:00
function globP ( path : string ) {
return new Promise < string [ ] > ( ( resolve , reject ) = > {
glob ( path , ( error , files ) = > {
if ( error ) {
reject ( error ) ;
} else {
resolve ( files ) ;
}
} ) ;
} ) ;
}
2021-04-10 04:06:16 +00:00
/ * *
* Returns a list of categories and their associated commands .
* /
export async function getCommandList ( ) : Promise < Collection < string , NamedCommand [ ] > > {
const list = new Collection < string , NamedCommand [ ] > ( ) ;
const commands = await loadableCommands ;
for ( const [ category , headers ] of categories ) {
const commandList : NamedCommand [ ] = [ ] ;
for ( const header of headers . filter ( ( header ) = > header !== "test" ) ) commandList . push ( commands . get ( header ) ! ) ;
// Ignore empty categories like "miscellaneous" (if it's empty).
2021-04-10 07:38:46 +00:00
if ( commandList . length > 0 ) list . set ( categoryTransformer ( category ) , commandList ) ;
2021-04-10 04:06:16 +00:00
}
return list ;
}
/ * *
* Resolves a command based on the arguments given .
* - Returns a string if there was an error .
* - Returns a CommandInfo / category tuple if it was a success .
* /
export async function getCommandInfo ( args : string [ ] ) : Promise < [ CommandInfo , string ] | string > {
// Use getCommandList() instead if you're just getting the list of all commands.
if ( args . length === 0 ) return "No arguments were provided!" ;
// Setup the root command
const commands = await loadableCommands ;
let header = args . shift ( ) ! ;
const command = commands . get ( header ) ;
if ( ! command || header === "test" ) return ` No command found by the name \` ${ header } \` . ` ;
if ( ! ( command instanceof NamedCommand ) ) return "Command is not a proper instance of NamedCommand." ;
// If it's an alias, set the header to the original command name.
if ( command . name ) header = command . name ;
// Search categories
let category = "Unknown" ;
for ( const [ referenceCategory , headers ] of categories ) {
if ( headers . includes ( header ) ) {
2021-04-10 07:38:46 +00:00
category = categoryTransformer ( referenceCategory ) ;
2021-04-10 04:06:16 +00:00
break ;
}
}
// Gather info
2021-04-10 16:30:27 +00:00
const result = command . resolveInfo ( args , header ) ;
2021-04-10 04:06:16 +00:00
if ( result . type === "error" ) return result . message ;
else return [ result , category ] ;
}