Finally made the commands directory configurable

This commit is contained in:
WatDuhHekBro 2021-04-10 02:38:46 -05:00
parent 653cc6f8a6
commit 4c3437a177
5 changed files with 91 additions and 46 deletions

View file

@ -1,6 +1,5 @@
import {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {loadableCommands} from "./loader";
import {getPrefix} from "./interface";
import {getPrefix, loadableCommands} from "./interface";
// For custom message events that want to cancel the command handler on certain conditions.
const interceptRules: ((message: Message) => boolean)[] = [(message) => message.author.bot];

View file

@ -1,23 +1,52 @@
import {Client, User, GuildMember, Guild} from "discord.js";
import {Collection, Client, User, GuildMember, Guild} from "discord.js";
import {attachMessageHandlerToClient} from "./handler";
import {attachEventListenersToClient} from "./eventListeners";
interface LaunchSettings {
permissionLevels: PermissionLevel[];
getPrefix: (guild: Guild | null) => string;
}
export async function launch(client: Client, settings: LaunchSettings) {
attachMessageHandlerToClient(client);
attachEventListenersToClient(client);
permissionLevels = settings.permissionLevels;
getPrefix = settings.getPrefix;
}
import {NamedCommand} from "./command";
import {loadCommands} from "./loader";
interface PermissionLevel {
name: string;
check: (user: User, member: GuildMember | null) => boolean;
}
export let permissionLevels: PermissionLevel[] = [];
export let getPrefix: (guild: Guild | null) => string = () => ".";
type PrefixResolver = (guild: Guild | null) => string;
type CategoryTransformer = (text: string) => string;
// One potential option is to let the user customize system messages such as "This command must be executed in a guild."
// I decided not to do that because I don't think it'll be worth the trouble.
interface LaunchSettings {
permissionLevels?: PermissionLevel[];
getPrefix?: PrefixResolver;
categoryTransformer?: CategoryTransformer;
}
// One alternative to putting everything in launch(client, ...) is to create an object then set each individual aspect, such as OnionCore.setPermissions(...).
// That way, you can split different pieces of logic into different files, then do OnionCore.launch(client).
// Additionally, each method would return the object so multiple methods could be chained, such as OnionCore.setPermissions(...).setPrefixResolver(...).launch(client).
// I decided to not do this because creating a class then having a bunch of boilerplate around it just wouldn't really be worth it.
// commandsDirectory requires an absolute path to work, so use __dirname.
export async function launch(client: Client, commandsDirectory: string, settings?: LaunchSettings) {
// Core Launch Parameters //
loadableCommands = loadCommands(commandsDirectory);
attachMessageHandlerToClient(client);
attachEventListenersToClient(client);
// Additional Configuration //
if (settings?.permissionLevels) {
if (settings.permissionLevels.length > 0) permissionLevels = settings.permissionLevels;
else console.warn("permissionLevels must have at least one element to work!");
}
if (settings?.getPrefix) getPrefix = settings.getPrefix;
if (settings?.categoryTransformer) categoryTransformer = settings.categoryTransformer;
}
// Placeholder until properly loaded by the user.
export let loadableCommands = (async () => new Collection<string, NamedCommand>())();
export let permissionLevels: PermissionLevel[] = [
{
name: "User",
check: () => true
}
];
export let getPrefix: PrefixResolver = () => ".";
export let categoryTransformer: CategoryTransformer = (text) => text;

View file

@ -1,35 +1,46 @@
import {Collection} from "discord.js";
import glob from "glob";
import path from "path";
import {NamedCommand, CommandInfo} from "./command";
import {toTitleCase} from "../lib";
import {loadableCommands, categoryTransformer} from "./interface";
// Internally, it'll keep its original capitalization. It's up to you to convert it to title case when you make a help command.
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 () => {
// 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;
const commands = new Collection<string, NamedCommand>();
// Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts");
// Extract the usable parts from "src/commands/" if:
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:
// - 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/;
// - 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$/;
const lists: {[category: string]: string[]} = {};
for (const path of files) {
const match = pattern.exec(path);
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"
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"
// This try-catch block MUST be here or Node.js' dynamic require() will silently fail.
try {
// 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;
const command = (await import(files[i])).default as unknown;
if (command instanceof NamedCommand) {
command.name = commandName;
@ -55,10 +66,12 @@ export const loadableCommands = (async () => {
if (!(category in lists)) lists[category] = [];
lists[category].push(commandName);
console.log(`Loading Command: ${commandID}`);
console.log(`Loaded Command: ${commandID}`);
} else {
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`);
}
} catch (error) {
console.log(error);
}
}
@ -67,7 +80,7 @@ export const loadableCommands = (async () => {
}
return commands;
})();
}
function globP(path: string) {
return new Promise<string[]>((resolve, reject) => {
@ -92,7 +105,7 @@ export async function getCommandList(): Promise<Collection<string, NamedCommand[
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).
if (commandList.length > 0) list.set(toTitleCase(category), commandList);
if (commandList.length > 0) list.set(categoryTransformer(category), commandList);
}
return list;
@ -120,7 +133,7 @@ export async function getCommandInfo(args: string[]): Promise<[CommandInfo, stri
let category = "Unknown";
for (const [referenceCategory, headers] of categories) {
if (headers.includes(header)) {
category = toTitleCase(referenceCategory);
category = categoryTransformer(referenceCategory);
break;
}
}

View file

@ -6,6 +6,7 @@ import {permissionLevels} from "./interface";
* Checks if a `Member` has a certain permission.
*/
export function hasPermission(user: User, member: GuildMember | null, permission: number): boolean {
if (permissionLevels.length === 0) return true;
for (let i = permissionLevels.length - 1; i >= permission; i--)
if (permissionLevels[i].check(user, member)) return true;
return false;
@ -20,6 +21,6 @@ export function getPermissionLevel(user: User, member: GuildMember | null): numb
}
export function getPermissionName(level: number) {
if (level > permissionLevels.length || level < 0) return "N/A";
if (level > permissionLevels.length || level < 0 || permissionLevels.length === 0) return "N/A";
else return permissionLevels[level].name;
}

View file

@ -1,21 +1,25 @@
// Bootstrapping Section //
import "./modules/globals";
import {Client, Permissions} from "discord.js";
import {launch} from "./core";
import setup from "./modules/setup";
import {Config, getPrefix} from "./structures";
import path from "path";
// This is here in order to make it much less of a headache to access the client from other files.
// This of course won't actually do anything until the setup process is complete and it logs in.
export const client = new Client();
import {launch} from "./core";
import setup from "./modules/setup";
import {Config, getPrefix} from "./structures";
import {toTitleCase} from "./lib";
// Send the login request to Discord's API and then load modules while waiting for it.
setup.init().then(() => {
client.login(Config.token).catch(setup.again);
});
// Setup the command handler.
launch(client, {
launch(client, path.join(__dirname, "commands"), {
getPrefix,
categoryTransformer: toTitleCase,
permissionLevels: [
{
// NONE //
@ -57,8 +61,7 @@ launch(client, {
name: "Bot Owner",
check: (user) => Config.owner === user.id
}
],
getPrefix: getPrefix
]
});
// Initialize Modules //