Reorganized code dealing with the command class

This commit is contained in:
WatDuhHekBro 2021-04-03 04:58:20 -05:00
parent 9adc5eea6e
commit f650faee89
4 changed files with 269 additions and 279 deletions

View File

@ -1,6 +1,6 @@
import Command from "../../core/command";
import {toTitleCase} from "../../core/lib";
import {loadableCommands, categories} from "../../core/command";
import {loadableCommands, categories} from "../../core/loader";
import {getPermissionName} from "../../core/permissions";
export default new Command({
@ -32,69 +32,7 @@ export default new Command({
},
any: new Command({
async run($) {
const commands = await loadableCommands;
let header = $.args.shift() as string;
let command = commands.get(header);
if (!command || header === "test") {
$.channel.send(`No command found by the name \`${header}\`!`);
return;
}
if (command.originalCommandName) header = command.originalCommandName;
else console.warn(`originalCommandName isn't defined for ${header}?!`);
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);
}
}
for (const param of $.args) {
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
if (permLevel === -1) permLevel = command.permission;
switch (type) {
case Command.TYPES.SUBCOMMAND:
header += ` ${command.originalCommandName}`;
break;
case Command.TYPES.USER:
header += " <user>";
break;
case Command.TYPES.NUMBER:
header += " <number>";
break;
case Command.TYPES.ANY:
header += " <any>";
break;
default:
header += ` ${param}`;
break;
}
if (type === Command.TYPES.NONE) {
invalid = true;
break;
}
}
if (invalid) {
$.channel.send(`No command found by the name \`${header}\`!`);
return;
}
// [category, commandName, command, subcommandInfo] = resolveCommandInfo();
let append = "";
@ -123,18 +61,10 @@ export default new Command({
append = "Usages:" + (list.length > 0 ? `\n${list.join("\n")}` : " None.");
} else append = `Usage: \`${header} ${usage}\``;
let aliases = "None";
if (command.aliases.length > 0) {
aliases = "";
for (let i = 0; i < command.aliases.length; i++) {
const alias = command.aliases[i];
aliases += `\`${alias}\``;
if (i !== command.aliases.length - 1) aliases += ", ";
}
}
const formattedAliases: string[] = [];
for (const alias of command.aliases) formattedAliases.push(`\`${alias}\``);
// Short circuit an empty string, in this case, if there are no aliases.
const aliases = formattedAliases.join(", ") || "None";
$.channel.send(
`Command: \`${header}\`\nAliases: ${aliases}\nCategory: \`${selectedCategory}\`\nPermission Required: \`${getPermissionName(

View File

@ -2,7 +2,8 @@ import {parseVars} from "./lib";
import {Collection} from "discord.js";
import {Client, Message, TextChannel, DMChannel, NewsChannel, Guild, User, GuildMember} from "discord.js";
import {getPrefix} from "../core/structures";
import glob from "glob";
import {SingleMessageOptions} from "./libd";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
interface CommandMenu {
args: any[];
@ -27,7 +28,7 @@ interface CommandOptions {
any?: Command;
}
export enum TYPES {
enum TYPES {
SUBCOMMAND,
USER,
NUMBER,
@ -47,7 +48,6 @@ export default class Command {
public user: Command | null;
public number: Command | null;
public any: Command | null;
public static readonly TYPES = TYPES;
constructor(options?: CommandOptions) {
this.description = options?.description || "No description.";
@ -120,6 +120,67 @@ export default class Command {
} else this.run($).catch(handler.bind($));
}
// 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> {
// Subcommand Recursion //
let command = commands.get(header)!;
//resolveSubcommand()
const params: any[] = [];
let isEndpoint = false;
let permLevel = command.permission ?? 0;
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);
command = command.get(param);
permLevel = command.permission ?? permLevel;
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}\`!`);
}
} else if (type === TYPES.NUMBER) params.push(Number(param));
else if (type !== TYPES.SUBCOMMAND) params.push(param);
}
if (!message.member)
return console.warn("This command was likely called from a DM channel meaning the member object is null.");
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}).`
);
}
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;
}
public resolve(param: string): TYPES {
if (this.subcommands.has(param)) return TYPES.SUBCOMMAND;
// Any Discord ID format will automatically format to a user ID.
@ -154,84 +215,73 @@ export default class Command {
return 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[]>();
// Returns: [category, command name, command, available subcommands: [type, subcommand]]
public resolveCommandInfo(args: string[]): [string, string, Command, Collection<string, Command>] {
const commands = await loadableCommands;
let header = args.shift();
let command = commands.get(header);
/** Returns the cache of the commands if it exists and searches the directory if not. */
export const loadableCommands = (async () => {
const commands = new Collection<string, Command>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" if:
// - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
// - Any leading directory isn't "modules"
// - The filename doesn't end in .test.ts (for jest testing)
// - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates
const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/;
const lists: {[category: string]: string[]} = {};
if (!command || header === "test") {
$.channel.send(`No command found by the name \`${header}\`!`);
return;
}
for (const path of files) {
const match = pattern.exec(path);
if (command.originalCommandName) header = command.originalCommandName;
else console.warn(`originalCommandName isn't defined for ${header}?!`);
if (match) {
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"
// 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;
let permLevel = command.permission ?? 0;
let usage = command.usage;
let invalid = false;
if (command instanceof Command) {
command.originalCommandName = commandName;
let selectedCategory = "Unknown";
if (commands.has(commandName)) {
for (const [category, headers] of categories) {
if (headers.includes(header)) {
if (selectedCategory !== "Unknown")
console.warn(
`Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!`
`Command "${header}" is somehow in multiple categories. This means that the command loading stage probably failed in properly adding categories.`
);
} else {
commands.set(commandName, command);
}
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] = [];
lists[category].push(commandName);
console.log(`Loading Command: ${commandID}`);
} else {
console.warn(`Command "${commandID}" has no default export which is a Command instance!`);
else selectedCategory = toTitleCase(category);
}
}
}
for (const category in lists) {
categories.set(category, lists[category]);
}
for (const param of args) {
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
return commands;
})();
if (permLevel === -1) permLevel = command.permission;
function globP(path: string) {
return new Promise<string[]>((resolve, reject) => {
glob(path, (error, files) => {
if (error) {
reject(error);
} else {
resolve(files);
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;
default:
header += ` ${param}`;
break;
}
});
});
if (type === TYPES.NONE) {
invalid = true;
break;
}
}
if (invalid) {
$.channel.send(`No command found by the name \`${header}\`!`);
return;
}
}
}
// If you use promises, use this function to display the error in chat.

View File

@ -1,28 +1,17 @@
import {client} from "../index";
import Command, {loadableCommands} from "./command";
import {hasPermission, getPermissionLevel, getPermissionName} from "./permissions";
import {loadableCommands} from "./loader";
import {Permissions, Message} from "discord.js";
import {getPrefix} from "./structures";
import {Config} from "./structures";
///////////
// Steps //
///////////
// 1. Someone sends a message in chat.
// 2. Check if bot, then load commands.
// 3. Check if "<prefix>...". If not, check if "@<bot>...". Resolve prefix and cropped message (if possible).
// 4. Test if bot has permission to send messages.
// 5. Once confirmed as a command, resolve the subcommand.
// 6. Check permission level and whether or not it's an endpoint.
// 7. Execute command if all successful.
// For custom message events that want to cancel this one on certain conditions.
// For custom message events that want to cancel the command handler on certain conditions.
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot];
export function addInterceptRule(handler: (message: Message) => boolean) {
interceptRules.push(handler);
}
// Note: client.user is only undefined before the bot logs in, so by this point, client.user cannot be undefined.
client.on("message", async (message) => {
for (const shouldIntercept of interceptRules) {
if (shouldIntercept(message)) {
@ -30,139 +19,57 @@ client.on("message", async (message) => {
}
}
const commands = await loadableCommands;
let prefix = getPrefix(message.guild);
const originalPrefix = prefix;
let exitEarly = !message.content.startsWith(prefix);
const clientUser = message.client.user;
let usesBotSpecificPrefix = false;
// If the client user exists, check if it starts with the bot-specific prefix.
if (clientUser) {
// If the prefix starts with the bot-specific prefix, go off that instead (these two options must mutually exclude each other).
// The pattern here has an optional space at the end to capture that and make it not mess with the header and args.
const matches = message.content.match(new RegExp(`^<@!?${clientUser.id}> ?`));
if (matches) {
prefix = matches[0];
exitEarly = false;
usesBotSpecificPrefix = true;
}
}
// If it doesn't start with the current normal prefix or the bot-specific unique prefix, exit the thread of execution early.
// Inline replies should still be captured here because if it doesn't exit early, two characters for a two-length prefix would still trigger commands.
if (exitEarly) return;
const [header, ...args] = message.content.substring(prefix.length).split(/ +/);
// If the message is just the prefix itself, move onto this block.
if (header === "" && args.length === 0) {
// I moved the bot-specific prefix to a separate conditional block to separate the logic.
// And because it listens for the mention as a prefix instead of a free-form mention, inline replies (probably) shouldn't ever trigger this unintentionally.
if (usesBotSpecificPrefix) {
message.channel.send(`${message.author.toString()}, my prefix on this guild is \`${originalPrefix}\`.`);
return;
}
}
if (!commands.has(header)) return;
// Continue if the bot has permission to send messages in this channel.
if (
message.channel.type === "text" &&
!message.channel.permissionsFor(message.client.user || "")?.has(Permissions.FLAGS.SEND_MESSAGES)
message.channel.type === "dm" ||
message.channel.permissionsFor(client.user!)!.has(Permissions.FLAGS.SEND_MESSAGES)
) {
let status;
const text = message.content;
const prefix = getPrefix(message.guild);
if (message.member?.hasPermission(Permissions.FLAGS.ADMINISTRATOR))
status =
"Because you're a server admin, you have the ability to change that channel's permissions to match if that's what you intended.";
else
status =
"Try using a different channel or contacting a server admin to change permissions of that channel if you think something's wrong.";
return message.author.send(
`I don't have permission to send messages in ${message.channel.toString()}. ${status}`
);
}
console.log(
`${message.author.username}#${message.author.discriminator} executed the command "${header}" with arguments "${args}".`
);
// Subcommand Recursion //
let command = commands.get(header)!;
//resolveSubcommand()
if (!message.member)
return console.warn("This command was likely called from a DM channel meaning the member object is null.");
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}).`
);
}
if (isEndpoint) return message.channel.send("Too many arguments!");
// Execute with dynamic library attached. //
// The purpose of using $.bind($) is to clone the function so as to not modify the original $.
// The cloned function doesn't copy the properties, so Object.assign() is used.
// Object.assign() modifies the first element and returns that, the second element applies its properties and the third element applies its own overriding the second one.
command.execute({
args: params,
author: message.author,
channel: message.channel,
client: message.client,
guild: message.guild,
member: message.member,
message: message
});
});
// Takes a base command and a list of string parameters and returns:
// - The resolved subcommand
// - The resolved parameters
// - Whether or not an endpoint has been broken
// - The permission level required
async function resolveSubcommand(command: Command, args: string[]): [Command, any[], boolean, number] {
const params: any[] = [];
let isEndpoint = false;
let permLevel = command.permission ?? 0;
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;
// First, test if the message is just a ping to the bot.
if (new RegExp(`^<@!?${client.user!.id}>$`).test(text)) {
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;
const type = command.resolve(param);
command = command.get(param);
permLevel = command.permission ?? permLevel;
if (commands.has(header)) {
const command = commands.get(header)!;
if (type === Command.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}\`!`);
// Send the arguments to the command to resolve and execute.
// 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) {
message.channel.send(result);
}
}
} else if (type === Command.TYPES.NUMBER) params.push(Number(param));
else if (type !== Command.TYPES.SUBCOMMAND) params.push(param);
}
} 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."
}`
);
}
}
});
client.once("ready", () => {
if (client.user) {
console.ready(`Logged in as ${client.user.username}#${client.user.discriminator}.`);
console.ready(`Logged in as ${client.user.tag}.`);
client.user.setActivity({
type: "LISTENING",
name: `${Config.prefix}help`

103
src/core/loader.ts Normal file
View File

@ -0,0 +1,103 @@
import {Collection} from "discord.js";
import glob from "glob";
import Command 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.
export const categories = new Collection<string, string[]>();
/** Returns the cache of the commands if it exists and searches the directory if not. */
export const loadableCommands = (async () => {
const commands = new Collection<string, Command>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" if:
// - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
// - Any leading directory isn't "modules"
// - The filename doesn't end in .test.ts (for jest testing)
// - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates
const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/;
const lists: {[category: string]: string[]} = {};
for (const path of files) {
const match = pattern.exec(path);
if (match) {
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"
// 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;
if (command instanceof Command) {
command.originalCommandName = commandName;
if (commands.has(commandName)) {
console.warn(
`Command "${commandName}" already exists! Make sure to make each command uniquely identifiable across categories!`
);
} else {
commands.set(commandName, command);
}
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] = [];
lists[category].push(commandName);
console.log(`Loading Command: ${commandID}`);
} else {
console.warn(`Command "${commandID}" has no default export which is a Command instance!`);
}
}
}
for (const category in lists) {
categories.set(category, lists[category]);
}
return commands;
})();
function globP(path: string) {
return new Promise<string[]>((resolve, reject) => {
glob(path, (error, files) => {
if (error) {
reject(error);
} else {
resolve(files);
}
});
});
}
// Gathers a list of categories and top-level commands.
// Returns: new Collection<string category, Command[] commandList>()
/*export async function getCommandList(): Promise<Collection<string, Command[]>> {
const categorizedCommands = new Collection<string, Command[]>();
const commands = await loadableCommands;
for (const [category, headers] of categories) {
const commandList: Command[] = [];
for (const header of headers) {
if (header !== "test") {
// If this is somehow undefined, it'll show up as an error when implementing a help command.
commandList.push(commands.get(header)!);
}
}
categorizedCommands.set(toTitleCase(category), commandList);
}
return categorizedCommands;
}*/