From 1eace32aa11e4c7732c4772c677dabd6aa04b863 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Sun, 14 Dec 2025 22:21:28 +0100 Subject: [PATCH] feat: Implement dynamic event loading and refactor event handlers into dedicated files. --- src/commands/economy/sell.ts | 9 ++-- src/events/guildMemberAdd.ts | 13 ++++++ src/events/interactionCreate.ts | 53 +++++++++++++++++++++++ src/events/messageCreate.ts | 19 +++++++++ src/events/ready.ts | 14 +++++++ src/index.ts | 74 +-------------------------------- src/lib/KyokoClient.ts | 54 +++++++++++++++++++++++- src/lib/types.ts | 8 +++- 8 files changed, 166 insertions(+), 78 deletions(-) create mode 100644 src/events/guildMemberAdd.ts create mode 100644 src/events/interactionCreate.ts create mode 100644 src/events/messageCreate.ts create mode 100644 src/events/ready.ts diff --git a/src/commands/economy/sell.ts b/src/commands/economy/sell.ts index c14bfa1..da717d5 100644 --- a/src/commands/economy/sell.ts +++ b/src/commands/economy/sell.ts @@ -8,7 +8,8 @@ import { ComponentType, type BaseGuildTextChannel, type ButtonInteraction, - PermissionFlagsBits + PermissionFlagsBits, + MessageFlags } from "discord.js"; import { userService } from "@/modules/user/user.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; @@ -31,7 +32,7 @@ export const sell = createCommand({ ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const itemId = interaction.options.getNumber("itemid", true); const targetChannel = (interaction.options.getChannel("channel") as BaseGuildTextChannel) || interaction.channel as BaseGuildTextChannel; @@ -90,7 +91,7 @@ export const sell = createCommand({ async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof items.$inferSelect) { try { - await interaction.deferReply({ ephemeral: true }); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); const userId = interaction.user.id; const user = await userService.getUserById(userId); @@ -118,7 +119,7 @@ async function handleBuyInteraction(interaction: ButtonInteraction, item: typeof if (interaction.deferred || interaction.replied) { await interaction.editReply({ content: "", embeds: [createErrorEmbed("An error occurred while processing your purchase.")] }); } else { - await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], ephemeral: true }); + await interaction.reply({ embeds: [createErrorEmbed("An error occurred while processing your purchase.")], flags: MessageFlags.Ephemeral }); } } } diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts new file mode 100644 index 0000000..4f0d890 --- /dev/null +++ b/src/events/guildMemberAdd.ts @@ -0,0 +1,13 @@ +import { Events } from "discord.js"; +import type { Event } from "@lib/types"; + +const event: Event = { + name: Events.GuildMemberAdd, + execute: async (member) => { + const role = member.guild.roles.cache.find(role => role.name === "Visitor"); + if (!role) return; + await member.roles.add(role); + }, +}; + +export default event; diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..5fe9c83 --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,53 @@ +import { Events, MessageFlags } from "discord.js"; +import { KyokoClient } from "@lib/KyokoClient"; +import { userService } from "@/modules/user/user.service"; +import { createErrorEmbed } from "@lib/embeds"; +import type { Event } from "@lib/types"; + +const event: Event = { + name: Events.InteractionCreate, + execute: async (interaction) => { + // Handle Trade Interactions + if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { + if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") { + await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction)); + return; + } + } + + if (!interaction.isChatInputCommand()) return; + + const command = KyokoClient.commands.get(interaction.commandName); + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + + // Ensure user exists in database + try { + const user = await userService.getUserById(interaction.user.id); + if (!user) { + console.log(`🆕 Creating new user entry for ${interaction.user.tag}`); + await userService.createUser(interaction.user.id, interaction.user.username); + } + } catch (error) { + console.error("Failed to check/create user:", error); + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + const errorEmbed = createErrorEmbed('There was an error while executing this command!'); + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral }); + } else { + await interaction.reply({ embeds: [errorEmbed], flags: MessageFlags.Ephemeral }); + } + } + }, +}; + +export default event; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..2988879 --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,19 @@ +import { Events } from "discord.js"; +import { userService } from "@/modules/user/user.service"; +import { levelingService } from "@/modules/leveling/leveling.service"; +import type { Event } from "@lib/types"; + +const event: Event = { + name: Events.MessageCreate, + execute: async (message) => { + if (message.author.bot) return; + if (!message.guild) return; + + const user = await userService.getUserById(message.author.id); + if (!user) return; + + levelingService.processChatXp(message.author.id); + }, +}; + +export default event; diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..ba1b9ec --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,14 @@ +import { Events } from "discord.js"; +import { schedulerService } from "@/modules/system/scheduler"; +import type { Event } from "@lib/types"; + +const event: Event = { + name: Events.ClientReady, + once: true, + execute: async (c) => { + console.log(`Ready! Logged in as ${c.user.tag}`); + schedulerService.start(); + }, +}; + +export default event; diff --git a/src/index.ts b/src/index.ts index ed4c472..0f75753 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,81 +1,11 @@ -import { Events } from "discord.js"; import { KyokoClient } from "@lib/KyokoClient"; import { env } from "@lib/env"; -import { userService } from "@/modules/user/user.service"; -import { levelingService } from "@/modules/leveling/leveling.service"; -import { createErrorEmbed } from "@lib/embeds"; -// Load commands +// Load commands & events await KyokoClient.loadCommands(); +await KyokoClient.loadEvents(); await KyokoClient.deployCommands(); -import { schedulerService } from "@/modules/system/scheduler"; - -KyokoClient.once(Events.ClientReady, async c => { - console.log(`Ready! Logged in as ${c.user.tag}`); - schedulerService.start(); -}); -// give visitor role to new users -KyokoClient.on(Events.GuildMemberAdd, async member => { - const role = member.guild.roles.cache.find(role => role.name === "Visitor"); - if (!role) return; - await member.roles.add(role); -}); -// process xp on message -KyokoClient.on(Events.MessageCreate, async message => { - if (message.author.bot) return; - if (!message.guild) return; - - const user = await userService.getUserById(message.author.id); - if (!user) return; - - levelingService.processChatXp(message.author.id); -}); - -// handle commands -KyokoClient.on(Events.InteractionCreate, async interaction => { - // Handle Trade Interactions - if (interaction.isButton() || interaction.isStringSelectMenu() || interaction.isModalSubmit()) { - if (interaction.customId.startsWith("trade_") || interaction.customId === "amount") { - await import("@/modules/trade/trade.interaction").then(m => m.handleTradeInteraction(interaction)); - return; - } - } - - if (!interaction.isChatInputCommand()) return; - - const command = KyokoClient.commands.get(interaction.commandName); - - if (!command) { - console.error(`No command matching ${interaction.commandName} was found.`); - return; - } - - - // Ensure user exists in database - try { - const user = await userService.getUserById(interaction.user.id); - if (!user) { - console.log(`🆕 Creating new user entry for ${interaction.user.tag}`); - await userService.createUser(interaction.user.id, interaction.user.username); - } - } catch (error) { - console.error("Failed to check/create user:", error); - } - - try { - await command.execute(interaction); - } catch (error) { - console.error(error); - const errorEmbed = createErrorEmbed('There was an error while executing this command!'); - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ embeds: [errorEmbed], ephemeral: true }); - } else { - await interaction.reply({ embeds: [errorEmbed], ephemeral: true }); - } - } -}); - // login with the token from .env if (!env.DISCORD_BOT_TOKEN) { diff --git a/src/lib/KyokoClient.ts b/src/lib/KyokoClient.ts index 5e25388..8a3e39c 100644 --- a/src/lib/KyokoClient.ts +++ b/src/lib/KyokoClient.ts @@ -1,7 +1,7 @@ import { Client as DiscordClient, Collection, GatewayIntentBits, REST, Routes } from "discord.js"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; -import type { Command } from "@lib/types"; +import type { Command, Event } from "@lib/types"; import { env } from "@lib/env"; class Client extends DiscordClient { @@ -23,6 +23,16 @@ class Client extends DiscordClient { await this.readCommandsRecursively(commandsPath, reload); } + async loadEvents(reload: boolean = false) { + if (reload) { + this.removeAllListeners(); + console.log("♻️ Reloading events..."); + } + + const eventsPath = join(import.meta.dir, '../events'); + await this.readEventsRecursively(eventsPath, reload); + } + private async readCommandsRecursively(dir: string, reload: boolean = false) { try { const files = await readdir(dir, { withFileTypes: true }); @@ -63,10 +73,52 @@ class Client extends DiscordClient { } } + private async readEventsRecursively(dir: string, reload: boolean = false) { + try { + const files = await readdir(dir, { withFileTypes: true }); + + for (const file of files) { + const filePath = join(dir, file.name); + + if (file.isDirectory()) { + await this.readEventsRecursively(filePath, reload); + continue; + } + + if (!file.name.endsWith('.ts') && !file.name.endsWith('.js')) continue; + + try { + const importPath = reload ? `${filePath}?t=${Date.now()}` : filePath; + const eventModule = await import(importPath); + const event = eventModule.default; + + if (this.isValidEvent(event)) { + if (event.once) { + this.once(event.name, (...args) => event.execute(...args)); + } else { + this.on(event.name, (...args) => event.execute(...args)); + } + console.log(`✅ Loaded event: ${event.name}`); + } else { + console.warn(`⚠️ Skipping invalid event in ${file.name}`); + } + } catch (error) { + console.error(`❌ Failed to load event from ${filePath}:`, error); + } + } + } catch (error) { + console.error(`Error reading directory ${dir}:`, error); + } + } + private isValidCommand(command: any): command is Command { return command && typeof command === 'object' && 'data' in command && 'execute' in command; } + private isValidEvent(event: any): event is Event { + return event && typeof event === 'object' && 'name' in event && 'execute' in event; + } + async deployCommands() { // We use env.DISCORD_BOT_TOKEN directly so this can run without client.login() const token = env.DISCORD_BOT_TOKEN; diff --git a/src/lib/types.ts b/src/lib/types.ts index 50537b4..3d1d082 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,12 @@ -import type { ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; +import type { ChatInputCommandInteraction, ClientEvents, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, SlashCommandSubcommandsOnlyBuilder } from "discord.js"; export interface Command { data: SlashCommandBuilder | SlashCommandOptionsOnlyBuilder | SlashCommandSubcommandsOnlyBuilder; execute: (interaction: ChatInputCommandInteraction) => Promise | void; } + +export interface Event { + name: K; + once?: boolean; + execute: (...args: ClientEvents[K]) => Promise | void; +}