A powerful and extendable Discord bot, with it's own module system :3 thevoid.cafe/projects/voidy

♻️ Refactor entire module flow and remove registries

Jo 181e8091 3f661d86

.env.example packages/bot/.env.example
+24 -7
bun.lock
··· 3 3 "workspaces": { 4 4 "": { 5 5 "name": "voidydiscord", 6 + }, 7 + "packages/bot": { 8 + "name": "voidy-bot", 9 + "version": "0.1.0", 6 10 "dependencies": { 7 - "ascii-table3": "^1.0.1", 11 + "discord.js": "^14.21.0", 12 + "voidy-framework": "workspace:*", 13 + }, 14 + "devDependencies": { 15 + "@types/bun": "latest", 16 + }, 17 + "peerDependencies": { 18 + "typescript": "^5", 19 + }, 20 + }, 21 + "packages/framework": { 22 + "name": "voidy-framework", 23 + "version": "0.1.0", 24 + "dependencies": { 8 25 "discord.js": "^14.21.0", 9 26 }, 10 27 "devDependencies": { ··· 34 51 35 52 "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], 36 53 37 - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], 54 + "@types/bun": ["@types/bun@1.2.22", "", { "dependencies": { "bun-types": "1.2.22" } }, "sha512-5A/KrKos2ZcN0c6ljRSOa1fYIyCKhZfIVYeuyb4snnvomnpFqC0tTsEkdqNxbAgExV384OETQ//WAjl3XbYqQA=="], 38 55 39 56 "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], 40 57 ··· 44 61 45 62 "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.6", "", {}, "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA=="], 46 63 47 - "ascii-table3": ["ascii-table3@1.0.1", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xOCMZC8S375W4JajrAxFWPyI1VddfbscW9G5zMfhCySSt2Rvi/rs21jAjopzldTPOaFrOocjyGKibQiGExmLrg=="], 48 - 49 - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], 64 + "bun-types": ["bun-types@1.2.22", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-hwaAu8tct/Zn6Zft4U9BsZcXkYomzpHJX28ofvx7k0Zz2HNz54n1n+tDgxoWFGB4PcFvJXJQloPhaV2eP3Q6EA=="], 50 65 51 66 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 52 67 ··· 62 77 63 78 "magic-bytes.js": ["magic-bytes.js@1.12.1", "", {}, "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA=="], 64 79 65 - "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], 66 - 67 80 "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="], 68 81 69 82 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], ··· 73 86 "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="], 74 87 75 88 "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], 89 + 90 + "voidy-bot": ["voidy-bot@workspace:packages/bot"], 91 + 92 + "voidy-framework": ["voidy-framework@workspace:packages/framework"], 76 93 77 94 "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], 78 95
+4 -18
package.json
··· 1 1 { 2 - "name": "voidydiscord", 3 - "version": "3.0.0-alpha1", 4 - "module": "src/index.ts", 5 - "type": "module", 6 - "private": true, 7 - "scripts": { 8 - "dev": "bun --watch ." 9 - }, 10 - "devDependencies": { 11 - "@types/bun": "latest" 12 - }, 13 - "peerDependencies": { 14 - "typescript": "^5" 15 - }, 16 - "dependencies": { 17 - "ascii-table3": "^1.0.1", 18 - "discord.js": "^14.21.0" 19 - } 2 + "name": "voidy", 3 + "workspaces": [ 4 + "packages/*" 5 + ] 20 6 }
+20
packages/bot/package.json
··· 1 + { 2 + "name": "voidy-bot", 3 + "version": "0.1.0", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "private": true, 7 + "scripts": { 8 + "dev": "bun --watch ." 9 + }, 10 + "devDependencies": { 11 + "@types/bun": "latest" 12 + }, 13 + "peerDependencies": { 14 + "typescript": "^5" 15 + }, 16 + "dependencies": { 17 + "voidy-framework": "workspace:*", 18 + "discord.js": "^14.21.0" 19 + } 20 + }
+28
packages/bot/src/modules/core/module.ts
··· 1 + import { 2 + ButtonLoader, 3 + CommandLoader, 4 + EventLoader, 5 + type Module 6 + } from "voidy-framework"; 7 + 8 + export default { 9 + id: "core", 10 + name: "Core", 11 + description: "The core feature set of the bot, required for command handling to work.", 12 + author: "jokiller230", 13 + 14 + exports: [ 15 + { 16 + source: `${import.meta.dir}/events`, 17 + loader: EventLoader, 18 + }, 19 + { 20 + source: `${import.meta.dir}/commands`, 21 + loader: CommandLoader, 22 + }, 23 + { 24 + source: `${import.meta.dir}/buttons`, 25 + loader: ButtonLoader, 26 + } 27 + ] 28 + } as Module;
+14
packages/bot/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "include": [ 4 + "src" 5 + ], 6 + "compilerOptions": { 7 + "baseUrl": ".", 8 + "paths": { 9 + "voidy-framework": [ 10 + "../framework/src" 11 + ] 12 + } 13 + } 14 + }
+16
packages/framework/package.json
··· 1 + { 2 + "name": "voidy-framework", 3 + "version": "0.1.0", 4 + "module": "src/index.ts", 5 + "type": "module", 6 + "private": true, 7 + "devDependencies": { 8 + "@types/bun": "latest" 9 + }, 10 + "peerDependencies": { 11 + "typescript": "^5" 12 + }, 13 + "dependencies": { 14 + "discord.js": "^14.21.0" 15 + } 16 + }
+72
packages/framework/src/core/ModuleManager.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import { ModuleLoader } from "../loaders/ModuleLoader"; 5 + import type { Command } from "./types/Command"; 6 + import type { Button } from "./types/Button"; 7 + import type { Module } from "./types/Module"; 8 + import type { Event } from "./types/Event"; 9 + 10 + export type CacheMap<T = unknown> = Map<string, T>; 11 + 12 + //=============================================== 13 + // ModuleManager Implementation 14 + //=============================================== 15 + export class ModuleManager { 16 + private cache = new Map<string, Map<string, unknown>>(); 17 + 18 + // Module Loading 19 + //============================== 20 + async loadModules(path: string) { 21 + const moduleLoader = new ModuleLoader(path); 22 + const modules = (await moduleLoader.collect()).getJSON(); 23 + 24 + for (const module of modules) { 25 + await this.prepareModule(module); 26 + } 27 + } 28 + 29 + async prepareModule(module: Module) { 30 + for (const exp of module.exports) { 31 + const loader = new exp.loader(exp.source); 32 + const data = (await loader.collect()).getJSON(); 33 + 34 + for (const item of data) { 35 + this.set(loader.id, (item as any).id, item); 36 + } 37 + } 38 + } 39 + 40 + // Core API 41 + //============================== 42 + set<T>(type: string, id: string, value: T) { 43 + if (!this.cache.has(type)) this.cache.set(type, new Map()); 44 + (this.cache.get(type) as CacheMap<T>).set(id, value); 45 + } 46 + 47 + get<T>(type: string, id: string): T | undefined { 48 + return (this.cache.get(type) as CacheMap<T>)?.get(id); 49 + } 50 + 51 + getAll<T>(type: string): CacheMap<T> { 52 + return (this.cache.get(type) as CacheMap<T>) ?? new Map(); 53 + } 54 + 55 + // Typed Accessors 56 + //============================== 57 + get modules(): CacheMap<Module> { 58 + return this.getAll<Module>("module"); 59 + } 60 + 61 + get commands(): CacheMap<Command> { 62 + return this.getAll<Command>("command"); 63 + } 64 + 65 + get buttons(): CacheMap<Button> { 66 + return this.getAll<Button>("button"); 67 + } 68 + 69 + get events(): CacheMap<Event> { 70 + return this.getAll<Event>("event"); 71 + } 72 + }
+126
packages/framework/src/core/VoidyClient.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import { 5 + type ClientOptions, 6 + SlashCommandSubcommandGroupBuilder, 7 + SlashCommandSubcommandBuilder, 8 + SlashCommandBuilder, 9 + Client, 10 + } from "discord.js"; 11 + import { ModuleManager, type CacheMap } from "./ModuleManager"; 12 + import type { Command } from "./types/Command"; 13 + import type { Button } from "./types/Button"; 14 + import type { Event } from "./types/Event"; 15 + 16 + //=============================================== 17 + // VoidyClient Implementation 18 + //=============================================== 19 + export class VoidyClient extends Client { 20 + public moduleManager = new ModuleManager(); 21 + 22 + public constructor(options: ClientOptions) { 23 + super(options); 24 + } 25 + 26 + /** 27 + * Launches the bot 28 + * @param token - The Discord application bot token. 29 + * @param modulesPath - Where the bot should search for modules. 30 + */ 31 + public async start(token: string, modulesPath: string) { 32 + // Load modules and register events 33 + await this.moduleManager.loadModules(modulesPath); 34 + await this.registerEvents(); 35 + 36 + // Register commands on ready event 37 + this.on("ready", this.registerCommands); 38 + 39 + // Login using the bot token 40 + await this.login(token); 41 + } 42 + 43 + /** 44 + * Registers all cached events 45 + * @param events 46 + */ 47 + private async registerEvents() { 48 + const events = this.moduleManager.events; 49 + 50 + for (const [_id, event] of events) { 51 + const execute = (...args: unknown[]) => event.execute(this, ...args); 52 + 53 + if (event.once) this.once(event.name, execute); 54 + else this.on(event.name, execute); 55 + } 56 + } 57 + 58 + /** 59 + * Registers all provided commands globally 60 + * @param commands 61 + */ 62 + private async registerCommands(): Promise<void> { 63 + const topLevelCommands = new Map<string, SlashCommandBuilder>(); 64 + 65 + for (const cmd of this.moduleManager.commands.values()) { 66 + const parts = cmd.id.split("."); // ["music", "set", "channel"] 67 + const command = parts[0]; 68 + const subcommand = parts[1]; 69 + const subgroupcommand = parts[2]; 70 + 71 + if (!command) continue; 72 + 73 + // Ensure top-level builder exists 74 + if (!topLevelCommands.has(command)) { 75 + const topCommand = ( 76 + this.moduleManager.commands.get(command)?.data as SlashCommandBuilder 77 + ) ?? new SlashCommandBuilder().setName(command).setDescription("..."); 78 + 79 + topLevelCommands.set(command, topCommand); 80 + } 81 + 82 + const parent = topLevelCommands.get(command)!; 83 + 84 + if (subcommand && !subgroupcommand) { 85 + // It's a subcommand 86 + parent.addSubcommand(cmd.data as SlashCommandSubcommandBuilder); 87 + } else if (subcommand && subgroupcommand) { 88 + // It's a subgroup command 89 + let group = parent.options.find( 90 + (o): o is SlashCommandSubcommandGroupBuilder => o instanceof SlashCommandSubcommandGroupBuilder && o.name === subcommand 91 + ); 92 + 93 + if (!group) { 94 + group = new SlashCommandSubcommandGroupBuilder().setName(subcommand).setDescription("..."); 95 + parent.addSubcommandGroup(group); 96 + } 97 + 98 + group.addSubcommand(cmd.data as SlashCommandSubcommandBuilder); 99 + } 100 + } 101 + 102 + // Finally convert assembled top-level commands to JSON and register them 103 + await this.application?.commands.set([...topLevelCommands.values()].map(c => c.toJSON())); 104 + } 105 + 106 + /** 107 + * Returns all cached commands 108 + */ 109 + get commands(): CacheMap<Command> { 110 + return this.moduleManager.commands; 111 + } 112 + 113 + /** 114 + * Returns all cached events 115 + */ 116 + get events(): CacheMap<Event> { 117 + return this.moduleManager.events; 118 + } 119 + 120 + /** 121 + * Returns all cached buttons 122 + */ 123 + get buttons(): CacheMap<Button> { 124 + return this.moduleManager.buttons; 125 + } 126 + }
+15
packages/framework/src/core/types/Button.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { ButtonInteraction } from "discord.js"; 5 + import type { VoidyClient } from "../VoidyClient"; 6 + import type { Resource } from "./Resource"; 7 + 8 + //=============================================== 9 + // Button Definition 10 + //=============================================== 11 + export interface Button extends Resource { 12 + execute: ( 13 + interaction: ButtonInteraction, client: VoidyClient 14 + ) => Promise<void> 15 + }
+21
packages/framework/src/core/types/Command.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { 5 + SlashCommandSubcommandGroupBuilder, 6 + SlashCommandSubcommandBuilder, 7 + ChatInputCommandInteraction, 8 + SlashCommandBuilder, 9 + } from "discord.js"; 10 + import type { VoidyClient } from "../VoidyClient"; 11 + import type { Resource } from "./Resource"; 12 + 13 + //=============================================== 14 + // Command Definition 15 + //=============================================== 16 + export interface Command extends Resource { 17 + data: SlashCommandBuilder | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder, 18 + execute: ( 19 + interaction: ChatInputCommandInteraction, client: VoidyClient 20 + ) => Promise<void> 21 + }
+15
packages/framework/src/core/types/Event.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { VoidyClient } from "../VoidyClient"; 5 + import type { ClientEvents } from "discord.js"; 6 + import type { Resource } from "./Resource"; 7 + 8 + //=============================================== 9 + // Event Definition 10 + //=============================================== 11 + export interface Event extends Resource { 12 + name: keyof ClientEvents, 13 + once?: boolean, 14 + execute: (client: VoidyClient, ...args: unknown[]) => void, 15 + }
+23
packages/framework/src/core/types/Module.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Resource } from "./Resource"; 5 + import type { Loader } from "../Loader"; 6 + 7 + //=============================================== 8 + // ModuleExportsItem Definition 9 + //=============================================== 10 + export interface ModuleExportsItem<T extends object> { 11 + source: string 12 + loader: new (...args: ConstructorParameters<typeof Loader<T>>) => Loader<T> 13 + } 14 + 15 + //=============================================== 16 + // Module Definition 17 + //=============================================== 18 + export interface Module extends Resource { 19 + name: string 20 + description: string 21 + author: string 22 + exports: ModuleExportsItem<object>[] 23 + }
+6
packages/framework/src/core/types/Resource.ts
··· 1 + //=============================================== 2 + // Resource Definition 3 + //=============================================== 4 + export interface Resource { 5 + id: string 6 + }
+21
packages/framework/src/index.ts
··· 1 + // Core 2 + export * from "./core/Loader"; 3 + export * from "./core/ModuleManager"; 4 + export * from "./core/VoidyClient"; 5 + 6 + // Types 7 + export * from "./core/types/Button"; 8 + export * from "./core/types/Command"; 9 + export * from "./core/types/Event"; 10 + export * from "./core/types/Module"; 11 + export * from "./core/types/Resource"; 12 + 13 + // Handlers 14 + export * from "./handlers/ButtonHandler"; 15 + export * from "./handlers/CommandHandler"; 16 + 17 + // Loaders 18 + export * from "./loaders/ButtonLoader"; 19 + export * from "./loaders/CommandLoader"; 20 + export * from "./loaders/EventLoader"; 21 + export * from "./loaders/ModuleLoader";
+16
packages/framework/src/loaders/ButtonLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Button } from "../core/types/Button"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // ButtonLoader Implementation 9 + //=============================================== 10 + export class ButtonLoader extends Loader<Button> { 11 + public id = "button"; 12 + public async validate(data: Partial<Button>) { 13 + if (!data.id || !data.execute) return null; 14 + return data as Button; 15 + } 16 + }
+16
packages/framework/src/loaders/CommandLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Command } from "../core/types/Command"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // CommandLoader Implementation 9 + //=============================================== 10 + export class CommandLoader extends Loader<Command> { 11 + public id = "command"; 12 + public async validate(data: Partial<Command>) { 13 + if (!data.id || !data.data || !data.execute) return null; 14 + return data as Command; 15 + } 16 + }
+16
packages/framework/src/loaders/EventLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Event } from "../core/types/Event"; 5 + import { Loader } from "../core/Loader"; 6 + 7 + //=============================================== 8 + // EventLoader Implemenation 9 + //=============================================== 10 + export class EventLoader extends Loader<Event> { 11 + public id = "event"; 12 + public async validate(data: Partial<Event>) { 13 + if (!data.id || !data.name || !data.execute) return null; 14 + return data as Event; 15 + } 16 + }
+23
packages/framework/src/loaders/ModuleLoader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 4 + import type { Module } from "../core/types/Module"; 5 + import { Loader } from "../core/Loader" 6 + 7 + //=============================================== 8 + // ModuleLoader Implementation 9 + //=============================================== 10 + export class ModuleLoader extends Loader<Module> { 11 + public id = "module"; 12 + public async validate(data: Partial<Module>) { 13 + if ( 14 + !data.id || 15 + !data.name || 16 + !data.description || 17 + !data.author || 18 + !data.exports 19 + ) return null; 20 + 21 + return data as Module; 22 + } 23 + }
+14
packages/framework/tsconfig.json
··· 1 + { 2 + "extends": "../../tsconfig.json", 3 + "compilerOptions": { 4 + "declaration": true, 5 + "declarationDir": "dist/types", 6 + "outDir": "dist", 7 + "strict": true, 8 + "esModuleInterop": true, 9 + "composite": true 10 + }, 11 + "include": [ 12 + "src" 13 + ] 14 + }
-33
src/core/Lifecycle.ts
··· 1 - export enum LifecycleEvents { 2 - // Registries 3 - RegistryPreCollect = "registry::preCollect", 4 - RegistryPostCollect = "registry::postCollect", 5 - 6 - // Client 7 - ClientLoop = "client::loop", 8 - } 9 - 10 - type LifecycleEventCallback = () => void; 11 - 12 - export class Lifecycle { 13 - public static subscribers = new Map<string, Array<LifecycleEventCallback>>(); 14 - 15 - public static subscribe(event: LifecycleEvents, callback: LifecycleEventCallback): void { 16 - const subscribers = this.subscribers.get(event); 17 - if (!subscribers) { 18 - this.subscribers.set(event, [callback]); 19 - return; 20 - } 21 - 22 - this.subscribers.set(event, subscribers.concat([callback])); 23 - } 24 - 25 - public static notify(event: LifecycleEvents) { 26 - const subscribers = this.subscribers.get(event); 27 - if (!subscribers) return; 28 - 29 - for (const subscriber of subscribers) { 30 - subscriber(); 31 - } 32 - } 33 - }
+14 -7
src/core/Loader.ts packages/framework/src/core/Loader.ts
··· 1 + //=============================================== 2 + // Imports 3 + //=============================================== 1 4 import { Glob } from "bun"; 2 5 3 - interface ILoader<T> { 6 + //=============================================== 7 + // Loader Definition 8 + //=============================================== 9 + interface ILoader<T extends object> { 4 10 id: string 5 11 cache: T[] 6 12 source: string ··· 10 16 getJSON: () => T[] 11 17 } 12 18 13 - export class Loader<T extends object> implements ILoader<T> { 14 - public id = "loader"; 19 + //=============================================== 20 + // Loader Implementation 21 + //=============================================== 22 + export abstract class Loader<T extends object> implements ILoader<T> { 23 + public abstract id: string; 15 24 public cache: T[] = []; 16 - public source; 25 + public source: string; 17 26 18 27 public constructor(source: string) { 19 28 if (!source) throw new Error("Class of type Loader was initialized without the *required* source parameter."); ··· 57 66 /** 58 67 * Validates a singular element during data collection, and returns whatever should be written to the cache. 59 68 */ 60 - public async validate(data: Partial<T>): Promise<T | null> { 61 - return null; 62 - } 69 + public abstract validate(data: Partial<T>): Promise<T | null>; 63 70 64 71 /** 65 72 * Returns the JSON-ified contents of the loader cache
-83
src/core/Registry.ts
··· 1 - import { ButtonLoader, type Button } from "../loaders/ButtonLoader" 2 - import { CommandLoader, type Command } from "../loaders/CommandLoader" 3 - import { EventLoader, type Event } from "../loaders/EventLoader" 4 - import { ModuleLoader, type Module } from "../loaders/ModuleLoader" 5 - 6 - export enum RegistryCacheKey { 7 - Events = "events", 8 - Modules = "modules", 9 - Buttons = "buttons", 10 - Commands = "commands", 11 - } 12 - 13 - export type RegistryCache = { 14 - events: Event[], 15 - modules: Module[], 16 - buttons: Button[], 17 - commands: Command[], 18 - [x: string]: object[], 19 - }; 20 - 21 - export interface IRegistry { 22 - id: string 23 - active: boolean 24 - dataSource: string 25 - 26 - cache: RegistryCache; 27 - 28 - collectModules: () => Promise<void> 29 - processModules: () => Promise<void> 30 - 31 - activate: () => Promise<void> 32 - deactivate: () => Promise<void> 33 - } 34 - 35 - export class Registry implements IRegistry { 36 - public id: string; 37 - public active = false; 38 - public dataSource: string; 39 - 40 - // Initialize cache stores 41 - public cache: RegistryCache = { 42 - events: [], 43 - modules: [], 44 - buttons: [], 45 - commands: [], 46 - } 47 - 48 - public constructor(id: string, dataSource: string) { 49 - this.id = id; 50 - this.dataSource = dataSource; 51 - } 52 - 53 - /** Collect modules from specified dataSource directory */ 54 - public async collectModules() { 55 - // Collect modules and bundle their JSON contents into an array. 56 - const moduleLoader = new ModuleLoader(this.dataSource); 57 - const modules = (await moduleLoader.collect()).getJSON(); 58 - 59 - // Merge all modules into the store. 60 - this.cache.modules = modules; 61 - } 62 - 63 - /** Process exports of all collected modules */ 64 - public async processModules() { 65 - for (const module of this.cache.modules) { 66 - for (const item of module.exports) { 67 - const loader = new item.loader(item.source); 68 - await loader.collect(); 69 - 70 - // Mape loader output to correct cache key 71 - this.cache[`${loader.id}s`] = [...loader.getJSON()]; 72 - } 73 - } 74 - } 75 - 76 - public async activate() { 77 - this.active = true; 78 - } 79 - 80 - public async deactivate() { 81 - this.active = false; 82 - } 83 - }
-62
src/core/RegistryManager.ts
··· 1 - import { Registry, type RegistryCache } from "./Registry" 2 - 3 - interface IRegistryManager { 4 - addRegistry: (newRegistry: Registry) => boolean 5 - getRegistry: (registryID: string) => Registry | null 6 - 7 - prepareRegistries: () => void 8 - getCache: () => RegistryCache 9 - } 10 - 11 - export class RegistryManager implements IRegistryManager { 12 - private registries = new Map<string, Registry>(); 13 - 14 - public addRegistry(newRegistry: Registry) { 15 - // Append registry to registries array if ID is unique, 16 - // else return false. 17 - if (this.registries.get(newRegistry.id)) return false; 18 - this.registries.set(newRegistry.id, newRegistry); 19 - 20 - // The registry was added successfully, therefore return true. 21 - return true; 22 - } 23 - 24 - public getRegistry(registryID: string) { 25 - const registry = this.registries.get(registryID); 26 - 27 - if (!registry) return null; 28 - return registry; 29 - } 30 - 31 - public async prepareRegistries() { 32 - for (const registry of this.registries.values()) { 33 - // 1. Collecting required registry data 34 - console.info(`[Voidy] Collecting registry data: ${registry.dataSource}`); 35 - await registry.collectModules(); 36 - 37 - // 2. Processing collected registry modules and their exports 38 - console.info(`[Voidy] Processing registry data: ${registry.dataSource}`); 39 - await registry.processModules(); 40 - 41 - // 3. Activating registry 42 - console.info(`[Voidy] Activating registry: ${registry.dataSource}`); 43 - await registry.activate(); 44 - } 45 - } 46 - 47 - public getCache() { 48 - let cache: RegistryCache = { 49 - events: [], 50 - modules: [], 51 - commands: [], 52 - buttons: [], 53 - }; 54 - 55 - // Combine all registry caches 56 - for (const registry of this.registries.values()) { 57 - if (registry.active) cache = { ...cache, ...registry.cache }; 58 - } 59 - 60 - return cache; 61 - } 62 - }
-114
src/core/VoidyClient.ts
··· 1 - import { 2 - type RESTPostAPIChatInputApplicationCommandsJSONBody, 3 - type APIApplicationCommandSubcommandGroupOption, 4 - type APIApplicationCommandSubcommandOption, 5 - type ApplicationCommandDataResolvable, 6 - type ClientOptions, 7 - Client, 8 - } from "discord.js"; 9 - import { Registry } from "./Registry"; 10 - import { RegistryManager } from "./RegistryManager"; 11 - import type { Event } from "../loaders/EventLoader"; 12 - import { Lifecycle, LifecycleEvents } from "./Lifecycle"; 13 - 14 - export class VoidyClient extends Client { 15 - public registryManager = new RegistryManager(); 16 - private intervalID?: NodeJS.Timeout | NodeJS.Timer; 17 - 18 - public constructor(options: ClientOptions) { 19 - super(options); 20 - 21 - // Add the core registry to our registry manager 22 - this.registryManager.addRegistry( 23 - new Registry('core', `${process.cwd()}/src/modules`) 24 - ); 25 - } 26 - 27 - /** 28 - * Register all provided events 29 - * @param events 30 - */ 31 - private async registerEventHandlers(events: Event[]) { 32 - console.log(`[Voidy] Registering ${events.length} event listeners: ${events.map(event => event.name).join(", ")}`); 33 - 34 - for (const event of events) { 35 - const execute = (...args: unknown[]) => event.execute(this, ...args); 36 - 37 - if (event.once) this.once(event.name, execute); 38 - else this.on(event.name, execute); 39 - } 40 - } 41 - 42 - /** 43 - * Register all provided commands to the global discord context 44 - * @param commands 45 - * @todo Fix this type mess, if possible 46 - */ 47 - private async registerCommands(commands: (RESTPostAPIChatInputApplicationCommandsJSONBody | APIApplicationCommandSubcommandOption | APIApplicationCommandSubcommandGroupOption)[]): Promise<void> { 48 - console.info(`[Voidy] Registering ${commands.length} commands: ${commands.map(command => command.name).join(", ")}`); 49 - 50 - await this.application?.commands.set(commands as ApplicationCommandDataResolvable[]); 51 - } 52 - 53 - /** 54 - * Refresh the registry manager and re-register relevant data 55 - * @param token 56 - */ 57 - private async refresh() { 58 - // 1. Prepare and fetch registry manager cache 59 - await this.registryManager.prepareRegistries(); 60 - const cache = this.registryManager.getCache(); 61 - 62 - let cacheSize = 0; 63 - for (const cacheValue of Object.values(cache)) { 64 - cacheSize += cacheValue.length; 65 - } 66 - 67 - // 2. Showcase number of loaded cache entities 68 - console.log(`[Voidy] Refreshed RegistryManager cache, with a total of ${cacheSize} items.`); 69 - 70 - // 3. Clear and re-register events 71 - const events = cache.events; 72 - this.removeAllListeners(); 73 - this.registerEventHandlers(events); 74 - 75 - // 4. Register all active commands 76 - const commands = cache.commands.flatMap(command => command.data.toJSON()); 77 - this.registerCommands(commands); 78 - } 79 - 80 - /** 81 - * Runs reccurring tasks, doesn't loop by itself, though 82 - */ 83 - private async loop() { 84 - // Notifies the "client_loop" lifecycle event 85 - Lifecycle.notify(LifecycleEvents.ClientLoop); 86 - } 87 - 88 - /** 89 - * Starts the client loop, with a customizable interval 90 - * @param interval 91 - */ 92 - public startLoop(interval: number = 60 * 1000) { 93 - this.intervalID = setInterval(this.loop.bind(this), interval); 94 - } 95 - 96 - /** 97 - * Stops the client loop 98 - */ 99 - public stopLoop() { 100 - if (!this.intervalID) return; 101 - clearInterval(this.intervalID); 102 - } 103 - 104 - /** 105 - * Launch the bot, additionally starts the client loop 106 - * @param token 107 - */ 108 - public async start(token: string) { 109 - await this.refresh(); 110 - await this.login(token); 111 - 112 - this.startLoop(); 113 - } 114 - }
src/handlers/ButtonHandler.ts packages/framework/src/handlers/ButtonHandler.ts
src/handlers/CommandHandler.ts packages/framework/src/handlers/CommandHandler.ts
+2 -2
src/index.ts packages/bot/src/index.ts
··· 1 1 import { GatewayIntentBits } from "discord.js" 2 - import { VoidyClient } from "./core/VoidyClient" 2 + import { VoidyClient } from "voidy-framework"; 3 3 4 4 // Client initialization with intents and stuff... 5 5 const client = new VoidyClient({ ··· 8 8 9 9 // Token validation and client start 10 10 if (!Bun.env.BOT_TOKEN) throw new Error("[Voidy] Missing bot token"); 11 - await client.start(Bun.env.BOT_TOKEN); 11 + await client.start(Bun.env.BOT_TOKEN, `${import.meta.dirname}/modules`);
-18
src/loaders/ButtonLoader.ts
··· 1 - import type { ButtonInteraction } from "discord.js"; 2 - import { Loader } from "../core/Loader"; 3 - import type { VoidyClient } from "../core/VoidyClient"; 4 - 5 - export interface Button { 6 - id: string, 7 - execute: ( 8 - interaction: ButtonInteraction, client: VoidyClient 9 - ) => Promise<void> 10 - } 11 - 12 - export class ButtonLoader extends Loader<Button> { 13 - public override id = "button"; 14 - public override async validate(data: Partial<Button>) { 15 - if (!data.id || !data.execute) return null; 16 - return data as Button; 17 - } 18 - }
-21
src/loaders/CommandLoader.ts
··· 1 - import type { 2 - ChatInputCommandInteraction, 3 - SlashCommandBuilder, SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder 4 - } from "discord.js"; 5 - import { Loader } from "../core/Loader"; 6 - import type { VoidyClient } from "../core/VoidyClient"; 7 - 8 - export interface Command { 9 - data: SlashCommandBuilder | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder, 10 - execute: ( 11 - interaction: ChatInputCommandInteraction, client: VoidyClient 12 - ) => Promise<void> 13 - } 14 - 15 - export class CommandLoader extends Loader<Command> { 16 - public override id = "command"; 17 - public override async validate(data: Partial<Command>) { 18 - if (!data.data || !data.execute) return null; 19 - return data as Command; 20 - } 21 - }
-17
src/loaders/EventLoader.ts
··· 1 - import { type ClientEvents } from "discord.js"; 2 - import { Loader } from "../core/Loader"; 3 - import type { VoidyClient } from "../core/VoidyClient"; 4 - 5 - export interface Event { 6 - name: keyof ClientEvents, 7 - once?: boolean, 8 - execute: (client: VoidyClient, ...args: unknown[]) => void, 9 - } 10 - 11 - export class EventLoader extends Loader<Event> { 12 - public override id = "event"; 13 - public override async validate(data: Partial<Event>) { 14 - if (!data.name || !data.execute) return null; 15 - return data as Event; 16 - } 17 - }
-27
src/loaders/ModuleLoader.ts
··· 1 - import { Loader } from "../core/Loader" 2 - 3 - export interface ModuleExportsItem<T extends object> { 4 - source: string 5 - loader: typeof Loader<T> 6 - } 7 - 8 - export interface Module { 9 - name: string 10 - description: string 11 - author: string 12 - exports: ModuleExportsItem<object>[] 13 - } 14 - 15 - export class ModuleLoader extends Loader<Module> { 16 - public override id = "module"; 17 - public override async validate(data: Partial<Module>) { 18 - if ( 19 - !data.name || 20 - !data.description || 21 - !data.author || 22 - !data.exports 23 - ) return null; 24 - 25 - return data as Module; 26 - } 27 - }
+2 -1
src/modules/core/commands/ping.ts packages/bot/src/modules/core/commands/ping.ts
··· 1 1 import { MessageFlags, SlashCommandBuilder } from "discord.js"; 2 - import type { Command } from "../../../loaders/CommandLoader"; 2 + import type { Command } from "voidy-framework"; 3 3 4 4 export default { 5 + id: "ping", 5 6 data: new SlashCommandBuilder() 6 7 .setName("ping") 7 8 .setDescription("View the websocket ping between Discord and the Bot."),
+24 -11
src/modules/core/events/interactionCreate.ts packages/bot/src/modules/core/events/interactionCreate.ts
··· 1 - import type { Event } from "../../../loaders/EventLoader"; 2 - import type { VoidyClient } from "../../../core/VoidyClient"; 3 - import { ButtonHandler } from "../../../handlers/ButtonHandler"; 4 1 import { Events, MessageFlags, type Interaction } from "discord.js"; 5 - import { ChatInputCommandHandler } from "../../../handlers/CommandHandler"; 2 + import { 3 + ChatInputCommandHandler, 4 + ButtonHandler, 5 + type VoidyClient, 6 + type Event 7 + } from "voidy-framework"; 6 8 7 9 export default { 10 + id: "interactionCreate", 8 11 name: Events.InteractionCreate, 9 12 execute: async (client: VoidyClient, interaction: Interaction) => { 10 13 if (interaction.isChatInputCommand() && interaction.isCommand()) { 11 - // Filter the client command cache to locate the invoked command 12 - const payload = client.registryManager.getCache().commands.filter(commands => commands.data.name === interaction.commandName)[0]; 14 + // Set the top-level command name 15 + let commandId = interaction.commandName; 16 + 17 + // Try to get a subgroup first 18 + const subgroup = interaction.options.getSubcommandGroup(false); 19 + if (subgroup) commandId += `.${subgroup}`; 20 + 21 + // Then subcommand (or subcommand in a group) 22 + const subcommand = interaction.options.getSubcommand(false); 23 + if (subcommand) commandId += `.${subcommand}`; 24 + 25 + const command = client.commands.get(commandId); 13 26 14 - if (!payload) return interaction.reply({ 27 + if (!command) return interaction.reply({ 15 28 content: `Sorry, but the command ${interaction.commandName} could not be located in my command cache >:3`, 16 29 flags: [MessageFlags.Ephemeral] 17 30 }); 18 31 19 - ChatInputCommandHandler.invoke(interaction, payload, client); 32 + ChatInputCommandHandler.invoke(interaction, command, client); 20 33 } else if (interaction.isButton()) { 21 34 // Filter the client button cache to locate the invoked button 22 - const payload = client.registryManager.getCache().buttons.filter(buttons => buttons.id === interaction.customId)[0]; 35 + const button = client.buttons.get(interaction.customId); 23 36 24 - if (!payload) return interaction.reply({ 37 + if (!button) return interaction.reply({ 25 38 content: `Sorry, but the button ${interaction.customId} could not be located in my button cache >:3`, 26 39 flags: [MessageFlags.Ephemeral] 27 40 }); 28 41 29 - ButtonHandler.invoke(interaction, payload, client); 42 + ButtonHandler.invoke(interaction, button, client); 30 43 } else { 31 44 let dmChannel = interaction.user.dmChannel; 32 45
+2 -2
src/modules/core/events/ready.ts packages/bot/src/modules/core/events/ready.ts
··· 1 + import type { Event, VoidyClient } from "voidy-framework"; 1 2 import { ActivityType, Events } from "discord.js"; 2 - import type { Event } from "../../../loaders/EventLoader"; 3 - import type { VoidyClient } from "../../../core/VoidyClient"; 4 3 5 4 export default { 5 + id: "ready", 6 6 name: Events.ClientReady, 7 7 once: true, 8 8 execute: async (client: VoidyClient) => {
-30
src/modules/core/module.ts
··· 1 - import { Lifecycle, LifecycleEvents } from "../../core/Lifecycle"; 2 - import { ButtonLoader } from "../../loaders/ButtonLoader"; 3 - import { CommandLoader } from "../../loaders/CommandLoader"; 4 - import { EventLoader } from "../../loaders/EventLoader"; 5 - import type { Module } from "../../loaders/ModuleLoader"; 6 - 7 - Lifecycle.subscribe(LifecycleEvents.ClientLoop, () => { 8 - console.log("Wait what, wait what..."); 9 - }) 10 - 11 - export default { 12 - name: "core", 13 - description: "The core feature set of the bot, required for command handling to work.", 14 - author: "jokiller230", 15 - 16 - exports: [ 17 - { 18 - source: `${import.meta.dir}/events`, 19 - loader: EventLoader, 20 - }, 21 - { 22 - source: `${import.meta.dir}/commands`, 23 - loader: CommandLoader, 24 - }, 25 - { 26 - source: `${import.meta.dir}/buttons`, 27 - loader: ButtonLoader, 28 - } 29 - ] 30 - } as Module;
-5
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 - // Environment setup & latest features 4 3 "lib": [ 5 4 "ESNext" 6 5 ], 7 6 "target": "ESNext", 8 7 "module": "Preserve", 9 8 "moduleDetection": "force", 10 - "jsx": "react-jsx", 11 9 "allowJs": true, 12 - // Bundler mode 13 10 "moduleResolution": "bundler", 14 11 "allowImportingTsExtensions": true, 15 12 "verbatimModuleSyntax": true, 16 13 "noEmit": true, 17 - // Best practices 18 14 "strict": true, 19 15 "skipLibCheck": true, 20 16 "noFallthroughCasesInSwitch": true, 21 17 "noUncheckedIndexedAccess": true, 22 18 "noImplicitOverride": true, 23 - // Some stricter flags (disabled by default) 24 19 "noUnusedLocals": false, 25 20 "noUnusedParameters": false, 26 21 "noPropertyAccessFromIndexSignature": false