2021-04-04 22:28:32 +00:00
import { parseVars , requireAllCasesHandledFor } from "./lib" ;
2020-12-15 01:44:28 +00:00
import { Collection } from "discord.js" ;
2021-04-05 00:35:10 +00:00
import { Client , Message , TextChannel , DMChannel , NewsChannel , Guild , User , GuildMember , GuildChannel } from "discord.js" ;
2020-12-15 01:44:28 +00:00
import { getPrefix } from "../core/structures" ;
2021-04-03 09:58:20 +00:00
import { SingleMessageOptions } from "./libd" ;
import { hasPermission , getPermissionLevel , getPermissionName } from "./permissions" ;
2021-04-05 00:35:10 +00:00
import { client } from "../index" ;
2020-10-15 09:23:24 +00:00
2021-04-04 22:28:32 +00:00
export enum TYPES {
SUBCOMMAND ,
USER ,
NUMBER ,
ANY ,
NONE
}
2021-04-05 00:35:10 +00:00
// RegEx patterns used for identifying/extracting each type from a string argument.
const patterns = {
channel : /^<#(\d{17,19})>$/ ,
role : /^<@&(\d{17,19})>$/ ,
emote : /^<a?:.*?:(\d{17,19})>$/ ,
message : /(?:\d{17,19}\/(\d{17,19})\/(\d{17,19})$)|(?:^(\d{17,19})-(\d{17,19})$)/ ,
user : /^<@!?(\d{17,19})>$/ ,
id : /^(\d{17,19})$/
} ;
2021-04-04 22:28:32 +00:00
// 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
}
2021-03-30 10:25:07 +00:00
interface CommandMenu {
2021-04-05 00:35:10 +00:00
readonly args : any [ ] ;
readonly client : Client ;
readonly message : Message ;
readonly channel : TextChannel | DMChannel | NewsChannel ;
readonly guild : Guild | null ;
readonly author : User ;
2021-04-04 22:28:32 +00:00
// 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.
2021-04-05 00:35:10 +00:00
readonly member : GuildMember | null ;
2021-03-30 10:25:07 +00:00
}
2021-04-04 22:28:32 +00:00
interface CommandOptionsBase {
2021-04-05 00:35:10 +00:00
readonly description? : string ;
readonly endpoint? : boolean ;
readonly usage? : string ;
readonly permission? : number ;
readonly nsfw? : boolean ;
readonly channelType? : CHANNEL_TYPE ;
readonly run ? : ( ( $ : CommandMenu ) = > Promise < any > ) | string ;
2021-04-04 22:28:32 +00:00
}
interface CommandOptionsEndpoint {
2021-04-05 00:35:10 +00:00
readonly endpoint : true ;
2021-04-04 22:28:32 +00:00
}
// Prevents subcommands from being added by compile-time.
interface CommandOptionsNonEndpoint {
2021-04-05 00:35:10 +00:00
readonly endpoint? : false ;
readonly subcommands ? : { [ key : string ] : NamedCommand } ;
readonly user? : Command ;
readonly number ? : Command ;
readonly any ? : Command ;
2020-07-25 08:15:26 +00:00
}
2021-04-04 22:28:32 +00:00
type CommandOptions = CommandOptionsBase & ( CommandOptionsEndpoint | CommandOptionsNonEndpoint ) ;
type NamedCommandOptions = CommandOptions & { aliases? : string [ ] } ;
2020-10-15 09:23:24 +00:00
2021-04-05 00:35:10 +00:00
interface ExecuteCommandMetadata {
readonly header : string ;
readonly args : string [ ] ;
permission : number ;
nsfw : boolean ;
channelType : CHANNEL_TYPE ;
}
2021-04-04 22:28:32 +00:00
export class Command {
2020-12-15 01:44:28 +00:00
public readonly description : string ;
public readonly endpoint : boolean ;
public readonly usage : string ;
2021-03-30 10:54:52 +00:00
public readonly permission : number ; // -1 (default) indicates to inherit, 0 is the lowest rank, 1 is second lowest rank, and so on.
2021-04-05 00:35:10 +00:00
public readonly nsfw : boolean | null ; // null (default) indicates to inherit
public readonly channelType : CHANNEL_TYPE | null ; // null (default) indicates to inherit
2021-04-04 22:28:32 +00:00
protected run : ( ( $ : CommandMenu ) = > Promise < any > ) | string ;
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.
protected user : Command | null ;
protected number : Command | null ;
protected any : Command | null ;
public static readonly CHANNEL_TYPE = CHANNEL_TYPE ;
2020-12-15 01:44:28 +00:00
constructor ( options? : CommandOptions ) {
this . description = options ? . description || "No description." ;
2021-04-04 22:28:32 +00:00
this . endpoint = ! ! options ? . endpoint ;
this . usage = options ? . usage ? ? "" ;
2021-03-30 10:54:52 +00:00
this . permission = options ? . permission ? ? - 1 ;
2021-04-05 00:35:10 +00:00
this . nsfw = options ? . nsfw ? ? null ;
this . channelType = options ? . channelType ? ? null ;
2020-12-15 01:44:28 +00:00
this . run = options ? . run || "No action was set on this command!" ;
this . subcommands = new Collection ( ) ; // Populate this collection after setting subcommands.
2021-04-04 22:28:32 +00:00
this . user = null ;
this . number = null ;
this . any = null ;
if ( options && ! options . endpoint ) {
this . user = options ? . user || null ;
this . number = options ? . number || null ;
this . any = options ? . any || null ;
if ( options ? . subcommands ) {
const baseSubcommands = Object . keys ( options . subcommands ) ;
// Loop once to set the base subcommands.
for ( const name in options . subcommands ) this . subcommands . set ( name , options . subcommands [ name ] ) ;
// Then loop again to make aliases point to the base subcommands and warn if something's not right.
// 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 ;
const aliases = subcmd . aliases ;
for ( const alias of aliases ) {
if ( baseSubcommands . includes ( alias ) )
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 ) ;
}
2020-12-15 01:44:28 +00:00
}
}
}
2020-10-15 09:23:24 +00:00
}
2021-04-04 22:28:32 +00:00
// Go through the arguments provided and find the right subcommand, then execute with the given arguments.
2021-04-03 09:58:20 +00:00
// Will return null if it successfully executes, SingleMessageOptions if there's an error (to let the user know what it is).
2021-04-05 00:35:10 +00:00
public async execute (
args : string [ ] ,
menu : CommandMenu ,
metadata : ExecuteCommandMetadata
) : Promise < SingleMessageOptions | null > {
const param = args . shift ( ) ;
// If there are no arguments left, execute the current command. Otherwise, continue on.
if ( ! param ) {
// 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?
if (
metadata . channelType === CHANNEL_TYPE . GUILD &&
( ! ( menu . channel instanceof GuildChannel ) || menu . guild === null )
) {
return { content : "This command must be executed in a server." } ;
} else if (
metadata . channelType === CHANNEL_TYPE . DM &&
( menu . channel . type !== "dm" || menu . guild !== null )
) {
return { content : "This command must be executed as a direct message." } ;
}
2021-04-03 09:58:20 +00:00
2021-04-05 00:35:10 +00:00
// 2. Is this an NSFW command where the channel prevents such use? (DM channels bypass this requirement.)
if ( metadata . nsfw && menu . channel . type !== "dm" && ! menu . channel . nsfw ) {
return { content : "This command must be executed in either an NSFW channel or as a direct message." } ;
}
2021-04-03 09:58:20 +00:00
2021-04-05 00:35:10 +00:00
// 3. Does the user have permission to execute the command?
if ( ! hasPermission ( menu . author , menu . member , metadata . permission ) ) {
const userPermLevel = getPermissionLevel ( menu . author , menu . member ) ;
return {
content : ` You don't have access to this command! Your permission level is \` ${ getPermissionName (
userPermLevel
) } \ ` ( ${ userPermLevel } ), but this command requires a permission level of \` ${ getPermissionName (
metadata . permission
) } \ ` ( ${ metadata . permission } ). `
} ;
}
// Then capture any potential errors.
try {
if ( typeof this . run === "string" ) {
await menu . channel . send (
parseVars (
this . run ,
{
author : menu.author.toString ( ) ,
prefix : getPrefix ( menu . guild )
} ,
"???"
)
) ;
} else {
await this . run ( menu ) ;
2021-04-03 09:58:20 +00:00
}
2021-04-05 00:35:10 +00:00
return null ;
} catch ( error ) {
const errorMessage = error . stack ? ? error ;
console . error ( ` Command Error: ${ metadata . header } ( ${ metadata . args } ) \ n ${ errorMessage } ` ) ;
return {
content : ` There was an error while trying to execute that command! \` \` \` ${ errorMessage } \` \` \` `
} ;
}
2021-04-03 09:58:20 +00:00
}
2021-04-05 00:35:10 +00:00
// 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!" } ;
2021-04-03 09:58:20 +00:00
2021-04-05 00:35:10 +00:00
// If the current command's permission level isn't -1 (inherit), then set the permission metadata equal to that.
if ( this . permission !== - 1 ) metadata . permission = this . permission ;
2021-04-03 09:58:20 +00:00
2021-04-05 00:35:10 +00:00
// If the current command has an NSFW setting specified, set it.
if ( this . nsfw !== null ) metadata . nsfw = this . nsfw ;
2021-04-03 09:58:20 +00:00
2021-04-05 00:35:10 +00:00
// If the current command doesn't inherit its channel type, set it.
if ( this . channelType !== null ) metadata . channelType = this . channelType ;
2020-10-15 09:23:24 +00:00
2021-04-05 00:35:10 +00:00
// Resolve the value of the current command's argument (adding it to the resolved args),
// then pass the thread of execution to whichever subcommand is valid (if any).
if ( this . subcommands . has ( param ) ) {
return this . subcommands . get ( param ) ! . execute ( args , menu , metadata ) ;
} else if ( this . user && patterns . user . test ( param ) ) {
const id = patterns . user . exec ( param ) ! [ 1 ] ;
2020-12-15 01:44:28 +00:00
2021-04-05 00:35:10 +00:00
try {
menu . args . push ( await client . users . fetch ( id ) ) ;
return this . user . execute ( args , menu , metadata ) ;
} catch {
return {
content : ` No user found by the ID \` ${ id } \` ! `
} ;
}
} else if ( this . number && ! Number . isNaN ( Number ( param ) ) && param !== "Infinity" && param !== "-Infinity" ) {
menu . args . push ( Number ( param ) ) ;
return this . number . execute ( args , menu , metadata ) ;
} else if ( this . any ) {
menu . args . push ( param ) ;
return this . any . execute ( args , menu , metadata ) ;
} else {
// Continue adding on the rest of the arguments if there's no valid subcommand.
menu . args . push ( param ) ;
return this . execute ( args , menu , metadata ) ;
2020-12-15 01:44:28 +00:00
}
2021-04-05 00:35:10 +00:00
// 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.
}
}
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 ;
2020-12-15 01:44:28 +00:00
}
2020-07-25 08:15:26 +00:00
2021-04-03 09:58:20 +00:00
// Returns: [category, command name, command, available subcommands: [type, subcommand]]
2021-04-04 22:28:32 +00:00
public async resolveInfo ( args : string [ ] ) : [ string , string , Command , Collection < string , Command > ] | null {
2021-04-05 00:35:10 +00:00
// For debug info, use this.originalCommandName? (if it exists?)
2021-04-03 09:58:20 +00:00
const commands = await loadableCommands ;
let header = args . shift ( ) ;
let command = commands . get ( header ) ;
2020-12-15 01:44:28 +00:00
2021-04-03 09:58:20 +00:00
if ( ! command || header === "test" ) {
$ . channel . send ( ` No command found by the name \` ${ header } \` ! ` ) ;
return ;
}
2020-12-15 01:44:28 +00:00
2021-04-03 09:58:20 +00:00
if ( command . originalCommandName ) header = command . originalCommandName ;
else console . warn ( ` originalCommandName isn't defined for ${ header } ?! ` ) ;
2020-12-15 01:44:28 +00:00
2021-04-03 09:58:20 +00:00
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 ) ;
2020-12-15 01:44:28 +00:00
}
2021-01-26 09:52:39 +00:00
}
2020-10-15 09:23:24 +00:00
2021-04-03 09:58:20 +00:00
for ( const param of args ) {
const type = command . resolve ( param ) ;
command = command . get ( param ) ;
permLevel = command . permission ? ? permLevel ;
2020-10-15 09:23:24 +00:00
2021-04-03 09:58:20 +00:00
if ( permLevel === - 1 ) permLevel = command . permission ;
2021-01-26 09:52:39 +00:00
2021-04-04 22:28:32 +00:00
// Switch over to doing `$help info <user>`
2021-04-03 09:58:20 +00:00
switch ( type ) {
case TYPES . SUBCOMMAND :
header += ` ${ command . originalCommandName } ` ;
break ;
case TYPES . USER :
header += " <user>" ;
break ;
case TYPES . NUMBER :
header += " <number>" ;
break ;
case TYPES . ANY :
header += " <any>" ;
break ;
2021-04-04 22:28:32 +00:00
case TYPES . NONE :
2021-04-03 09:58:20 +00:00
header += ` ${ param } ` ;
break ;
2021-04-04 22:28:32 +00:00
default :
requireAllCasesHandledFor ( type ) ;
2021-01-26 09:52:39 +00:00
}
2021-04-03 09:58:20 +00:00
if ( type === TYPES . NONE ) {
invalid = true ;
break ;
}
}
if ( invalid ) {
$ . channel . send ( ` No command found by the name \` ${ header } \` ! ` ) ;
return ;
}
}
2020-07-26 01:14:11 +00:00
}