From 125ab779bb7c6fa5b65166483a44713e2a5f917f Mon Sep 17 00:00:00 2001 From: mustafakyia Date: Fri, 16 Jan 2026 23:35:23 +0300 Subject: [PATCH 1/2] refactor: implement class-based structure with hybrid command support --- package-lock.json | 47 ++----- src/classes/base/BaseClient.ts | 15 -- src/index.ts | 6 + src/structures/Argument.ts | 36 +++++ src/structures/Client.ts | 191 ++++++++++++++++++++++++++ src/structures/Command.ts | 43 ++++++ src/structures/Context.ts | 64 +++++++++ src/structures/Event.ts | 16 +++ src/{classes/base => utils}/Logger.ts | 0 9 files changed, 364 insertions(+), 54 deletions(-) delete mode 100644 src/classes/base/BaseClient.ts create mode 100644 src/structures/Argument.ts create mode 100644 src/structures/Client.ts create mode 100644 src/structures/Command.ts create mode 100644 src/structures/Context.ts create mode 100644 src/structures/Event.ts rename src/{classes/base => utils}/Logger.ts (100%) diff --git a/package-lock.json b/package-lock.json index ef734b5..a879393 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,7 +41,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", @@ -63,7 +62,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=16.11.0" } @@ -73,7 +71,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "discord-api-types": "^0.38.33" }, @@ -89,7 +86,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", @@ -113,7 +109,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" }, @@ -126,7 +121,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "discord-api-types": "^0.38.33" }, @@ -142,7 +136,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", @@ -166,7 +159,6 @@ "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18" }, @@ -1204,7 +1196,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -1215,7 +1206,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -1229,7 +1219,6 @@ "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -1416,7 +1405,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -1434,7 +1422,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=10" } @@ -1452,7 +1439,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -1470,7 +1456,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -1488,7 +1473,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -1506,7 +1490,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -1524,7 +1507,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=10" } @@ -1542,7 +1524,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -1560,7 +1541,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -1578,7 +1558,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=10" } @@ -1596,7 +1575,6 @@ "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -1701,7 +1679,6 @@ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -1711,7 +1688,6 @@ "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", - "peer": true, "engines": { "node": ">=v14.0.0", "npm": ">=7.0.0" @@ -2324,7 +2300,6 @@ "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.37.tgz", "integrity": "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==", "license": "MIT", - "peer": true, "workspaces": [ "scripts/actions/documentation" ] @@ -2457,8 +2432,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/fast-fifo": { "version": "1.3.2", @@ -2981,15 +2955,13 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lodash.snakecase": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/lowercase-keys": { "version": "3.0.0", @@ -3018,8 +2990,7 @@ "version": "1.12.1", "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.12.1.tgz", "integrity": "sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/make-fetch-happen": { "version": "15.0.3", @@ -3550,6 +3521,7 @@ "integrity": "sha512-fGYb7z/cljC0Rjtbxh7mIe8vtF/M9TShLvniwc2rdcqNG3Z9g3nM01cr2kWRb1DZdbY4/kItvIsrV4uhaMifyQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "tsgolint": "bin/tsgolint.js" }, @@ -3672,6 +3644,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4260,15 +4233,13 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tuf-js": { "version": "4.1.0", @@ -4328,7 +4299,6 @@ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.17" } @@ -4451,7 +4421,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, diff --git a/src/classes/base/BaseClient.ts b/src/classes/base/BaseClient.ts deleted file mode 100644 index fb93305..0000000 --- a/src/classes/base/BaseClient.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Client, ClientOptions } from "discord.js"; -import { LoggerInstance, LogLevel } from "./Logger"; - -export interface BaseOptions extends ClientOptions { - logLevel?: LogLevel; -} - -export default class BaseClient extends Client { - readonly logger: LoggerInstance; - - constructor(opts: BaseOptions) { - super(opts); - this.logger = new LoggerInstance(opts.logLevel ?? "log"); - } -} diff --git a/src/index.ts b/src/index.ts index 6097fa3..b218a95 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,7 @@ +export * from "./structures/Client"; +export * from "./structures/Command"; +export * from "./structures/Context"; +export * from "./structures/Event"; +export * from "./structures/Argument"; +export * from "./utils/Logger"; export const version = "[VI]{{version}}[/VI]"; diff --git a/src/structures/Argument.ts b/src/structures/Argument.ts new file mode 100644 index 0000000..991aa20 --- /dev/null +++ b/src/structures/Argument.ts @@ -0,0 +1,36 @@ +import { + ApplicationCommandOptionData, + ApplicationCommandOptionType, +} from "discord.js"; + +export class Argument { + public readonly name: string; + public readonly description: string; + public readonly type: ApplicationCommandOptionType; + public readonly required: boolean; + public readonly choices?: { name: string; value: string | number }[]; + + constructor(data: { + name: string; + description: string; + type: ApplicationCommandOptionType; + required?: boolean; + choices?: { name: string; value: string | number }[]; + }) { + this.name = data.name; + this.description = data.description; + this.type = data.type; + this.required = data.required ?? false; + this.choices = data.choices; + } + + public toJSON(): ApplicationCommandOptionData { + return { + name: this.name, + description: this.description, + type: this.type, + required: this.required, + choices: this.choices, + } as ApplicationCommandOptionData; + } +} diff --git a/src/structures/Client.ts b/src/structures/Client.ts new file mode 100644 index 0000000..5c024ca --- /dev/null +++ b/src/structures/Client.ts @@ -0,0 +1,191 @@ +import { + Client as DiscordClient, + ClientOptions, + Collection, + Interaction, + Message, + REST, + Routes, +} from "discord.js"; +import { LoggerInstance, LogLevel } from "../utils/Logger"; +import { Command } from "./Command"; +import { Context } from "./Context"; +import { Event } from "./Event"; +import fs from "fs"; +import path from "path"; + +export interface ClientOptionsWithFramework extends ClientOptions { + logLevel?: LogLevel; + prefix?: string; + token?: string; +} + +export class Client extends DiscordClient { + public readonly logger: LoggerInstance; + public readonly commands: Collection; + public readonly aliases: Collection; + public readonly prefix: string; + + constructor(opts: ClientOptionsWithFramework) { + super(opts); + this.logger = new LoggerInstance(opts.logLevel ?? "log"); + this.commands = new Collection(); + this.aliases = new Collection(); + this.prefix = opts.prefix ?? "!"; + + if (opts.token) this.token = opts.token; + + this.on("messageCreate", this.handleMessage.bind(this)); + this.on("interactionCreate", this.handleInteraction.bind(this)); + + // Auto-load events + this.loadEvents(path.join(__dirname, "..", "events")).catch((error) => + this.logger.error("Error loading events:", error) + ); + } + + public async loadCommands(dir: string) { + const files = this.getFiles(dir); + for (const file of files) { + try { + delete require.cache[require.resolve(file)]; + const { default: CommandClass } = await require(file); + + if (!CommandClass || !(CommandClass.prototype instanceof Command)) { + continue; // Skip non-command files + } + + const command: Command = new CommandClass(this); + this.commands.set(command.name, command); + + for (const alias of command.aliases) { + this.aliases.set(alias, command.name); + } + + this.logger.debug(`Loaded command: ${command.name}`); + } catch (error) { + this.logger.error(`Error loading command ${file}:`, error); + } + } + this.logger.log(`Loaded ${this.commands.size} commands.`); + } + + public async loadEvents(dir: string) { + const files = this.getFiles(dir); + for (const file of files) { + try { + delete require.cache[require.resolve(file)]; + const { default: EventClass } = await require(file); + + if (!EventClass || !(EventClass.prototype instanceof Event)) { + continue; + } + + const event: Event = new EventClass(this); + if (event.once) { + this.once(event.name, (...args) => event.execute(...args)); + } else { + this.on(event.name, (...args) => event.execute(...args)); + } + + this.logger.debug(`Loaded event: ${event.name}`); + } catch (error) { + this.logger.error(`Error loading event ${file}:`, error); + } + } + } + + private getFiles(dir: string, fileList: string[] = []): string[] { + if (!fs.existsSync(dir)) return []; + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + if (fs.statSync(filePath).isDirectory()) { + this.getFiles(filePath, fileList); + } else if (file.endsWith(".ts") || file.endsWith(".js")) { + fileList.push(filePath); + } + } + return fileList; + } + + public async registerCommands() { + if (!this.token || !this.application) return; + + const slashCommands = this.commands + .filter((cmd) => cmd.supportsSlash) + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + options: cmd.options, + })); + + const rest = new REST({ version: "10" }).setToken(this.token); + + try { + this.logger.log( + `Started refreshing ${slashCommands.length} application (/) commands.` + ); + await rest.put(Routes.applicationCommands(this.application.id), { + body: slashCommands, + }); + this.logger.log(`Successfully reloaded application (/) commands.`); + } catch (error) { + this.logger.error("Failed to register commands:", error); + } + } + + private async handleMessage(message: Message) { + if (message.author.bot) return; + if (!message.content.startsWith(this.prefix)) return; + + const args = message.content.slice(this.prefix.length).trim().split(/ +/); + const commandName = args.shift()?.toLowerCase(); + + if (!commandName) return; + + const name = this.aliases.get(commandName) || commandName; + const command = this.commands.get(name); + + if (!command || !command.supportsPrefix) return; + + try { + const context = new Context(this, { message, args }); + await command.execute(context); + } catch (error) { + this.logger.error(`Error executing command ${command.name}:`, error); + await message.reply("There was an error trying to execute that command!"); + } + } + + private async handleInteraction(interaction: Interaction) { + if (!interaction.isCommand()) return; + + const command = this.commands.get(interaction.commandName); + if (!command || !command.supportsSlash) { + await interaction.reply({ + content: "Command not found or disabled.", + ephemeral: true, + }); + return; + } + + try { + const context = new Context(this, { interaction }); + await command.execute(context); + } catch (error) { + this.logger.error(`Error executing command ${command.name}:`, error); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } else { + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } + } + } +} diff --git a/src/structures/Command.ts b/src/structures/Command.ts new file mode 100644 index 0000000..71f0b78 --- /dev/null +++ b/src/structures/Command.ts @@ -0,0 +1,43 @@ +import { ApplicationCommandOptionData } from "discord.js"; +import { Context } from "./Context"; +import { Client } from "./Client"; +import { Argument } from "./Argument"; + +export interface CommandOptions { + name: string; + description: string; + aliases?: string[]; + options?: (ApplicationCommandOptionData | Argument)[]; + slash?: boolean; // Default true + prefix?: boolean; // Default true (if user enabled prefix generally) +} + +export abstract class Command { + public readonly client: Client; + public readonly name: string; + public readonly description: string; + public readonly aliases: string[]; + public readonly options: ApplicationCommandOptionData[]; + public readonly supportsSlash: boolean; + public readonly supportsPrefix: boolean; + + constructor(client: Client, options: CommandOptions) { + this.client = client; + this.name = options.name; + this.description = options.description; + this.aliases = options.aliases ?? []; + + // Handle Argument instances or raw data + this.options = (options.options ?? []).map((opt) => { + if (opt instanceof Argument) { + return opt.toJSON(); + } + return opt; + }); + + this.supportsSlash = options.slash ?? true; + this.supportsPrefix = options.prefix ?? true; + } + + public abstract execute(ctx: Context): Promise; +} diff --git a/src/structures/Context.ts b/src/structures/Context.ts new file mode 100644 index 0000000..32aa972 --- /dev/null +++ b/src/structures/Context.ts @@ -0,0 +1,64 @@ +import { + Message, + Guild, + User, + TextBasedChannel, + InteractionReplyOptions, + MessageReplyOptions, + CommandInteraction, +} from "discord.js"; +import { Client } from "./Client"; + +export type ReplyOptions = + | string + | MessageReplyOptions + | InteractionReplyOptions; + +export class Context { + public readonly client: Client; + public readonly interaction?: CommandInteraction; + public readonly message?: Message; + public readonly args: string[] = []; + + constructor( + client: Client, + data: { + interaction?: CommandInteraction; + message?: Message; + args?: string[]; + } + ) { + this.client = client; + this.interaction = data.interaction; + this.message = data.message; + if (data.args) this.args = data.args; + } + + public get author(): User { + return this.interaction?.user ?? this.message!.author; + } + + public get guild(): Guild | null { + return this.interaction?.guild ?? this.message!.guild; + } + + public get channel(): TextBasedChannel | null { + return (this.interaction?.channel ?? + this.message?.channel) as TextBasedChannel; + } + + public async reply(options: ReplyOptions): Promise { + if (this.interaction) { + if (this.interaction.replied || this.interaction.deferred) { + return this.interaction.followUp(options as InteractionReplyOptions); + } + return this.interaction.reply(options as InteractionReplyOptions); + } + return this.message!.reply(options as MessageReplyOptions); + } + + public async send(options: ReplyOptions): Promise { + const channel = this.channel as any; + return channel?.send(options); + } +} diff --git a/src/structures/Event.ts b/src/structures/Event.ts new file mode 100644 index 0000000..8c7aeef --- /dev/null +++ b/src/structures/Event.ts @@ -0,0 +1,16 @@ +import { ClientEvents } from "discord.js"; +import { Client } from "./Client"; + +export abstract class Event { + public readonly client: Client; + public readonly name: K; + public readonly once: boolean; + + constructor(client: Client, name: K, once: boolean = false) { + this.client = client; + this.name = name; + this.once = once; + } + + public abstract execute(...args: ClientEvents[K]): Promise | void; +} diff --git a/src/classes/base/Logger.ts b/src/utils/Logger.ts similarity index 100% rename from src/classes/base/Logger.ts rename to src/utils/Logger.ts From 5abd62eb59bf2a2330762f8bd8e012189a2074d9 Mon Sep 17 00:00:00 2001 From: mustafakyia Date: Fri, 16 Jan 2026 23:44:45 +0300 Subject: [PATCH 2/2] add: handlers and types --- src/handlers/CommandHandler.ts | 143 +++++++++++++++++++++++++ src/handlers/EventHandler.ts | 51 +++++++++ src/index.ts | 3 + src/structures/Client.ts | 185 +++------------------------------ src/structures/Command.ts | 10 +- src/types/index.ts | 18 ++++ 6 files changed, 233 insertions(+), 177 deletions(-) create mode 100644 src/handlers/CommandHandler.ts create mode 100644 src/handlers/EventHandler.ts create mode 100644 src/types/index.ts diff --git a/src/handlers/CommandHandler.ts b/src/handlers/CommandHandler.ts new file mode 100644 index 0000000..4a2c640 --- /dev/null +++ b/src/handlers/CommandHandler.ts @@ -0,0 +1,143 @@ +import { Client } from "../structures/Client"; +import { Command } from "../structures/Command"; +import { Context } from "../structures/Context"; +import { Interaction, Message, REST, Routes } from "discord.js"; +import fs from "fs"; +import path from "path"; + +export class CommandHandler { + public readonly client: Client; + + constructor(client: Client) { + this.client = client; + } + + public async loadCommands(dir: string) { + const files = this.getFiles(dir); + for (const file of files) { + try { + delete require.cache[require.resolve(file)]; + const { default: CommandClass } = await require(file); + + if (!CommandClass || !(CommandClass.prototype instanceof Command)) { + continue; + } + + const command: Command = new CommandClass(this.client); + this.client.commands.set(command.name, command); + + for (const alias of command.aliases) { + this.client.aliases.set(alias, command.name); + } + + this.client.logger.debug(`Loaded command: ${command.name}`); + } catch (error) { + this.client.logger.error(`Error loading command ${file}:`, error); + } + } + this.client.logger.log(`Loaded ${this.client.commands.size} commands.`); + } + + public async registerCommands() { + if (!this.client.token || !this.client.application) return; + + const slashCommands = this.client.commands + .filter((cmd) => cmd.supportsSlash) + .map((cmd) => ({ + name: cmd.name, + description: cmd.description, + options: cmd.options, + })); + + const rest = new REST({ version: "10" }).setToken(this.client.token); + + try { + this.client.logger.log( + `Started refreshing ${slashCommands.length} application (/) commands.` + ); + await rest.put(Routes.applicationCommands(this.client.application.id), { + body: slashCommands, + }); + this.client.logger.log(`Successfully reloaded application (/) commands.`); + } catch (error) { + this.client.logger.error("Failed to register commands:", error); + } + } + + public async handleMessage(message: Message) { + if (message.author.bot) return; + if (!message.content.startsWith(this.client.prefix)) return; + + const args = message.content + .slice(this.client.prefix.length) + .trim() + .split(/ +/); + const commandName = args.shift()?.toLowerCase(); + + if (!commandName) return; + + const name = this.client.aliases.get(commandName) || commandName; + const command = this.client.commands.get(name); + + if (!command || !command.supportsPrefix) return; + + try { + const context = new Context(this.client, { message, args }); + await command.execute(context); + } catch (error) { + this.client.logger.error( + `Error executing command ${command.name}:`, + error + ); + await message.reply("There was an error trying to execute that command!"); + } + } + + public async handleInteraction(interaction: Interaction) { + if (!interaction.isCommand()) return; + + const command = this.client.commands.get(interaction.commandName); + if (!command || !command.supportsSlash) { + await interaction.reply({ + content: "Command not found or disabled.", + ephemeral: true, + }); + return; + } + + try { + const context = new Context(this.client, { interaction }); + await command.execute(context); + } catch (error) { + this.client.logger.error( + `Error executing command ${command.name}:`, + error + ); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } else { + await interaction.reply({ + content: "There was an error while executing this command!", + ephemeral: true, + }); + } + } + } + + private getFiles(dir: string, fileList: string[] = []): string[] { + if (!fs.existsSync(dir)) return []; + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + if (fs.statSync(filePath).isDirectory()) { + this.getFiles(filePath, fileList); + } else if (file.endsWith(".ts") || file.endsWith(".js")) { + fileList.push(filePath); + } + } + return fileList; + } +} diff --git a/src/handlers/EventHandler.ts b/src/handlers/EventHandler.ts new file mode 100644 index 0000000..0b8ac19 --- /dev/null +++ b/src/handlers/EventHandler.ts @@ -0,0 +1,51 @@ +import { Client } from "../structures/Client"; +import { Event } from "../structures/Event"; +import fs from "fs"; +import path from "path"; + +export class EventHandler { + public readonly client: Client; + + constructor(client: Client) { + this.client = client; + } + + public async loadEvents(dir: string) { + const files = this.getFiles(dir); + for (const file of files) { + try { + delete require.cache[require.resolve(file)]; + const { default: EventClass } = await require(file); + + if (!EventClass || !(EventClass.prototype instanceof Event)) { + continue; + } + + const event: Event = new EventClass(this.client); + if (event.once) { + this.client.once(event.name, (...args) => event.execute(...args)); + } else { + this.client.on(event.name, (...args) => event.execute(...args)); + } + + this.client.logger.debug(`Loaded event: ${event.name}`); + } catch (error) { + this.client.logger.error(`Error loading event ${file}:`, error); + } + } + } + + private getFiles(dir: string, fileList: string[] = []): string[] { + if (!fs.existsSync(dir)) return []; + const files = fs.readdirSync(dir); + for (const file of files) { + const filePath = path.join(dir, file); + if (fs.statSync(filePath).isDirectory()) { + this.getFiles(filePath, fileList); + } else if (file.endsWith(".ts") || file.endsWith(".js")) { + fileList.push(filePath); + } + } + return fileList; + } +} diff --git a/src/index.ts b/src/index.ts index b218a95..f036c9e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,5 +3,8 @@ export * from "./structures/Command"; export * from "./structures/Context"; export * from "./structures/Event"; export * from "./structures/Argument"; +export * from "./handlers/CommandHandler"; +export * from "./handlers/EventHandler"; export * from "./utils/Logger"; +export * from "./types"; export const version = "[VI]{{version}}[/VI]"; diff --git a/src/structures/Client.ts b/src/structures/Client.ts index 5c024ca..20deeb6 100644 --- a/src/structures/Client.ts +++ b/src/structures/Client.ts @@ -1,31 +1,20 @@ -import { - Client as DiscordClient, - ClientOptions, - Collection, - Interaction, - Message, - REST, - Routes, -} from "discord.js"; -import { LoggerInstance, LogLevel } from "../utils/Logger"; +import { Client as DiscordClient, Collection } from "discord.js"; +import { LoggerInstance } from "../utils/Logger"; import { Command } from "./Command"; -import { Context } from "./Context"; -import { Event } from "./Event"; -import fs from "fs"; +import { CommandHandler } from "../handlers/CommandHandler"; +import { EventHandler } from "../handlers/EventHandler"; +import { ClientOptionsWithFramework } from "../types"; import path from "path"; -export interface ClientOptionsWithFramework extends ClientOptions { - logLevel?: LogLevel; - prefix?: string; - token?: string; -} - export class Client extends DiscordClient { public readonly logger: LoggerInstance; public readonly commands: Collection; public readonly aliases: Collection; public readonly prefix: string; + public readonly commandHandler: CommandHandler; + public readonly eventHandler: EventHandler; + constructor(opts: ClientOptionsWithFramework) { super(opts); this.logger = new LoggerInstance(opts.logLevel ?? "log"); @@ -35,157 +24,17 @@ export class Client extends DiscordClient { if (opts.token) this.token = opts.token; - this.on("messageCreate", this.handleMessage.bind(this)); - this.on("interactionCreate", this.handleInteraction.bind(this)); + this.commandHandler = new CommandHandler(this); + this.eventHandler = new EventHandler(this); - // Auto-load events - this.loadEvents(path.join(__dirname, "..", "events")).catch((error) => - this.logger.error("Error loading events:", error) + this.on("messageCreate", (msg) => this.commandHandler.handleMessage(msg)); + this.on("interactionCreate", (int) => + this.commandHandler.handleInteraction(int) ); - } - - public async loadCommands(dir: string) { - const files = this.getFiles(dir); - for (const file of files) { - try { - delete require.cache[require.resolve(file)]; - const { default: CommandClass } = await require(file); - - if (!CommandClass || !(CommandClass.prototype instanceof Command)) { - continue; // Skip non-command files - } - - const command: Command = new CommandClass(this); - this.commands.set(command.name, command); - - for (const alias of command.aliases) { - this.aliases.set(alias, command.name); - } - this.logger.debug(`Loaded command: ${command.name}`); - } catch (error) { - this.logger.error(`Error loading command ${file}:`, error); - } - } - this.logger.log(`Loaded ${this.commands.size} commands.`); - } - - public async loadEvents(dir: string) { - const files = this.getFiles(dir); - for (const file of files) { - try { - delete require.cache[require.resolve(file)]; - const { default: EventClass } = await require(file); - - if (!EventClass || !(EventClass.prototype instanceof Event)) { - continue; - } - - const event: Event = new EventClass(this); - if (event.once) { - this.once(event.name, (...args) => event.execute(...args)); - } else { - this.on(event.name, (...args) => event.execute(...args)); - } - - this.logger.debug(`Loaded event: ${event.name}`); - } catch (error) { - this.logger.error(`Error loading event ${file}:`, error); - } - } - } - - private getFiles(dir: string, fileList: string[] = []): string[] { - if (!fs.existsSync(dir)) return []; - const files = fs.readdirSync(dir); - for (const file of files) { - const filePath = path.join(dir, file); - if (fs.statSync(filePath).isDirectory()) { - this.getFiles(filePath, fileList); - } else if (file.endsWith(".ts") || file.endsWith(".js")) { - fileList.push(filePath); - } - } - return fileList; - } - - public async registerCommands() { - if (!this.token || !this.application) return; - - const slashCommands = this.commands - .filter((cmd) => cmd.supportsSlash) - .map((cmd) => ({ - name: cmd.name, - description: cmd.description, - options: cmd.options, - })); - - const rest = new REST({ version: "10" }).setToken(this.token); - - try { - this.logger.log( - `Started refreshing ${slashCommands.length} application (/) commands.` - ); - await rest.put(Routes.applicationCommands(this.application.id), { - body: slashCommands, - }); - this.logger.log(`Successfully reloaded application (/) commands.`); - } catch (error) { - this.logger.error("Failed to register commands:", error); - } - } - - private async handleMessage(message: Message) { - if (message.author.bot) return; - if (!message.content.startsWith(this.prefix)) return; - - const args = message.content.slice(this.prefix.length).trim().split(/ +/); - const commandName = args.shift()?.toLowerCase(); - - if (!commandName) return; - - const name = this.aliases.get(commandName) || commandName; - const command = this.commands.get(name); - - if (!command || !command.supportsPrefix) return; - - try { - const context = new Context(this, { message, args }); - await command.execute(context); - } catch (error) { - this.logger.error(`Error executing command ${command.name}:`, error); - await message.reply("There was an error trying to execute that command!"); - } - } - - private async handleInteraction(interaction: Interaction) { - if (!interaction.isCommand()) return; - - const command = this.commands.get(interaction.commandName); - if (!command || !command.supportsSlash) { - await interaction.reply({ - content: "Command not found or disabled.", - ephemeral: true, - }); - return; - } - - try { - const context = new Context(this, { interaction }); - await command.execute(context); - } catch (error) { - this.logger.error(`Error executing command ${command.name}:`, error); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: "There was an error while executing this command!", - ephemeral: true, - }); - } else { - await interaction.reply({ - content: "There was an error while executing this command!", - ephemeral: true, - }); - } - } + // Auto-load events + this.eventHandler + .loadEvents(path.join(__dirname, "..", "events")) + .catch((error) => this.logger.error("Error loading events:", error)); } } diff --git a/src/structures/Command.ts b/src/structures/Command.ts index 71f0b78..10219f8 100644 --- a/src/structures/Command.ts +++ b/src/structures/Command.ts @@ -2,15 +2,7 @@ import { ApplicationCommandOptionData } from "discord.js"; import { Context } from "./Context"; import { Client } from "./Client"; import { Argument } from "./Argument"; - -export interface CommandOptions { - name: string; - description: string; - aliases?: string[]; - options?: (ApplicationCommandOptionData | Argument)[]; - slash?: boolean; // Default true - prefix?: boolean; // Default true (if user enabled prefix generally) -} +import { CommandOptions } from "../types"; export abstract class Command { public readonly client: Client; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..a1d9cf3 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,18 @@ +import { ApplicationCommandOptionData, ClientOptions } from "discord.js"; +import { LogLevel } from "../utils/Logger"; +import { Argument } from "../structures/Argument"; + +export interface ClientOptionsWithFramework extends ClientOptions { + logLevel?: LogLevel; + prefix?: string; + token?: string; +} + +export interface CommandOptions { + name: string; + description: string; + aliases?: string[]; + options?: (ApplicationCommandOptionData | Argument)[]; + slash?: boolean; + prefix?: boolean; +}