diff --git a/src/core/command.ts b/src/core/command.ts index 20b7f90..d25a647 100644 --- a/src/core/command.ts +++ b/src/core/command.ts @@ -1,10 +1,9 @@ import {parseVars, requireAllCasesHandledFor} from "./lib"; import {Collection} from "discord.js"; -import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember, GuildChannel} from "discord.js"; +import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js"; import {getPrefix} from "../core/structures"; import {SingleMessageOptions} from "./libd"; import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions"; -import {client} from "../index"; export enum TYPES { SUBCOMMAND, @@ -14,16 +13,6 @@ export enum TYPES { NONE } -// RegEx patterns used for identifying/extracting each type from a string argument. -const patterns = { - channel: /^<#(\d{17,19})>$/, - role: /^<@&(\d{17,19})>$/, - emote: /^$/, - message: /(?:\d{17,19}\/(\d{17,19})\/(\d{17,19})$)|(?:^(\d{17,19})-(\d{17,19})$)/, - user: /^<@!?(\d{17,19})>$/, - id: /^(\d{17,19})$/ -}; - // Callbacks don't work with discriminated unions: // - https://github.com/microsoft/TypeScript/issues/41759 // - https://github.com/microsoft/TypeScript/issues/35769 @@ -37,58 +26,55 @@ export enum CHANNEL_TYPE { } interface CommandMenu { - readonly args: any[]; - readonly client: Client; - readonly message: Message; - readonly channel: TextChannel | DMChannel | NewsChannel; - readonly guild: Guild | null; - readonly author: User; + args: any[]; + client: Client; + message: Message; + channel: TextChannel | DMChannel | NewsChannel; + guild: Guild | null; + 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; + member: GuildMember | null; } interface CommandOptionsBase { - readonly description?: string; - readonly endpoint?: boolean; - readonly usage?: string; - readonly permission?: number; - readonly nsfw?: boolean; - readonly channelType?: CHANNEL_TYPE; - readonly run?: (($: CommandMenu) => Promise) | string; + description?: string; + endpoint?: boolean; + usage?: string; + permission?: number; + nsfw?: boolean; + channelType?: CHANNEL_TYPE; + run?: (($: CommandMenu) => Promise) | string; } interface CommandOptionsEndpoint { - readonly endpoint: true; + endpoint: true; } // Prevents subcommands from being added by compile-time. interface CommandOptionsNonEndpoint { - readonly endpoint?: false; - readonly subcommands?: {[key: string]: NamedCommand}; - readonly user?: Command; - readonly number?: Command; - readonly any?: Command; + endpoint?: false; + subcommands?: {[key: string]: NamedCommand}; + user?: Command; + number?: Command; + any?: Command; } type CommandOptions = CommandOptionsBase & (CommandOptionsEndpoint | CommandOptionsNonEndpoint); type NamedCommandOptions = CommandOptions & {aliases?: string[]}; -interface ExecuteCommandMetadata { - readonly header: string; - readonly args: string[]; - permission: number; - nsfw: boolean; - channelType: CHANNEL_TYPE; -} +// RegEx patterns used for identifying/extracting each type from a string argument. +const patterns = { + // +}; export class Command { public readonly description: string; public readonly endpoint: boolean; 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 + public readonly nsfw: boolean; + public readonly channelType: CHANNEL_TYPE; protected run: (($: CommandMenu) => Promise) | string; protected readonly subcommands: Collection; // This is the final data structure you'll actually use to work with the commands the aliases point to. protected user: Command | null; @@ -101,8 +87,8 @@ export class Command { this.endpoint = !!options?.endpoint; this.usage = options?.usage ?? ""; this.permission = options?.permission ?? -1; - this.nsfw = options?.nsfw ?? null; - this.channelType = options?.channelType ?? null; + this.nsfw = !!options?.nsfw; + this.channelType = options?.channelType ?? CHANNEL_TYPE.ANY; this.run = options?.run || "No action was set on this command!"; this.subcommands = new Collection(); // Populate this collection after setting subcommands. this.user = null; @@ -143,135 +129,116 @@ export class Command { } } + public execute($: CommandMenu) { + if (typeof this.run === "string") { + $.channel.send( + parseVars( + this.run, + { + author: $.author.toString(), + prefix: getPrefix($.guild) + }, + "???" + ) + ); + } 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). - public async execute( - args: string[], - menu: CommandMenu, - metadata: ExecuteCommandMetadata - ): Promise { - const param = args.shift(); + public async actualExecute(args: string[], tmp: any): Promise { + // For debug info, use this.originalCommandName? + // Subcommand Recursion // + let command: Command = new Command(); // = commands.get(header)!; + //resolveSubcommand() + const params: any[] = []; + let isEndpoint = false; + let permLevel = command.permission ?? 0; - // 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. + for (const param of args) { + const type = command.resolve(param); + command = command.get(param); + permLevel = command.permission ?? permLevel; - // 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."}; - } - - // 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}).` - }; - } - - // 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); + if (type === TYPES.USER) { + const id = param.match(/\d+/g)![0]; + try { + params.push(await message.client.users.fetch(id)); + } catch (error) { + return message.channel.send(`No user found by the ID \`${id}\`!`); } - - 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}\`\`\`` - }; - } + } else if (type === TYPES.NUMBER) params.push(Number(param)); + else if (type !== TYPES.SUBCOMMAND) params.push(param); } - // 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!"}; + if (!message.member) + return console.warn("This command was likely called from a DM channel meaning the member object is null."); - // 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; - - // If the current command has an NSFW setting specified, set it. - if (this.nsfw !== null) metadata.nsfw = this.nsfw; - - // If the current command doesn't inherit its channel type, set it. - if (this.channelType !== null) metadata.channelType = this.channelType; - - // 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]; - - 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); + if (!hasPermission(message.member, permLevel)) { + const userPermLevel = getPermissionLevel(message.member); + return message.channel.send( + `You don't have access to this command! Your permission level is \`${getPermissionName( + userPermLevel + )}\` (${userPermLevel}), but this command requires a permission level of \`${getPermissionName( + permLevel + )}\` (${permLevel}).` + ); } - // 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. + if (isEndpoint) return message.channel.send("Too many arguments!"); + + command.execute({ + args: params, + author: message.author, + channel: message.channel, + client: message.client, + guild: message.guild, + member: message.member, + message: message + }); + + return null; } -} -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? + private resolve(param: string): TYPES { + if (this.subcommands.has(param)) return TYPES.SUBCOMMAND; + // Any Discord ID format will automatically format to a user ID. + else if (this.user && /\d{17,19}/.test(param)) return TYPES.USER; + // Disallow infinity and allow for 0. + else if (this.number && (Number(param) || param === "0") && !param.includes("Infinity")) return TYPES.NUMBER; + else if (this.any) return TYPES.ANY; + else return TYPES.NONE; + } - constructor(options?: NamedCommandOptions) { - super(options); - this.aliases = options?.aliases || []; - this.originalCommandName = null; + private get(param: string): Command { + const type = this.resolve(param); + let command: Command; + + switch (type) { + case TYPES.SUBCOMMAND: + command = this.subcommands.get(param)!; + break; + case TYPES.USER: + command = this.user!; + break; + case TYPES.NUMBER: + command = this.number!; + break; + case TYPES.ANY: + command = this.any!; + break; + case TYPES.NONE: + command = this; + break; + default: + requireAllCasesHandledFor(type); + } + + return command; } // Returns: [category, command name, command, available subcommands: [type, subcommand]] public async resolveInfo(args: string[]): [string, string, Command, Collection] | null { - // For debug info, use this.originalCommandName? (if it exists?) const commands = await loadableCommands; let header = args.shift(); let command = commands.get(header); @@ -340,3 +307,31 @@ export class NamedCommand extends 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. +// Case #1: await $.channel.send(""); --> Automatically caught by Command.execute(). +// Case #2: $.channel.send("").catch(handler.bind($)); --> Manually caught by the user. +// TODO: Find a way to catch unhandled rejections automatically, forgoing the need for this. +export function handler(this: CommandMenu, error: Error) { + if (this) + this.channel.send( + `There was an error while trying to execute that command!\`\`\`${error.stack ?? error}\`\`\`` + ); + else + console.warn( + "No context was attached to $.handler! Make sure to use .catch($.handler.bind($)) or .catch(error => $.handler(error)) instead!" + ); + + console.error(error); +} diff --git a/src/core/handler.ts b/src/core/handler.ts index cff1c41..d0cba3c 100644 --- a/src/core/handler.ts +++ b/src/core/handler.ts @@ -3,7 +3,6 @@ import {loadableCommands} from "./loader"; import {Permissions, Message} from "discord.js"; import {getPrefix} from "./structures"; import {Config} from "./structures"; -import {CHANNEL_TYPE} from "./command"; // For custom message events that want to cancel the command handler on certain conditions. const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot]; @@ -12,14 +11,7 @@ export function addInterceptRule(handler: (message: Message) => boolean) { interceptRules.push(handler); } -const defaultMetadata = { - permission: 0, - nsfw: false, - channelType: CHANNEL_TYPE.ANY -}; - // Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined. -// Note: guild.available will never need to be checked because the message starts in either a DM channel or an already-available guild. client.on("message", async (message) => { for (const shouldIntercept of interceptRules) { if (shouldIntercept(message)) { @@ -27,78 +19,47 @@ client.on("message", async (message) => { } } - const commands = await loadableCommands; - const {author, channel, content, guild, member} = message; - const text = content; - const menu = { - author, - channel, - client, - guild, - member, - message, - args: [] - }; - - // Execute a dedicated block for messages in DM channels. - if (channel.type === "dm") { - // In a DM channel, simply forget about the prefix and execute any message as a command. - const [header, ...args] = text.split(/ +/); - - if (commands.has(header)) { - const command = commands.get(header)!; - - // Send the arguments to the command to resolve and execute. - const result = await command.execute(args, menu, { - header, - args, - ...defaultMetadata - }); - - // If something went wrong, let the user know (like if they don't have permission to use a command). - if (result) { - channel.send(result); - } - } else { - channel.send( - `I couldn't find the command or alias that starts with \`${header}\`. To see the list of commands, type \`help\`` - ); - } - } // Continue if the bot has permission to send messages in this channel. - else if (channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)) { - const prefix = getPrefix(guild); + if ( + message.channel.type === "dm" || + message.channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES) + ) { + const text = message.content; + const prefix = getPrefix(message.guild); // First, test if the message is just a ping to the bot. if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) { - channel.send(`${author}, my prefix on this server is \`${prefix}\`.`); + message.channel.send(`${message.author}, my prefix on this guild is \`${prefix}\`.`); } // Then check if it's a normal command. else if (text.startsWith(prefix)) { const [header, ...args] = text.substring(prefix.length).split(/ +/); + const commands = await loadableCommands; if (commands.has(header)) { const command = commands.get(header)!; // Send the arguments to the command to resolve and execute. - const result = await command.execute(args, menu, { - header, - args, - ...defaultMetadata + // TMP[MAKE SURE TO REPLACE WITH command.execute WHEN FINISHED] + const result = await command.actualExecute(args, { + author: message.author, + channel: message.channel, + client: message.client, + guild: message.guild, + member: message.member, + message: message }); // If something went wrong, let the user know (like if they don't have permission to use a command). if (result) { - channel.send(result); + message.channel.send(result); } } } - } - // Otherwise, let the sender know that the bot doesn't have permission to send messages. - else { - author.send( - `I don't have permission to send messages in ${channel}. ${ - member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR) + } else { + message.author.send( + `I don't have permission to send messages in ${message.channel}. ${ + message.member!.hasPermission(Permissions.FLAGS.ADMINISTRATOR) ? "Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended." : "Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong." }` diff --git a/src/core/permissions.ts b/src/core/permissions.ts index 9798a2a..37b0000 100644 --- a/src/core/permissions.ts +++ b/src/core/permissions.ts @@ -15,7 +15,7 @@ export const PermissionLevels: PermissionLevel[] = [ { // MOD // name: "Moderator", - check: (_user, member) => + check: (_, member) => !!member && (member.hasPermission(Permissions.FLAGS.MANAGE_ROLES) || member.hasPermission(Permissions.FLAGS.MANAGE_MESSAGES) || @@ -25,12 +25,12 @@ export const PermissionLevels: PermissionLevel[] = [ { // ADMIN // name: "Administrator", - check: (_user, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR) + check: (_, member) => !!member && member.hasPermission(Permissions.FLAGS.ADMINISTRATOR) }, { // OWNER // name: "Server Owner", - check: (_user, member) => !!member && member.guild.ownerID === member.id + check: (_, member) => !!member && member.guild.ownerID === member.id }, { // BOT_SUPPORT // @@ -52,13 +52,13 @@ export const PermissionLevels: PermissionLevel[] = [ // After checking the lengths of these three objects, use this as the length for consistency. const length = PermissionLevels.length; -export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean { - for (let i = length - 1; i >= permission; i--) if (PermissionLevels[i].check(user, member)) return true; +export function hasPermission(member: GuildMember, permission: number): boolean { + for (let i = length - 1; i >= permission; i--) if (PermissionLevels[i].check(member.user, member)) return true; return false; } -export function getPermissionLevel(user: User, member: GuildMember | null): number { - for (let i = length - 1; i >= 0; i--) if (PermissionLevels[i].check(user, member)) return i; +export function getPermissionLevel(member: GuildMember): number { + for (let i = length - 1; i >= 0; i--) if (PermissionLevels[i].check(member.user, member)) return i; return 0; }