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 {Client, Permissions, Message, TextChannel, DMChannel, NewsChannel} from "discord.js";
import {loadableCommands} from "./loader"; import {getPrefix, loadableCommands} from "./interface";
import {getPrefix} from "./interface";
// For custom message events that want to cancel the command handler 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]; 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 {attachMessageHandlerToClient} from "./handler";
import {attachEventListenersToClient} from "./eventListeners"; import {attachEventListenersToClient} from "./eventListeners";
import {NamedCommand} from "./command";
interface LaunchSettings { import {loadCommands} from "./loader";
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;
}
interface PermissionLevel { interface PermissionLevel {
name: string; name: string;
check: (user: User, member: GuildMember | null) => boolean; check: (user: User, member: GuildMember | null) => boolean;
} }
export let permissionLevels: PermissionLevel[] = []; type PrefixResolver = (guild: Guild | null) => string;
export let getPrefix: (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 {Collection} from "discord.js";
import glob from "glob"; import glob from "glob";
import path from "path";
import {NamedCommand, CommandInfo} from "./command"; 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. // 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[]>(); const categories = new Collection<string, string[]>();
/** Returns the cache of the commands if it exists and searches the directory if not. */ // 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.
export const loadableCommands = (async () => { // 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>(); const commands = new Collection<string, NamedCommand>();
// Include all .ts files recursively in "src/commands/". // Include all .ts files recursively in "src/commands/".
const files = await globP("src/commands/**/*.ts"); const files = await globP(path.join(commandsDir, "**", "*.js")); // This stage filters out source maps (.js.map).
// Extract the usable parts from "src/commands/" if: // 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) // - The path is 1 to 2 subdirectories (a or a/b, not a/b/c)
// - Any leading directory isn't "modules" // - Any leading directory isn't "modules"
// - The filename doesn't end in .test.ts (for jest testing) // - The filename doesn't end in .test.js (for jest testing)
// - The filename cannot be the hardcoded top-level "template.ts", reserved for generating templates // - The filename cannot be the hardcoded top-level "template.js", reserved for generating templates
const pattern = /src\/commands\/(?!template\.ts)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.ts/; const pattern = /^(?!template\.js)(?!modules\/)(\w+(?:\/\w+)?)(?:test\.)?\.js$/;
const lists: {[category: string]: string[]} = {}; const lists: {[category: string]: string[]} = {};
for (const path of files) { for (let i = 0; i < files.length; i++) {
const match = pattern.exec(path); const match = pattern.exec(filesClean[i]);
if (!match) continue;
if (match) {
const commandID = match[1]; // e.g. "utilities/info" const commandID = match[1]; // e.g. "utilities/info"
const slashIndex = commandID.indexOf("/"); const slashIndex = commandID.indexOf("/");
const isMiscCommand = slashIndex !== -1; const isMiscCommand = slashIndex !== -1;
const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous"; const category = isMiscCommand ? commandID.substring(0, slashIndex) : "miscellaneous";
const commandName = isMiscCommand ? commandID.substring(slashIndex + 1) : commandID; // e.g. "info" 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. // 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) { if (command instanceof NamedCommand) {
command.name = commandName; command.name = commandName;
@ -55,10 +66,12 @@ export const loadableCommands = (async () => {
if (!(category in lists)) lists[category] = []; if (!(category in lists)) lists[category] = [];
lists[category].push(commandName); lists[category].push(commandName);
console.log(`Loading Command: ${commandID}`); console.log(`Loaded Command: ${commandID}`);
} else { } else {
console.warn(`Command "${commandID}" has no default export which is a NamedCommand instance!`); 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; return commands;
})(); }
function globP(path: string) { function globP(path: string) {
return new Promise<string[]>((resolve, reject) => { return new Promise<string[]>((resolve, reject) => {
@ -92,7 +105,7 @@ export async function getCommandList(): Promise<Collection<string, NamedCommand[
const commandList: NamedCommand[] = []; const commandList: NamedCommand[] = [];
for (const header of headers.filter((header) => header !== "test")) commandList.push(commands.get(header)!); for (const header of headers.filter((header) => header !== "test")) commandList.push(commands.get(header)!);
// Ignore empty categories like "miscellaneous" (if it's empty). // 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; return list;
@ -120,7 +133,7 @@ export async function getCommandInfo(args: string[]): Promise<[CommandInfo, stri
let category = "Unknown"; let category = "Unknown";
for (const [referenceCategory, headers] of categories) { for (const [referenceCategory, headers] of categories) {
if (headers.includes(header)) { if (headers.includes(header)) {
category = toTitleCase(referenceCategory); category = categoryTransformer(referenceCategory);
break; break;
} }
} }

View file

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

View file

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