mirror of
				https://github.com/keanuplayz/TravBot-v3.git
				synced 2024-08-15 02:33:12 +00:00 
			
		
		
		
	Finally made the commands directory configurable
This commit is contained in:
		
							parent
							
								
									653cc6f8a6
								
							
						
					
					
						commit
						4c3437a177
					
				
					 5 changed files with 91 additions and 46 deletions
				
			
		|  | @ -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]; | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										17
									
								
								src/index.ts
									
										
									
									
									
								
							
							
						
						
									
										17
									
								
								src/index.ts
									
										
									
									
									
								
							|  | @ -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 //
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue