import { Collection, Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, GuildChannel, Channel } from "discord.js"; import {getChannelByID, getGuildByID, getMessageByID, getUserByID, SingleMessageOptions, SendFunction} from "./libd"; import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; import {getPrefix} from "./interface"; import {parseVars, requireAllCasesHandledFor} from "../lib"; /** * ===[ Command Types ]=== * SUBCOMMAND - Any specifically-defined keywords / string literals. * CHANNEL - <#...> * ROLE - <@&...> * EMOTE - <::ID> (The previous two values, animated and emote name respectively, do not matter at all for finding the emote.) * MESSAGE - Available by using the built-in "Copy Message Link" or "Copy ID" buttons. https://discordapp.com/channels/// or - (automatically searches all guilds for the channel ID). * USER - <@...> and <@!...> * ID - Any number with 17-19 digits. Only used as a redirect to another subcommand type. * NUMBER - Any valid number via the Number() function, except for NaN and Infinity (because those can really mess with the program). * ANY - Generic argument case. * NONE - No subcommands exist. */ // RegEx patterns used for identifying/extracting each type from a string argument. // The reason why \d{17,} is used is because the max safe number for JS numbers is 16 characters when stringified (decimal). Beyond that are IDs. const patterns = { channel: /^<#(\d{17,})>$/, role: /^<@&(\d{17,})>$/, emote: /^$/, // The message type won't include #. At that point, you may as well just use a search usernames function. Even then, tags would only be taken into account to differentiate different users with identical usernames. messageLink: /^https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/channels\/(?:\d{17,}|@me)\/(\d{17,})\/(\d{17,})$/, messagePair: /^(\d{17,})-(\d{17,})$/, user: /^<@!?(\d{17,})>$/, id: /^(\d{17,})$/ }; // Maybe add a guild redirect... somehow? type ID = "channel" | "role" | "emote" | "message" | "user" | "guild"; // 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 { readonly args: any[]; readonly client: Client; readonly message: Message; readonly channel: TextChannel | DMChannel | NewsChannel; readonly guild: Guild | null; readonly 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. readonly member: GuildMember | null; readonly send: SendFunction; } interface CommandOptionsBase { readonly description?: string; readonly endpoint?: boolean; readonly usage?: string; readonly permission?: number; readonly nsfw?: boolean; readonly channelType?: CHANNEL_TYPE; } interface CommandOptionsEndpoint { readonly endpoint: true; readonly rest?: RestCommand; readonly run?: (($: CommandMenu) => Promise) | string; } // Prevents subcommands from being added by compile-time. // Also, contrary to what you might think, channel pings do still work in DM channels. // Role pings, maybe not, but it's not a big deal. interface CommandOptionsNonEndpoint { readonly endpoint?: false; readonly subcommands?: {[key: string]: NamedCommand}; readonly channel?: Command; readonly role?: Command; readonly emote?: Command; readonly message?: Command; readonly user?: Command; readonly guild?: Command; // Only available if an ID is set to reroute to it. readonly id?: ID; readonly number?: Command; readonly any?: Command; readonly rest?: undefined; // Redeclare it here as undefined to prevent its use otherwise. readonly run?: (($: CommandMenu) => Promise) | string; } type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint); type NamedCommandOptions = CommandOptions & {aliases?: string[]; nameOverride?: string}; type RestCommandOptions = CommandOptionsBase & { run?: (($: CommandMenu & {readonly combined: string}) => Promise) | string; }; interface ExecuteCommandMetadata { readonly header: string; readonly args: string[]; permission: number; nsfw: boolean; channelType: CHANNEL_TYPE; symbolicArgs: string[]; // i.e. instead of <#...> } export interface CommandInfo { readonly type: "info"; readonly command: BaseCommand; readonly subcommandInfo: Collection; readonly keyedSubcommandInfo: Collection; readonly hasRestCommand: boolean; readonly permission: number; readonly nsfw: boolean; readonly channelType: CHANNEL_TYPE; readonly args: string[]; readonly header: string; } interface CommandInfoError { readonly type: "error"; readonly message: string; } interface CommandInfoMetadata { permission: number; nsfw: boolean; channelType: CHANNEL_TYPE; args: string[]; usage: string; readonly originalArgs: string[]; readonly header: string; } // An isolated command of just the metadata. abstract class BaseCommand { public readonly description: 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 nsfw: boolean | null; // null (default) indicates to inherit public readonly channelType: CHANNEL_TYPE | null; // null (default) indicates to inherit constructor(options?: CommandOptionsBase) { this.description = options?.description || "No description."; this.usage = options?.usage ?? ""; this.permission = options?.permission ?? -1; this.nsfw = options?.nsfw ?? null; this.channelType = options?.channelType ?? null; } } // Each Command instance represents a block that links other Command instances under it. export class Command extends BaseCommand { public readonly endpoint: boolean; // The execute and subcommand properties are restricted to the class because subcommand recursion could easily break when manually handled. // The class will handle checking for null fields. private run: (($: CommandMenu) => Promise) | string; private readonly subcommands: Collection; // This is the final data structure you'll actually use to work with the commands the aliases point to. private channel: Command | null; private role: Command | null; private emote: Command | null; private message: Command | null; private user: Command | null; private guild: Command | null; private id: Command | null; private idType: ID | null; private number: Command | null; private any: Command | null; private rest: RestCommand | null; constructor(options?: CommandOptions) { super(options); this.endpoint = !!options?.endpoint; this.run = options?.run || "No action was set on this command!"; this.subcommands = new Collection(); // Populate this collection after setting subcommands. this.channel = null; this.role = null; this.emote = null; this.message = null; this.user = null; this.guild = null; this.id = null; this.idType = null; this.number = null; this.any = null; this.rest = null; if (options && !options.endpoint) { if (options.channel) this.channel = options.channel; if (options.role) this.role = options.role; if (options.emote) this.emote = options.emote; if (options.message) this.message = options.message; if (options.user) this.user = options.user; if (options.guild) this.guild = options.guild; if (options.number) this.number = options.number; if (options.any) this.any = options.any; if (options.id) this.idType = options.id; switch (options.id) { case "channel": this.id = this.channel; break; case "role": this.id = this.role; break; case "emote": this.id = this.emote; break; case "message": this.id = this.message; break; case "user": this.id = this.user; break; case "guild": this.id = this.guild; break; case undefined: break; default: requireAllCasesHandledFor(options.id); } 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.name = 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); } } } } else if (options && options.endpoint) { if (options.rest) this.rest = options.rest; } } // 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). // // Calls the resulting subcommand's execute method in order to make more modular code, basically pushing the chain of execution to the subcommand. // For example, a numeric subcommand would accept args of [4] then execute on it. // // Because each Command instance is isolated from others, it becomes practically impossible to predict the total amount of subcommands when isolating the code to handle each individual layer of recursion. // Therefore, if a Command is declared as a rest type, any typed args that come at the end must be handled manually. public async execute( args: string[], menu: CommandMenu, metadata: ExecuteCommandMetadata ): Promise { // Update inherited properties if the current command specifies a property. // In case there are no initial arguments, these should go first so that it can register. if (this.permission !== -1) metadata.permission = this.permission; if (this.nsfw !== null) metadata.nsfw = this.nsfw; if (this.channelType !== null) metadata.channelType = this.channelType; // Take off the leftmost argument from the list. const param = args.shift(); // If there are no arguments left, execute the current command. Otherwise, continue on. if (param === undefined) { const error = canExecute(menu, metadata); if (error) return error; if (typeof this.run === "string") { // Although I *could* add an option in the launcher to attach arbitrary variables to this var string... // I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string. await menu.send( parseVars( this.run, { author: menu.author.toString(), prefix: getPrefix(menu.guild), command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}` }, "???" ) ); } else { // Then capture any potential errors. try { await this.run(menu); } catch (error) { const errorMessage = error.stack ?? error; console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); return { content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\`` }; } } return null; } // If the current command is an endpoint but there are still some arguments left, don't continue unless there's a RestCommand. if (this.endpoint) { if (this.rest) { args.unshift(param); return this.rest.execute(args.join(" "), menu, metadata); } else { return {content: "Too many arguments!"}; } } // 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). const isMessageLink = patterns.messageLink.test(param); const isMessagePair = patterns.messagePair.test(param); if (this.subcommands.has(param)) { metadata.symbolicArgs.push(param); return this.subcommands.get(param)!.execute(args, menu, metadata); } else if (this.channel && patterns.channel.test(param)) { const id = patterns.channel.exec(param)![1]; const channel = await getChannelByID(id); if (channel instanceof Channel) { if (channel instanceof TextChannel || channel instanceof DMChannel) { metadata.symbolicArgs.push(""); menu.args.push(channel); return this.channel.execute(args, menu, metadata); } else { return { content: `\`${id}\` is not a valid text channel!` }; } } else { return channel; } } else if (this.role && patterns.role.test(param)) { const id = patterns.role.exec(param)![1]; if (!menu.guild) { return { content: "You can't use role parameters in DM channels!" }; } const role = menu.guild.roles.cache.get(id); if (role) { metadata.symbolicArgs.push(""); menu.args.push(role); return this.role.execute(args, menu, metadata); } else { return { content: `\`${id}\` is not a valid role in this server!` }; } } else if (this.emote && patterns.emote.test(param)) { const id = patterns.emote.exec(param)![1]; const emote = menu.client.emojis.cache.get(id); if (emote) { metadata.symbolicArgs.push(""); menu.args.push(emote); return this.emote.execute(args, menu, metadata); } else { return { content: `\`${id}\` isn't a valid emote!` }; } } else if (this.message && (isMessageLink || isMessagePair)) { let channelID = ""; let messageID = ""; if (isMessageLink) { const result = patterns.messageLink.exec(param)!; channelID = result[1]; messageID = result[2]; } else if (isMessagePair) { const result = patterns.messagePair.exec(param)!; channelID = result[1]; messageID = result[2]; } const message = await getMessageByID(channelID, messageID); if (message instanceof Message) { metadata.symbolicArgs.push(""); menu.args.push(message); return this.message.execute(args, menu, metadata); } else { return message; } } else if (this.user && patterns.user.test(param)) { const id = patterns.user.exec(param)![1]; const user = await getUserByID(id); if (user instanceof User) { metadata.symbolicArgs.push(""); menu.args.push(user); return this.user.execute(args, menu, metadata); } else { return user; } } else if (this.id && this.idType && patterns.id.test(param)) { metadata.symbolicArgs.push(""); const id = patterns.id.exec(param)![1]; // Probably modularize the findXByY code in general in libd. // Because this part is pretty much a whole bunch of copy pastes. switch (this.idType) { case "channel": const channel = await getChannelByID(id); if (channel instanceof Channel) { if (channel instanceof TextChannel || channel instanceof DMChannel) { metadata.symbolicArgs.push(""); menu.args.push(channel); return this.id.execute(args, menu, metadata); } else { return { content: `\`${id}\` is not a valid text channel!` }; } } else { return channel; } case "role": if (!menu.guild) { return { content: "You can't use role parameters in DM channels!" }; } const role = menu.guild.roles.cache.get(id); if (role) { menu.args.push(role); return this.id.execute(args, menu, metadata); } else { return { content: `\`${id}\` isn't a valid role in this server!` }; } case "emote": const emote = menu.client.emojis.cache.get(id); if (emote) { menu.args.push(emote); return this.id.execute(args, menu, metadata); } else { return { content: `\`${id}\` isn't a valid emote!` }; } case "message": const message = await getMessageByID(menu.channel, id); if (message instanceof Message) { menu.args.push(message); return this.id.execute(args, menu, metadata); } else { return message; } case "user": const user = await getUserByID(id); if (user instanceof User) { menu.args.push(user); return this.id.execute(args, menu, metadata); } else { return user; } case "guild": const guild = getGuildByID(id); if (guild instanceof Guild) { menu.args.push(guild); return this.id.execute(args, menu, metadata); } else { return guild; } default: requireAllCasesHandledFor(this.idType); } } else if (this.number && !Number.isNaN(Number(param)) && param !== "Infinity" && param !== "-Infinity") { metadata.symbolicArgs.push(""); menu.args.push(Number(param)); return this.number.execute(args, menu, metadata); } else if (this.any) { metadata.symbolicArgs.push(""); 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); } // 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. } // What this does is resolve the resulting subcommand as well as the inherited properties and the available subcommands. public resolveInfo(args: string[], header: string): CommandInfo | CommandInfoError { return this.resolveInfoInternal(args, { permission: 0, nsfw: false, channelType: CHANNEL_TYPE.ANY, header, args: [], usage: "", originalArgs: [...args] }); } private resolveInfoInternal(args: string[], metadata: CommandInfoMetadata): CommandInfo | CommandInfoError { // Update inherited properties if the current command specifies a property. // In case there are no initial arguments, these should go first so that it can register. if (this.permission !== -1) metadata.permission = this.permission; if (this.nsfw !== null) metadata.nsfw = this.nsfw; if (this.channelType !== null) metadata.channelType = this.channelType; if (this.usage !== "") metadata.usage = this.usage; // Take off the leftmost argument from the list. const param = args.shift(); // If there are no arguments left, return the data or an error message. if (param === undefined) { const keyedSubcommandInfo = new Collection(); const subcommandInfo = new Collection(); // Get all the subcommands of the current command but without aliases. for (const [tag, command] of this.subcommands.entries()) { // Don't capture duplicates generated from aliases. if (tag === command.name) { keyedSubcommandInfo.set(tag, command); } } // Then get all the generic subcommands. if (this.channel) subcommandInfo.set("", this.channel); if (this.role) subcommandInfo.set("", this.role); if (this.emote) subcommandInfo.set("", this.emote); if (this.message) subcommandInfo.set("", this.message); if (this.user) subcommandInfo.set("", this.user); if (this.id) subcommandInfo.set(`>`, this.id); if (this.number) subcommandInfo.set("", this.number); if (this.any) subcommandInfo.set("", this.any); return { type: "info", command: this, keyedSubcommandInfo, subcommandInfo, hasRestCommand: !!this.rest, ...metadata }; } const invalidSubcommandGenerator: () => CommandInfoError = () => ({ type: "error", message: `No subcommand found by the argument list: \`${metadata.originalArgs.join(" ")}\`` }); // Then test if anything fits any hardcoded values, otherwise check if it's a valid keyed subcommand. if (param === "") { if (this.channel) { metadata.args.push(""); return this.channel.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.role) { metadata.args.push(""); return this.role.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.emote) { metadata.args.push(""); return this.emote.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.message) { metadata.args.push(""); return this.message.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.user) { metadata.args.push(""); return this.user.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.id) { metadata.args.push(`>`); return this.id.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.number) { metadata.args.push(""); return this.number.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "") { if (this.any) { metadata.args.push(""); return this.any.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } else if (param === "<...>") { if (this.rest) { metadata.args.push("<...>"); return this.rest.resolveInfoFinale(metadata); } else { return invalidSubcommandGenerator(); } } else if (this.subcommands?.has(param)) { metadata.args.push(param); return this.subcommands.get(param)!.resolveInfoInternal(args, metadata); } else { return invalidSubcommandGenerator(); } } } 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. private originalCommandName: string | null; // If the command is an alias, what's the original name? constructor(options?: NamedCommandOptions) { super(options); this.aliases = options?.aliases || []; // The name override exists in case a user wants to bypass filename restrictions. this.originalCommandName = options?.nameOverride ?? null; } public get name(): string { if (this.originalCommandName === null) throw new Error("originalCommandName must be set before accessing it!"); else return this.originalCommandName; } public set name(value: string) { if (this.originalCommandName !== null) throw new Error(`originalCommandName cannot be set twice! Attempted to set the value to "${value}".`); else this.originalCommandName = value; } public isNameSet(): boolean { return this.originalCommandName !== null; } } // RestCommand is a declarative version of the common "any: args.join(' ')" pattern, basically the Command version of a rest parameter. // This way, you avoid having extra subcommands when using this pattern. // I'm probably not going to add a transformer function (a callback to automatically handle stuff like searching for usernames). // I don't think the effort to figure this part out via generics or something is worth it. export class RestCommand extends BaseCommand { private run: (($: CommandMenu & {readonly combined: string}) => Promise) | string; constructor(options?: RestCommandOptions) { super(options); this.run = options?.run || "No action was set on this command!"; } public async execute( combined: string, menu: CommandMenu, metadata: ExecuteCommandMetadata ): Promise { // Update inherited properties if the current command specifies a property. // In case there are no initial arguments, these should go first so that it can register. if (this.permission !== -1) metadata.permission = this.permission; if (this.nsfw !== null) metadata.nsfw = this.nsfw; if (this.channelType !== null) metadata.channelType = this.channelType; const error = canExecute(menu, metadata); if (error) return error; if (typeof this.run === "string") { // Although I *could* add an option in the launcher to attach arbitrary variables to this var string... // I'll just leave it like this, because instead of using var strings for user stuff, you could just make "run" a template string. await menu.send( parseVars( this.run, { author: menu.author.toString(), prefix: getPrefix(menu.guild), command: `${metadata.header} ${metadata.symbolicArgs.join(", ")}` }, "???" ) ); } else { // Then capture any potential errors. try { await this.run({...menu, combined}); } catch (error) { const errorMessage = error.stack ?? error; console.error(`Command Error: ${metadata.header} (${metadata.args.join(", ")})\n${errorMessage}`); return { content: `There was an error while trying to execute that command!\`\`\`${errorMessage}\`\`\`` }; } } return null; } public resolveInfoFinale(metadata: CommandInfoMetadata): CommandInfo { if (this.permission !== -1) metadata.permission = this.permission; if (this.nsfw !== null) metadata.nsfw = this.nsfw; if (this.channelType !== null) metadata.channelType = this.channelType; if (this.usage !== "") metadata.usage = this.usage; return { type: "info", command: this, keyedSubcommandInfo: new Collection(), subcommandInfo: new Collection(), hasRestCommand: false, ...metadata }; } } // See if there is anything that'll prevent the user from executing the command. // Returns null if successful, otherwise returns a message with the error. function canExecute(menu: CommandMenu, metadata: ExecuteCommandMetadata): SingleMessageOptions | null { // 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 || menu.member === 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 || menu.member !== null) ) { return {content: "This command must be executed as a direct message."}; } // 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."}; } // 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}).` }; } return null; }