From cfe3348f1957ed6b2a08835abd7bc2cf46b70b05 Mon Sep 17 00:00:00 2001 From: breadddevv Date: Sun, 7 Jun 2026 21:24:52 +0100 Subject: [PATCH] Revamped the codebase (some bits) --- src/base/classes/command.ts | 118 +++++++++++------ .../{example_all.ts => example_all.test.ts} | 4 - src/commands/info.ts | 1 - src/commands/role.ts | 1 - src/commands/settings.ts | 0 src/commands/setup.ts | 10 +- src/events/clientReady.ts | 2 +- src/events/guildMemberUpdate.ts | 93 ++++++++----- src/events/roleCleanup.ts | 1 - src/libs/colors.ts | 3 + src/libs/loadCommands.ts | 3 +- src/services/roleService.ts | 123 ++++++++++++++---- 12 files changed, 248 insertions(+), 111 deletions(-) rename src/commands/{example_all.ts => example_all.test.ts} (97%) create mode 100644 src/commands/settings.ts create mode 100644 src/libs/colors.ts diff --git a/src/base/classes/command.ts b/src/base/classes/command.ts index a95e6f3..1e45e43 100644 --- a/src/base/classes/command.ts +++ b/src/base/classes/command.ts @@ -1,5 +1,7 @@ import { ChatInputCommandInteraction, + Colors, + ContainerBuilder, MessageFlags, PermissionFlagsBits, SlashCommandBuilder, @@ -32,7 +34,7 @@ export class Command { requiredPermissions: (keyof typeof PermissionFlagsBits)[]; cooldown: number; masterLock: boolean - private _cooldowns: Map; + private readonly _cooldowns: Map; constructor({ info, execute, guildOnly = false, ownerOnly = false, requiredPermissions = [], cooldown = 0, masterLock = false }: CommandOptions) { this.data = info; @@ -50,50 +52,12 @@ export class Command { } async run(interaction: ChatInputCommandInteraction, ownerId?: string): Promise { - if (this.guildOnly && !interaction.guild) { - await interaction.reply({ content: 'This command is limited to servers', flags: MessageFlags.Ephemeral }); - return; - } - - if (this.ownerOnly && interaction.user.id !== ownerId) { - await interaction.reply({ content: 'Only the owner can run this command', flags: MessageFlags.Ephemeral }); + if (!await this.validateCommand(interaction, ownerId)) { return; } - if (this.masterLock && interaction.guild) { - if (interaction.guild.id != process.env.MASTER_GUILD) { - await interaction.reply('This command cannot be ran in this server.') - } - } - - if (this.requiredPermissions.length && interaction.guild) { - const missing = this.requiredPermissions.filter( - p => !interaction.memberPermissions?.has(PermissionFlagsBits[p]) - ); - if (missing.length) { - await interaction.reply({ - content: `You're lacking of the necessary permissions: \`${missing.join(', ')}\``, - flags: MessageFlags.Ephemeral, - }); - return; - } - } - - if (this.cooldown > 0) { - const now = Date.now(); - const expiry = this._cooldowns.get(interaction.user.id); - if (expiry && now < expiry) { - const unixExpiry = Math.floor(expiry / 1000); - await interaction.reply({ - content: `You can use \`/${this.name}\` again, please wait .`, - flags: MessageFlags.Ephemeral, - }); - return; - } - this._cooldowns.set(interaction.user.id, now + this.cooldown * 1000); - setTimeout(() => this._cooldowns.delete(interaction.user.id), this.cooldown * 1000); - } - + await this.applyCooldown(interaction); + try { await this.execute(interaction); } catch (err) { @@ -106,4 +70,74 @@ export class Command { } } } + + private async validateCommand(interaction: ChatInputCommandInteraction, ownerId?: string): Promise { + if (this.guildOnly && !interaction.guild) { + await interaction.reply({ content: 'This command is limited to servers', flags: MessageFlags.Ephemeral }); + return false; + } + + if (this.ownerOnly && interaction.user.id !== ownerId) { + const container = new ContainerBuilder().setAccentColor(Colors.Red) + .addTextDisplayComponents((txt) => txt.setContent([ + "## Owner-locked command", + "This command can only be used by the owner of this server." + ].join("\n"))) + await interaction.reply({ components: [container], flags: [MessageFlags.Ephemeral, MessageFlags.IsComponentsV2]}) + return false; + } + + if (this.masterLock && interaction.guild?.id !== process.env.MASTER_GUILD) { + await interaction.reply('This command cannot be ran in this server.'); + return false; + } + + if (!await this.validatePermissions(interaction)) { + return false; + } + + return true; + } + + private async validatePermissions(interaction: ChatInputCommandInteraction): Promise { + if (!this.requiredPermissions.length || !interaction.guild) { + return true; + } + + const missing = this.requiredPermissions.filter( + p => !interaction.memberPermissions?.has(PermissionFlagsBits[p]) + ); + + if (missing.length) { + await interaction.reply({ + content: `You're lacking of the necessary permissions: \`${missing.join(', ')}\``, + flags: MessageFlags.Ephemeral, + }); + return false; + } + + return true; + } + + private async applyCooldown(interaction: ChatInputCommandInteraction): Promise { + if (this.cooldown <= 0) { + return; + } + + const now = Date.now(); + const expiry = this._cooldowns.get(interaction.user.id); + + if (expiry && now < expiry) { + const unixExpiry = Math.floor(expiry / 1000); + await interaction.reply({ + content: `You can use \`/${this.name}\` again, please wait .`, + flags: MessageFlags.Ephemeral, + }); + return; + } + + this._cooldowns.set(interaction.user.id, now + this.cooldown * 1000); + setTimeout(() => this._cooldowns.delete(interaction.user.id), this.cooldown * 1000); + await this.execute(interaction); + } } \ No newline at end of file diff --git a/src/commands/example_all.ts b/src/commands/example_all.test.ts similarity index 97% rename from src/commands/example_all.ts rename to src/commands/example_all.test.ts index b16e62e..417355c 100644 --- a/src/commands/example_all.ts +++ b/src/commands/example_all.test.ts @@ -1,10 +1,6 @@ import { SlashCommandBuilder, - ChatInputCommandInteraction, EmbedBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, } from "discord.js"; import { Command } from "../base/classes/command.js"; diff --git a/src/commands/info.ts b/src/commands/info.ts index e860137..094d86b 100644 --- a/src/commands/info.ts +++ b/src/commands/info.ts @@ -1,6 +1,5 @@ import { SlashCommandBuilder, - ChatInputCommandInteraction, EmbedBuilder, ButtonBuilder, ButtonStyle, diff --git a/src/commands/role.ts b/src/commands/role.ts index 905d9d3..97d13fa 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -1,6 +1,5 @@ import { SlashCommandBuilder, - ChatInputCommandInteraction, ColorResolvable, ContainerBuilder, TextDisplayBuilder, diff --git a/src/commands/settings.ts b/src/commands/settings.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 2ac30c0..8a8450e 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -4,9 +4,12 @@ import { ChannelType, LabelBuilder, MessageFlags, + ContainerBuilder, + Colors, } from "discord.js"; import { Command } from "../base/classes/command.js"; import { prisma } from "../libs/database.js"; +import { SystemColors } from "../libs/colors.js"; export default new Command({ info: new SlashCommandBuilder() @@ -22,7 +25,12 @@ export default new Command({ }); if (serversetup) { - await interaction.reply({ content: "This server has been setted up.", flags: [MessageFlags.Ephemeral]}) + const container = new ContainerBuilder().setAccentColor(SystemColors.main) + .addTextDisplayComponents((txt) => txt.setContent([ + "## This server is already set up", + "This server was already configured, if you want to change a setting please run the `/settings` command." + ].join("\n"))) + await interaction.reply({ components: [container], flags: [MessageFlags.Ephemeral, MessageFlags.IsComponentsV2]}) return } diff --git a/src/events/clientReady.ts b/src/events/clientReady.ts index 0e12880..379b9a3 100644 --- a/src/events/clientReady.ts +++ b/src/events/clientReady.ts @@ -1,4 +1,4 @@ -import { ActivityType, Client, Events } from "discord.js"; +import { Client, Events } from "discord.js"; import { logger } from "../libs/logger.js"; export default { diff --git a/src/events/guildMemberUpdate.ts b/src/events/guildMemberUpdate.ts index 65b134f..9e35a0a 100644 --- a/src/events/guildMemberUpdate.ts +++ b/src/events/guildMemberUpdate.ts @@ -1,5 +1,11 @@ -import "../libs/loadVariables.js" -import { Client, EmbedBuilder, Events, GuildMember, TextChannel } from "discord.js"; +import "../libs/loadVariables.js"; +import { + Client, + EmbedBuilder, + Events, + GuildMember, + TextChannel, +} from "discord.js"; import { registerBoost, removeBoost, @@ -15,12 +21,19 @@ import { prisma } from "../libs/database.js"; export default { name: Events.GuildMemberUpdate, - async execute(_client: Client, oldMember: GuildMember, newMember: GuildMember) { + async execute( + _client: Client, + oldMember: GuildMember, + newMember: GuildMember, + ) { try { if (oldMember.partial) oldMember = await oldMember.fetch(); if (newMember.partial) newMember = await newMember.fetch(); } catch (error) { - logger.error(`Failed to fetch partials for user ${oldMember.user.username}`); + const username = + oldMember?.user?.username ?? oldMember?.id ?? "Unknown user"; + logger.error(`Failed to fetch partials for user ${username}`); + return; } const wasBoostingBefore = oldMember.premiumSince !== null; @@ -43,7 +56,9 @@ async function onBoostStart(member: GuildMember): Promise { const settings = await getGuildSettings(guild.id); if (!settings) { - logger.error(`No guild settings found for guild ${guild.id} — run /setup first.`); + logger.error( + `No guild settings found for guild ${guild.id} — run /setup first.`, + ); return; } @@ -58,43 +73,45 @@ async function onBoostStart(member: GuildMember): Promise { await clearPendingCustomRoleDeletion(record.id); await assignLevelRoles(member, record.boostCounts ?? 1); - const greetChannel = guild.channels.cache.get(settings.greetChannelId) as TextChannel | undefined; + const greetChannel = guild.channels.cache.get(settings.greetChannelId) as + | TextChannel + | undefined; if (greetChannel) { const embed = new EmbedBuilder() .setColor(0xf47fff) .setTitle("New Server Boost! 🎉") .setDescription(`${member} has boosted the server!`) .addFields( - { - name: "Total Boosts", - value: String(record.boostCounts ?? 1), - inline: true - }, - { name: "Member", - value: member.user.tag, - inline: true + { + name: "Total Boosts", + value: String(record.boostCounts ?? 1), + inline: true, }, + { name: "Member", value: member.user.tag, inline: true }, ) .setThumbnail(member.displayAvatarURL({ size: 512 })) + .setFooter({ text: "Thank youuu!"}) .setTimestamp(); await greetChannel.send({ embeds: [embed] }); } - const logChannel = guild.channels.cache.get(settings.logChannelId) as TextChannel | undefined; + const logChannel = guild.channels.cache.get(settings.logChannelId) as + | TextChannel + | undefined; if (logChannel) { const logEmbed = new EmbedBuilder() .setColor(0x57f287) .setTitle("Boost Started") .addFields( - { - name: "User", - value: `${member.user.tag} (${member.id})`, - inline: false + { + name: "User", + value: `${member.user.tag} (${member.id})`, + inline: false, }, - { - name: "Total Boost Count", - value: String(record.boostCounts ?? 1), - inline: true + { + name: "Total Boost Count", + value: String(record.boostCounts ?? 1), + inline: true, }, ) .setThumbnail(member.displayAvatarURL({ size: 512 })) @@ -103,7 +120,9 @@ async function onBoostStart(member: GuildMember): Promise { } try { - await member.send(`Thank you for boosting the server! You now have access to booster perks.`); + await member.send( + `Thank you for boosting the server! You now have access to booster perks.`, + ); } catch {} } @@ -112,7 +131,9 @@ async function onBoostEnd(member: GuildMember): Promise { const settings = await getGuildSettings(guild.id); if (!settings) { - logger.error(`No guild settings found for guild ${guild.id} — run /setup first.`); + logger.error( + `No guild settings found for guild ${guild.id} — run /setup first.`, + ); return; } @@ -122,25 +143,27 @@ async function onBoostEnd(member: GuildMember): Promise { await removeAllLevelRoles(member); await scheduleCustomRoleDeletionAfterGrace(member.id, guild.id); - const logChannel = guild.channels.cache.get(settings.logChannelId) as TextChannel | undefined; + const logChannel = guild.channels.cache.get(settings.logChannelId) as + | TextChannel + | undefined; if (logChannel) { const logEmbed = new EmbedBuilder() .setColor(0xed4245) .setTitle("Boost Ended") .addFields( - { - name: "User", - value: `${member.user.tag} (${member.id})`, - inline: false + { + name: "User", + value: `${member.user.tag} (${member.id})`, + inline: false, }, - { - name: "Historical Boost Count", - value: String(result.boostCounts ?? 0), - inline: true + { + name: "Historical Boost Count", + value: String(result.boostCounts ?? 0), + inline: true, }, ) .setThumbnail(member.displayAvatarURL({ size: 512 })) .setTimestamp(); await logChannel.send({ embeds: [logEmbed] }); } -} \ No newline at end of file +} diff --git a/src/events/roleCleanup.ts b/src/events/roleCleanup.ts index f9172b1..decddac 100644 --- a/src/events/roleCleanup.ts +++ b/src/events/roleCleanup.ts @@ -1,5 +1,4 @@ import { Client, Events } from "discord.js"; -import { logger } from "../libs/logger.js"; import { processDueCustomRoleDeletions } from "../services/customRoleCleanup.js"; const CUSTOM_ROLE_CLEANUP_MS = 60 * 60 * 1000; diff --git a/src/libs/colors.ts b/src/libs/colors.ts new file mode 100644 index 0000000..c607eee --- /dev/null +++ b/src/libs/colors.ts @@ -0,0 +1,3 @@ +export enum SystemColors { + main = 0xFF48B6 +} \ No newline at end of file diff --git a/src/libs/loadCommands.ts b/src/libs/loadCommands.ts index 4f6f16f..9d1a449 100644 --- a/src/libs/loadCommands.ts +++ b/src/libs/loadCommands.ts @@ -1,5 +1,4 @@ import { - Client, Collection, REST, Routes, @@ -36,7 +35,7 @@ export async function loadCommands(): Promise { const commandFiles = fs .readdirSync(commandsPath) - .filter((file) => file.endsWith(".ts") || file.endsWith(".js")); + .filter((file) => file.endsWith(".ts") || !file.endsWith("test.ts") ||file.endsWith(".js") || !file.endsWith("test.js")); if (commandFiles.length === 0) { logger.warn("No command files found — nothing to register."); diff --git a/src/services/roleService.ts b/src/services/roleService.ts index c722264..8040ef5 100644 --- a/src/services/roleService.ts +++ b/src/services/roleService.ts @@ -4,12 +4,14 @@ import { Role, ColorResolvable, resolveColor, + PermissionFlagsBits, } from "discord.js"; import { setCustomRole, getCustomRole, patchCustomRoleStoredName, } from "./boosterService.js"; +import { logger } from "../libs/logger.js"; export interface BoostLevelRole { minBoosts: number; @@ -39,14 +41,28 @@ export async function assignLevelRoles( for (const roleId of eligibleRoleIds) { if (!member.roles.cache.has(roleId)) { const role = member.guild.roles.cache.get(roleId); - if (role) await member.roles.add(role); + if (role) { + try { + await member.roles.add(role); + } catch (error) { + logger.error(`Failed to add level role ${role.name} (${roleId}) to ${member.user.tag}:`, error); + } + } else { + logger.warn(`Level role ${roleId} not found in guild ${member.guild.id}`); + } } } for (const roleId of ineligibleRoleIds) { if (member.roles.cache.has(roleId)) { const role = member.guild.roles.cache.get(roleId); - if (role) await member.roles.remove(role); + if (role) { + try { + await member.roles.remove(role); + } catch (error) { + logger.error(`Failed to remove level role ${role.name} (${roleId}) from ${member.user.tag}:`, error); + } + } } } } @@ -55,7 +71,13 @@ export async function removeAllLevelRoles(member: GuildMember): Promise { for (const lr of BOOST_LEVEL_ROLES) { if (member.roles.cache.has(lr.roleId)) { const role = member.guild.roles.cache.get(lr.roleId); - if (role) await member.roles.remove(role); + if (role) { + try { + await member.roles.remove(role); + } catch (error) { + logger.error(`Failed to remove level role ${role.name} (${lr.roleId}) from ${member.user.tag}:`, error); + } + } } } } @@ -66,30 +88,73 @@ export async function createCustomRole( name: string, color: ColorResolvable, gradientColor: ColorResolvable -): Promise { - const role = await guild.roles.create({ - name, - colors: { primaryColor: resolveColor(color), secondaryColor: resolveColor(gradientColor) }, - permissions: [], - }); +): Promise { + const botMember = guild.members.me; + if (!botMember?.permissions.has(PermissionFlagsBits.ManageRoles)) { + logger.error(`Bot lacks ManageRoles permission in guild ${guild.id}`); + await member.send("I don't have permission to manage roles. Please contact an admin.").catch(() => {}); + return null; + } + const botHighestRole = botMember.roles.highest; + const newRolePosition = botHighestRole.position - 1; + + if (newRolePosition < 0) { + logger.error(`Bot's highest role is too low to create new roles in guild ${guild.id}`); + await member.send("I cannot create roles because my highest role is too low. Please move my role higher in the role hierarchy.").catch(() => {}); + return null; + } - await member.roles.add(role); - await setCustomRole(member.id, guild.id, role.id, role.name); + try { + const role = await guild.roles.create({ + name, + permissions: [], + position: newRolePosition, + }); + + try { + await member.roles.add(role); + } catch (addError) { + logger.error(`Failed to add custom role ${role.id} to ${member.user.tag}:`, addError); + await role.delete(); + await member.send(`I created your custom role but couldn't assign it to you due to permission issues. Please contact an admin.`).catch(() => {}); + return null; + } - return role; + await setCustomRole(member.id, guild.id, role.id, role.name); + return role; + } catch (error) { + logger.error(`Failed to create custom role for ${member.user.tag} in guild ${guild.id}:`, error); + + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes("Missing Permissions")) { + await member.send("I don't have permission to create roles. Please make sure I have the 'Manage Roles' permission and my role is high enough in the hierarchy.").catch(() => {}); + } else { + await member.send("Failed to create your custom role due to an unexpected error. Please try again or contact an admin.").catch(() => {}); + } + + return null; + } } export async function deleteCustomRole( guild: Guild, userId: string -): Promise { +): Promise { const customRole = await getCustomRole(userId, guild.id); - if (!customRole) return; + if (!customRole) return false; const role = guild.roles.cache.get(customRole.discordRoleId); - if (role) await role.delete(); + if (role) { + try { + await role.delete(); + } catch (error) { + logger.error(`Failed to delete custom role ${customRole.discordRoleId} for user ${userId}:`, error); + return false; + } + } await setCustomRole(userId, guild.id, null); + return true; } export async function updateCustomRole( @@ -104,12 +169,24 @@ export async function updateCustomRole( const role = guild.roles.cache.get(customRole.discordRoleId); if (!role) return null; - await role.edit({ - ...(name ? { name } : {}), - ...(color ? { colors: { primaryColor: resolveColor(color) } } : {}), - }); - - await patchCustomRoleStoredName(userId, guild.id, role.name); - - return role; + try { + await role.edit({ + ...(name ? { name } : {}), + ...(color ? { color } : {}), + }); + + await patchCustomRoleStoredName(userId, guild.id, role.name); + return role; + } catch (error) { + logger.error(`Failed to update custom role ${customRole.discordRoleId} for user ${userId}:`, error); + + if (error instanceof Error && error.message.includes("Missing Permissions")) { + const member = await guild.members.fetch(userId).catch(() => null); + if (member) { + await member.send("I couldn't update your custom role due to missing permissions. Please contact an admin to check my role position.").catch(() => {}); + } + } + + return null; + } } \ No newline at end of file