From 71ab692094197b15fbf6361f3475e9efddb68673 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sat, 21 Feb 2026 14:34:46 +1100 Subject: [PATCH 01/10] Deferring + Handlers/Service Issues/Bugs --- .env.example | 1 + README.md | 2 +- src/app.js | 9 +- src/commands/Core/bug.js | 1 - src/commands/Core/ping.js | 12 + src/commands/Counter/counter.js | 15 +- .../Counter/modules/counter_create.js | 79 +++-- .../Counter/modules/counter_delete.js | 116 +++---- src/commands/Counter/modules/counter_list.js | 30 +- .../Counter/modules/counter_update.js | 58 ++-- src/commands/Fun/fight.js | 10 +- src/commands/Fun/roll.js | 3 +- src/commands/Giveaway/gcreate.js | 1 - src/commands/Giveaway/gend.js | 1 - src/commands/JoinToCreate/jointocreate.js | 263 +++++++++------ .../JoinToCreate/modules/config_setup.js | 96 ++++-- src/commands/JoinToCreate/modules/setup.js | 17 +- src/commands/Tools/baseconvert.js | 150 ++++++--- src/commands/Tools/calculate.js | 141 +++----- src/commands/Tools/countdown.js | 309 ++---------------- src/commands/Tools/generatepassword.js | 2 +- src/commands/Tools/hexcolor.js | 6 +- src/commands/Tools/poll.js | 14 +- src/commands/Tools/randomuser.js | 27 +- src/commands/Tools/shorten.js | 28 +- src/commands/Utility/report.js | 11 + src/commands/Utility/serverinfo.js | 13 +- src/commands/Utility/todo.js | 81 ++++- src/commands/Utility/userinfo.js | 13 +- src/commands/Utility/weather.js | 11 + src/commands/Welcome/autorole.js | 11 + src/commands/Welcome/goodbye.js | 11 + src/commands/Welcome/welcome.js | 16 + src/config/bot.js | 8 +- src/events/interactionCreate.js | 11 + src/handlers/calculateButtonLoader.js | 11 + src/handlers/calculateButtons.js | 138 ++++++++ src/handlers/calculateModalLoader.js | 11 + src/handlers/calculateModals.js | 140 ++++++++ src/handlers/countdownButtonLoader.js | 13 + src/handlers/countdownButtons.js | 194 +++++++++++ src/handlers/counterButtonLoader.js | 11 + src/handlers/counterButtons.js | 95 ++++++ src/handlers/helpSelectMenus.js | 133 ++++++-- src/handlers/todoButtonLoader.js | 4 +- src/handlers/todoButtons.js | 242 +++++++++++++- src/services/counterService.js | 71 +++- src/services/giveawayService.js | 3 +- src/services/joinToCreateService.js | 37 ++- src/services/loggingService.js | 12 + src/utils/logger.js | 23 +- src/utils/postgresDatabase.js | 43 ++- todolist.md | 19 +- 53 files changed, 1943 insertions(+), 834 deletions(-) create mode 100644 src/handlers/calculateButtonLoader.js create mode 100644 src/handlers/calculateButtons.js create mode 100644 src/handlers/calculateModalLoader.js create mode 100644 src/handlers/calculateModals.js create mode 100644 src/handlers/countdownButtonLoader.js create mode 100644 src/handlers/countdownButtons.js create mode 100644 src/handlers/counterButtonLoader.js create mode 100644 src/handlers/counterButtons.js diff --git a/.env.example b/.env.example index 95d710efc6..700de52f41 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ NODE_ENV=production # LOG_LEVEL options (Winston levels): # error, warn, info, http, verbose, debug, silly # Accepted aliases in this bot: warns, warning, warnings -> warn +# In production with LOG_LEVEL=warn, startup/shutdown status is still shown for non-technical operators. LOG_LEVEL=warn # Production Configuration diff --git a/README.md b/README.md index fa93a36dda..75f8621c7e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: - `NODE_ENV=production` - `LOG_LEVEL=warn` for a clean production console (critical issues + startup status) - `LOG_LEVEL=info` if you want more detailed operational logs - - If your chosen `PORT` is already used, TitanBot now automatically tries the next port(s) + - If your chosen `PORT` is already used, TitanBot automatically tries the next port(s) Environment options reference: - `NODE_ENV`: `development`, `production`, `test` (any non-`production` value is treated as non-production) diff --git a/src/app.js b/src/app.js index 6a11dd8076..da5587f30f 100644 --- a/src/app.js +++ b/src/app.js @@ -13,7 +13,7 @@ import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/guildConfig.js'; import { getServerCounters, updateCounter } from './services/counterService.js'; -import { logger, startupLog } from './utils/logger.js'; +import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; import { loadCommands, registerCommands as registerSlashCommands } from './handlers/commandLoader.js'; @@ -263,6 +263,7 @@ class TitanBot extends Client { { path: 'events', type: 'default', required: true }, { path: 'interactions', type: 'default', required: true }, { path: 'todoButtonLoader', type: 'default', required: false }, + { path: 'counterButtonLoader', type: 'default', required: false }, { path: 'ticketButtonLoader', type: 'default', required: false }, { path: 'shopButtonLoader', type: 'default', required: false }, { path: 'giveawayButtonLoader', type: 'named:loadGiveawayButtons', required: false }, @@ -270,7 +271,9 @@ class TitanBot extends Client { { path: 'helpSelectMenuLoader', type: 'default', required: false }, { path: 'loggingButtonLoader', type: 'default', required: false }, { path: 'verificationButtonLoader', type: 'named:loadVerificationButtons', required: false }, - { path: 'wipedataButtonLoader', type: 'default', required: false } + { path: 'wipedataButtonLoader', type: 'default', required: false }, + { path: 'countdownButtonLoader', type: 'default', required: false }, + { path: 'calculateButtonLoader', type: 'default', required: false } ]; for (const handler of handlers) { @@ -306,6 +309,7 @@ class TitanBot extends Client { } async shutdown(reason = 'UNKNOWN') { + shutdownLog(`Bot is shutting down (${reason})...`); logger.info(`\n${'='.repeat(60)}`); logger.info(`๐Ÿ›‘ Graceful Shutdown Initiated (${reason})`); logger.info(`${'='.repeat(60)}`); @@ -343,6 +347,7 @@ class TitanBot extends Client { } logger.info('โœ… Graceful shutdown complete'); + shutdownLog('Bot stopped successfully.'); process.exit(0); } catch (error) { logger.error('Error during graceful shutdown:', error); diff --git a/src/commands/Core/bug.js b/src/commands/Core/bug.js index 398ce7b78b..59d2910996 100644 --- a/src/commands/Core/bug.js +++ b/src/commands/Core/bug.js @@ -30,7 +30,6 @@ export default { await interaction.reply({ embeds: [bugReportEmbed], components: [row], - flags: MessageFlags.Ephemeral }); }, }; diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js index ad80c30641..a82410d5d7 100644 --- a/src/commands/Core/ping.js +++ b/src/commands/Core/ping.js @@ -1,5 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -7,6 +9,16 @@ export default { .setDescription("Checks the bot's latency and API speed"), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Ping interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'ping' + }); + return; + } + try { const reply = await interaction.reply({ content: "Pinging...", diff --git a/src/commands/Counter/counter.js b/src/commands/Counter/counter.js index c1a14cb429..ccc035cdc5 100644 --- a/src/commands/Counter/counter.js +++ b/src/commands/Counter/counter.js @@ -1,5 +1,5 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; @@ -16,7 +16,7 @@ export default { .addSubcommand(subcommand => subcommand .setName("create") - .setDescription("Create a new counter for a channel") + .setDescription("Create a new counter channel in a category") .addStringOption(option => option .setName("type") @@ -30,14 +30,21 @@ export default { ) .addStringOption(option => option - .setName("channel") - .setDescription("The channel type for the counter") + .setName("channel_type") + .setDescription("The channel type to create for this counter") .setRequired(true) .addChoices( { name: "voice channel (recommended)", value: "voice" }, { name: "text channel", value: "text" } ) ) + .addChannelOption(option => + option + .setName("category") + .setDescription("The category where the counter channel will be created") + .setRequired(true) + .addChannelTypes(ChannelType.GuildCategory) + ) ) .addSubcommand(subcommand => subcommand diff --git a/src/commands/Counter/modules/counter_create.js b/src/commands/Counter/modules/counter_create.js index f2d419e178..f7a0cc953f 100644 --- a/src/commands/Counter/modules/counter_create.js +++ b/src/commands/Counter/modules/counter_create.js @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { PermissionFlagsBits, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getServerCounters, saveServerCounters, updateCounter } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -11,42 +11,68 @@ import { logger } from '../../../utils/logger.js'; export async function handleCreate(interaction, client) { const guild = interaction.guild; const type = interaction.options.getString("type"); - const channelType = interaction.options.getString("channel"); + const channelType = interaction.options.getString("channel_type"); + const category = interaction.options.getChannel("category"); - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed("You need **Manage Channels** permission to create counters.")], - flags: MessageFlags.Ephemeral - }); + // Defer reply immediately to ensure interaction is acknowledged + try { + await interaction.deferReply(); + } catch (error) { + logger.error("Failed to defer reply:", error); return; } - await interaction.deferReply(); + // Check permissions after deferring + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + await interaction.editReply({ + embeds: [errorEmbed("You need **Manage Channels** permission to create counters.")] + }).catch(logger.error); + return; + } try { + if (!category || category.type !== ChannelType.GuildCategory) { + await interaction.editReply({ + embeds: [errorEmbed("Please select a valid category for the counter channel.")] + }).catch(logger.error); + return; + } + + const targetChannelType = channelType === 'voice' ? ChannelType.GuildVoice : ChannelType.GuildText; + const baseNameMap = { + members: 'Members', + bots: 'Bots', + members_only: 'Humans' + }; + const baseChannelName = baseNameMap[type] || 'Counter'; + const counters = await getServerCounters(client, guild.id); - let targetChannel; - const channels = guild.channels.cache; - - if (channelType === "voice") { - targetChannel = channels.find(c => c.type === ChannelType.GuildVoice); - } else { - targetChannel = channels.find(c => c.type === ChannelType.GuildText); - } + const duplicateInCategory = counters.find(counter => { + if (counter.type !== type) return false; + const existingChannel = guild.channels.cache.get(counter.channelId); + return existingChannel?.parentId === category.id; + }); - if (!targetChannel) { + if (duplicateInCategory) { await interaction.editReply({ - embeds: [errorEmbed(`No ${channelType} channels found in this server.`)] - }); + embeds: [errorEmbed(`A **${type.replace('_', ' ')}** counter already exists in ${category}. `)] + }).catch(logger.error); return; } + const targetChannel = await guild.channels.create({ + name: baseChannelName, + type: targetChannelType, + parent: category.id, + reason: `Counter channel created by ${interaction.user.tag}` + }); + const existingCounter = counters.find(c => c.channelId === targetChannel.id); if (existingCounter) { await interaction.editReply({ - embeds: [errorEmbed(`A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different channel type.`)] - }); + embeds: [errorEmbed(`A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different type.`)] + }).catch(logger.error); return; } @@ -63,9 +89,10 @@ export async function handleCreate(interaction, client) { const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { + await targetChannel.delete('Counter creation failed during save').catch(() => null); await interaction.editReply({ embeds: [errorEmbed("Failed to save counter data. Please try again.")] - }); + }).catch(logger.error); return; } @@ -73,7 +100,7 @@ export async function handleCreate(interaction, client) { if (!updated) { await interaction.editReply({ embeds: [errorEmbed("Counter created but failed to update channel name. The counter will update on the next scheduled run.")] - }); + }).catch(logger.error); return; } @@ -84,14 +111,14 @@ export async function handleCreate(interaction, client) { }; await interaction.editReply({ - embeds: [successEmbed(`โœ… **Counter Created Successfully!**\n\n**Type:** ${typeDisplay[type]}\n**Channel Type:** ${channelType}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/counter list\` to view all counters.`)] - }); + embeds: [successEmbed(`โœ… **Counter Created Successfully!**\n\n**Type:** ${typeDisplay[type]}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/counter list\` to view all counters.`)] + }).catch(logger.error); } catch (error) { logger.error("Error creating counter:", error); await interaction.editReply({ embeds: [errorEmbed("An error occurred while creating the counter. Please try again.")] - }); + }).catch(logger.error); } } diff --git a/src/commands/Counter/modules/counter_delete.js b/src/commands/Counter/modules/counter_delete.js index 52ec94663a..b2cca04a7e 100644 --- a/src/commands/Counter/modules/counter_delete.js +++ b/src/commands/Counter/modules/counter_delete.js @@ -1,6 +1,6 @@ import { getColor } from '../../../config/bot.js'; -import { PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; import { getServerCounters, saveServerCounters } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -13,31 +13,37 @@ export async function handleDelete(interaction, client) { const guild = interaction.guild; const counterId = interaction.options.getString("counter-id"); - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed("You need **Manage Channels** permission to delete counters.")], - flags: MessageFlags.Ephemeral - }); + // Defer reply immediately to ensure interaction is acknowledged + try { + await interaction.deferReply(); + } catch (error) { + logger.error("Failed to defer reply:", error); return; } - await interaction.deferReply(); + // Check permissions after deferring + if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { + await interaction.editReply({ + embeds: [errorEmbed("You need **Manage Channels** permission to delete counters.")] + }).catch(logger.error); + return; + } try { const counters = await getServerCounters(client, guild.id); if (counters.length === 0) { await interaction.editReply({ - embeds: [errorEmbed("No counters found to delete.")]} - ); + embeds: [errorEmbed("No counters found to delete.")] + }).catch(logger.error); return; } const counterToDelete = counters.find(c => c.id === counterId); if (!counterToDelete) { await interaction.editReply({ - embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)]} - ); + embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] + }).catch(logger.error); return; } @@ -51,63 +57,22 @@ export async function handleDelete(interaction, client) { const row = new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`confirm_delete_${counterToDelete.id}`) + .setCustomId(`counter-delete:confirm:${counterToDelete.id}:${interaction.user.id}`) .setLabel("Confirm Delete") .setStyle(ButtonStyle.Danger), new ButtonBuilder() - .setCustomId(`cancel_delete_${counterToDelete.id}`) + .setCustomId(`counter-delete:cancel:${counterToDelete.id}:${interaction.user.id}`) .setLabel("Cancel") .setStyle(ButtonStyle.Secondary) ); - await interaction.editReply({ embeds: [embed], components: [row] }); - - const filter = (i) => i.user.id === interaction.user.id && i.customId.includes(counterToDelete.id); - const collector = interaction.channel.createMessageComponentCollector({ - filter, - componentType: ComponentType.Button, -time: 30000, - max: 1 - }); - - collector.on("collect", async (i) => { - try { - if (i.customId === `confirm_delete_${counterToDelete.id}`) { - await i.update({ content: "Deleting counter...", components: [] }); - await performDeletion(interaction, client, guild, counterToDelete); - } else if (i.customId === `cancel_delete_${counterToDelete.id}`) { - await i.update({ - embeds: [createEmbed({ - title: "โŒ Cancelled", - description: "Counter deletion cancelled.", - color: getColor('error') - })], - components: [] - }); - } - } catch (error) { - logger.error("Error in button interaction:", error); - } - }); - - collector.on("end", async (collected) => { - if (collected.size === 0) { - await interaction.editReply({ - embeds: [createEmbed({ - title: "โŒ Cancelled", - description: "Counter deletion cancelled - no confirmation received.", - color: getColor('error') - })], - components: [] - }); - } - }); + await interaction.editReply({ embeds: [embed], components: [row] }).catch(logger.error); } catch (error) { logger.error("Error in handleDelete:", error); await interaction.editReply({ - embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")]} - ); + embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] + }).catch(logger.error); } } @@ -117,20 +82,26 @@ time: 30000, - -async function performDeletion(interaction, client, guild, counter) { +export async function performDeletionByCounterId(client, guild, counterId) { try { const counters = await getServerCounters(client, guild.id); + const counter = counters.find(c => c.id === counterId); + if (!counter) { + return { + success: false, + message: `Counter with ID \`${counterId}\` was not found.` + }; + } + const updatedCounters = counters.filter(c => c.id !== counter.id); const saved = await saveServerCounters(client, guild.id, updatedCounters); if (!saved) { - await interaction.followUp({ - embeds: [errorEmbed("Failed to delete counter. Please try again.")], - flags: MessageFlags.Ephemeral - }); - return; + return { + success: false, + message: "Failed to delete counter. Please try again." + }; } const channel = guild.channels.cache.get(counter.channelId); @@ -155,16 +126,17 @@ async function performDeletion(interaction, client, guild, counter) { message += `\n**Channel:** Already deleted`; } - await interaction.followUp({ - embeds: [successEmbed(message)] - }); + return { + success: true, + message + }; } catch (error) { logger.error("Error deleting counter:", error); - await interaction.followUp({ - embeds: [errorEmbed("An error occurred while deleting the counter. Please try again.")], - flags: MessageFlags.Ephemeral - }); + return { + success: false, + message: "An error occurred while deleting the counter. Please try again." + }; } } diff --git a/src/commands/Counter/modules/counter_list.js b/src/commands/Counter/modules/counter_list.js index 04a6d49241..5e408b97bc 100644 --- a/src/commands/Counter/modules/counter_list.js +++ b/src/commands/Counter/modules/counter_list.js @@ -1,6 +1,6 @@ import { getColor } from '../../../config/bot.js'; -import { PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { PermissionFlagsBits } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; import { getServerCounters } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -12,16 +12,22 @@ import { logger } from '../../../utils/logger.js'; export async function handleList(interaction, client) { const guild = interaction.guild; + // Defer reply immediately to ensure interaction is acknowledged + try { + await interaction.deferReply(); + } catch (error) { + logger.error("Failed to defer reply:", error); + return; + } + + // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed("You need **Manage Channels** permission to view counters.")], - flags: MessageFlags.Ephemeral - }); + await interaction.editReply({ + embeds: [errorEmbed("You need **Manage Channels** permission to view counters.")] + }).catch(logger.error); return; } - await interaction.deferReply(); - try { const counters = await getServerCounters(client, guild.id); @@ -40,7 +46,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "๐Ÿ“ **Usage Examples**", - value: "`/counter create type:Members channel:#general`\n`/counter create type:Bots channel:#member-count`\n`/counter list`", + value: "`/counter create type:members channel_type:voice category:Stats`\n`/counter create type:bots channel_type:text category:Server Info`\n`/counter list`", inline: false }); @@ -48,7 +54,7 @@ export async function handleList(interaction, client) { text: "Counter System โ€ข Automatic updates every 15 minutes" }); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }).catch(logger.error); return; } @@ -101,13 +107,13 @@ export async function handleList(interaction, client) { }); embed.setTimestamp(); - await interaction.editReply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }).catch(logger.error); } catch (error) { logger.error("Error displaying counters:", error); await interaction.editReply({ embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] - }); + }).catch(logger.error); } } diff --git a/src/commands/Counter/modules/counter_update.js b/src/commands/Counter/modules/counter_update.js index adb323d8ba..1442495001 100644 --- a/src/commands/Counter/modules/counter_update.js +++ b/src/commands/Counter/modules/counter_update.js @@ -1,4 +1,4 @@ -import { PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { PermissionFlagsBits, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getServerCounters, saveServerCounters, updateCounter } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -14,32 +14,36 @@ export async function handleUpdate(interaction, client) { const newType = interaction.options.getString("type"); const newChannel = interaction.options.getChannel("channel"); + // Defer reply immediately to ensure interaction is acknowledged + try { + await interaction.deferReply(); + } catch (error) { + logger.error("Failed to defer reply:", error); + return; + } + + // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed("You need **Manage Channels** permission to update counters.")], - flags: MessageFlags.Ephemeral - }); + await interaction.editReply({ + embeds: [errorEmbed("You need **Manage Channels** permission to update counters.")] + }).catch(logger.error); return; } if (newChannel && newChannel.type !== ChannelType.GuildText && newChannel.type !== ChannelType.GuildVoice) { - await interaction.reply({ - embeds: [errorEmbed("Counters can only be placed on text or voice channels.")], - flags: MessageFlags.Ephemeral - }); + await interaction.editReply({ + embeds: [errorEmbed("Counters can only be placed on text or voice channels.")] + }).catch(logger.error); return; } if (!newType && !newChannel) { - await interaction.reply({ - embeds: [errorEmbed("You must provide at least one option to update (type or channel).")], - flags: MessageFlags.Ephemeral - }); + await interaction.editReply({ + embeds: [errorEmbed("You must provide at least one option to update (type or channel).")] + }).catch(logger.error); return; } - await interaction.deferReply(); - try { const counters = await getServerCounters(client, guild.id); @@ -47,7 +51,7 @@ export async function handleUpdate(interaction, client) { if (counterIndex === -1) { await interaction.editReply({ embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] - }); + }).catch(logger.error); return; } @@ -56,8 +60,8 @@ export async function handleUpdate(interaction, client) { if (!oldChannel) { await interaction.editReply({ - embeds: [errorEmbed("The channel for this counter no longer exists. You cannot update a counter for a deleted channel.")]} - ); + embeds: [errorEmbed("The channel for this counter no longer exists. You cannot update a counter for a deleted channel.")] + }).catch(logger.error); return; } @@ -65,8 +69,8 @@ export async function handleUpdate(interaction, client) { const existingCounter = counters.find(c => c.channelId === newChannel.id); if (existingCounter) { await interaction.editReply({ - embeds: [errorEmbed("The selected channel already has a counter. Please choose a different channel.")]} - ); + embeds: [errorEmbed("The selected channel already has a counter. Please choose a different channel.")] + }).catch(logger.error); return; } } @@ -81,8 +85,8 @@ export async function handleUpdate(interaction, client) { const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { await interaction.editReply({ - embeds: [errorEmbed("Failed to save updated counter data. Please try again.")]} - ); + embeds: [errorEmbed("Failed to save updated counter data. Please try again.")] + }).catch(logger.error); return; } @@ -90,8 +94,8 @@ export async function handleUpdate(interaction, client) { const updated = await updateCounter(client, guild, updatedCounter); if (!updated) { await interaction.editReply({ - embeds: [errorEmbed("Counter updated but failed to update channel name. The counter will update on the next scheduled run.")]} - ); + embeds: [errorEmbed("Counter updated but failed to update channel name. The counter will update on the next scheduled run.")] + }).catch(logger.error); return; } @@ -118,13 +122,13 @@ export async function handleUpdate(interaction, client) { await interaction.editReply({ embeds: [successEmbed(`โœ… **Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n\n${changes.join('\n')}\n\n**New Settings:**\n**Type:** ${typeEmoji[updatedCounter.type]} ${typeDisplay[updatedCounter.type]}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel.name}\n\nThe counter will automatically update every 15 minutes.`)] - }); + }).catch(logger.error); } catch (error) { logger.error("Error updating counter:", error); await interaction.editReply({ - embeds: [errorEmbed("An error occurred while updating the counter. Please try again.")]} - ); + embeds: [errorEmbed("An error occurred while updating the counter. Please try again.")] + }).catch(logger.error); } } diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js index b6d638d7a6..c6083fd06b 100644 --- a/src/commands/Fun/fight.js +++ b/src/commands/Fun/fight.js @@ -1,7 +1,7 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { handleInteractionError } from '../../utils/errorHandler.js'; const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; @@ -35,11 +35,11 @@ export default { if (opponent.bot) { - throw new TitanBotError( - `User tried to fight a bot: ${opponent.id}`, - ErrorTypes.USER_INPUT, + const embed = warningEmbed( + "โš”๏ธ Invalid Opponent", "You can't fight bots! Challenge a real person instead." ); + return await interaction.editReply({ embeds: [embed] }); } const winner = rand(0, 1) === 0 ? challenger : opponent; diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js index ffe1a35bcc..a7b819fcbe 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/roll.js @@ -1,5 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -79,7 +79,6 @@ export default { await interaction.editReply({ embeds: [embed] }); logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); } catch (error) { - logger.error('Roll command error:', error); await handleInteractionError(interaction, error, { commandName: 'roll', source: 'roll_command' diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js index 6007c04810..53c7fea682 100644 --- a/src/commands/Giveaway/gcreate.js +++ b/src/commands/Giveaway/gcreate.js @@ -185,7 +185,6 @@ export default { }); } catch (error) { - logger.error('Error in gcreate command:', error); await handleInteractionError(interaction, error, { type: 'command', commandName: 'gcreate', diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js index 757ee4f2dc..b81d019c9f 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/gend.js @@ -196,7 +196,6 @@ export default { }); } catch (error) { - logger.error('Error in gend command:', error); await handleInteractionError(interaction, error, { type: 'command', commandName: 'gend', diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index 96c078b85d..0c0cee5449 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -1,5 +1,5 @@ import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder } from 'discord.js'; import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; @@ -97,16 +97,15 @@ export default { } } catch (error) { - logger.error('Error in jointocreate command:', error); - try { let errorMessage = 'An error occurred while executing this command.'; if (error instanceof TitanBotError) { - errorMessage = error.userMessage || errorMessage; - logger.warn(`TitanBotError [${error.type}]: ${error.message}`); + errorMessage = error.userMessage || 'An error occurred. Please try again.'; + logger.debug(`TitanBotError [${error.type}]: ${error.message}`, error.context || {}); } else { - logger.error('Non-TitanBotError in jointocreate:', error); + logger.error('Unexpected error in jointocreate command:', error); + errorMessage = 'An unexpected error occurred. Please try again or contact support.'; } const errorEmbedObj = errorEmbed("โš ๏ธ Error", errorMessage); @@ -131,98 +130,88 @@ async function handleSetupSubcommand(interaction, client) { const bitrate = interaction.options.getInteger('bitrate') || 64; const guildId = interaction.guild.id; - const existingChannels = await interaction.guild.channels.fetch(); - const existingJoinToCreate = existingChannels.filter(c => - c.type === ChannelType.GuildVoice && - c.name === 'Join to Create' - ); - - let triggerChannel; - - if (existingJoinToCreate.size > 0) { - triggerChannel = existingJoinToCreate.first(); + logger.debug(`Setting up Join to Create in guild ${guildId} with template: ${nameTemplate}`); + // Check if guild already has a Join to Create channel configured + const existingConfig = await getConfiguration(client, guildId); + + if (existingConfig.triggerChannels && existingConfig.triggerChannels.length > 0) { + // Guild has a JTC channel in database - verify it still exists in Discord + const existingChannelId = existingConfig.triggerChannels[0]; + const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); - const config = await updateChannelConfig(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); - - await logConfigurationChange(client, guildId, interaction.user.id, 'Updated Join to Create', { - channelId: triggerChannel.id, - nameTemplate, - userLimit, - bitrate - }); - - const responseEmbed = successEmbed( - `Updated existing Join to Create channel: ${triggerChannel}\n\n` + - `**New Settings:**\n` + - `โ€ข Template: \`${nameTemplate}\`\n` + - `โ€ข User Limit: ${userLimit === 0 ? 'Unlimited' : userLimit + ' users'}\n` + - `โ€ข Bitrate: ${bitrate} kbps\n` + - `${category ? `โ€ข Category: ${category.name}` : ''}`, - 'โœ… Settings Updated' - ); + if (existingChannel) { + // Channel exists, show error + const errorMessage = `This server already has a Join to Create channel set up: ${existingChannel}\n\nUse \`/jointocreate config\` to modify it, or remove it first before creating a new one.`; + + throw new TitanBotError( + 'Guild already has a Join to Create channel', + ErrorTypes.VALIDATION, + errorMessage + ); + } else { + // Channel in database but doesn't exist in Discord - clean it up + logger.info(`Cleaning up stale JTC trigger ${existingChannelId} from guild ${guildId}`); + await removeTriggerChannel(client, guildId, existingChannelId); + } + } - return await interaction.editReply({ embeds: [responseEmbed] }); + // Create the trigger channel + logger.debug('Creating Join to Create trigger channel...'); + let triggerChannel = await interaction.guild.channels.create({ + name: 'Join to Create', + type: ChannelType.GuildVoice, + parent: category?.id, + userLimit: 0, + bitrate: 64000, + permissionOverwrites: [ + { + id: interaction.guild.id, + allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], + }, + ], + }); - } else { - - triggerChannel = await interaction.guild.channels.create({ - name: 'Join to Create', - type: ChannelType.GuildVoice, - parent: category?.id, - userLimit: 0, - bitrate: 64000, - permissionOverwrites: [ - { - id: interaction.guild.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - ], - }); + logger.debug(`Created trigger channel ${triggerChannel.id}, initializing config...`); - - const config = await initializeJoinToCreate(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); + // Initialize the Join to Create configuration + const config = await initializeJoinToCreate(client, guildId, triggerChannel.id, { + nameTemplate: nameTemplate, + userLimit: userLimit, + bitrate: bitrate * 1000, + categoryId: category?.id + }); - await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { - channelId: triggerChannel.id, - nameTemplate, - userLimit, - bitrate - }); + await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { + channelId: triggerChannel.id, + nameTemplate, + userLimit, + bitrate + }); - logger.info(`Created new Join to Create system in guild ${guildId}`); + logger.info(`Successfully created Join to Create system in guild ${guildId}`); - const responseEmbed = successEmbed( - `Created Join to Create channel: ${triggerChannel}\n\n` + - `**Settings:**\n` + - `โ€ข Template: \`${nameTemplate}\`\n` + - `โ€ข User Limit: ${userLimit === 0 ? 'Unlimited' : userLimit + ' users'}\n` + - `โ€ข Bitrate: ${bitrate} kbps\n` + - `${category ? `โ€ข Category: ${category.name}` : 'โ€ข Category: Root level'}`, - 'โœ… Setup Complete' - ); + const responseEmbed = successEmbed( + 'โœ… Setup Complete', + `Created Join to Create channel: ${triggerChannel}\n\n` + + `**Settings:**\n` + + `โ€ข Template: \`${nameTemplate}\`\n` + + `โ€ข User Limit: ${userLimit === 0 ? 'Unlimited' : userLimit + ' users'}\n` + + `โ€ข Bitrate: ${bitrate} kbps\n` + + `${category ? `โ€ข Category: ${category.name}` : 'โ€ข Category: Root level'}` + ); - return await interaction.editReply({ embeds: [responseEmbed] }); - } + return await interaction.editReply({ embeds: [responseEmbed] }); } catch (error) { + logger.error('Error in handleSetupSubcommand:', error); if (error instanceof TitanBotError) { throw error; } throw new TitanBotError( `Setup failed: ${error.message}`, ErrorTypes.DISCORD_API, - 'Failed to set up Join to Create system.' + 'Failed to set up Join to Create system. Please check bot permissions.' ); } } @@ -232,16 +221,16 @@ async function handleConfigSubcommand(interaction, client) { const triggerChannel = interaction.options.getChannel('trigger_channel'); const guildId = interaction.guild.id; - + // Validate that the channel is actually a Join to Create trigger const currentConfig = await getChannelConfiguration(client, guildId, triggerChannel.id); const channelConfig = currentConfig.channelConfig || {}; - const configEmbed = { - title: 'โš™๏ธ Join to Create Configuration', - description: `Configuration for ${triggerChannel}`, - color: getColor('info'), - fields: [ + const configEmbed = new EmbedBuilder() + .setTitle('โš™๏ธ Join to Create Configuration') + .setDescription(`Configuration for ${triggerChannel}`) + .setColor(getColor('info')) + .addFields( { name: '๐Ÿ“ Channel Name Template', value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate || "{username}'s Room"}\``, @@ -257,10 +246,9 @@ async function handleConfigSubcommand(interaction, client) { value: `${(channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000} kbps`, inline: true } - ], - footer: { text: 'Use the buttons below to modify settings' }, - timestamp: new Date() - }; + ) + .setFooter({ text: 'Use the buttons below to modify settings' }) + .setTimestamp(); const nameButton = new ButtonBuilder() @@ -319,12 +307,16 @@ async function handleConfigSubcommand(interaction, client) { await handleChannelDeletion(buttonInteraction, triggerChannel, currentConfig, client); } } catch (error) { - logger.error('Error handling config button interaction:', error); - const userMessage = error instanceof TitanBotError ? error.userMessage || 'An error occurred.' : 'An error occurred while processing your request.'; + if (error instanceof TitanBotError) { + logger.debug(`Button interaction validation error: ${error.message}`, error.context || {}); + } else { + logger.error('Unexpected error in config button interaction:', error); + } + await buttonInteraction.reply({ content: `โŒ ${userMessage}`, flags: MessageFlags.Ephemeral @@ -342,10 +334,7 @@ async function handleConfigSubcommand(interaction, client) { message.edit({ components: [disabledRow], - embeds: [{ - ...configEmbed, - footer: { text: 'Configuration session expired. Run the command again to make changes.' } - }] + embeds: [configEmbed.setFooter({ text: 'Configuration session expired. Run the command again to make changes.' })] }).catch(() => {}); }); @@ -386,6 +375,15 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi time: 60000 }); + // Recheck permissions + if (!hasManageGuildPermission(modalSubmission.member)) { + await modalSubmission.reply({ + content: 'โŒ You need **Manage Server** permission to modify these settings.', + flags: MessageFlags.Ephemeral + }); + return; + } + const newTemplate = modalSubmission.fields.getTextInputValue('template').trim(); await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { @@ -406,8 +404,15 @@ async function handleNameTemplateModal(interaction, triggerChannel, currentConfi if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - logger.error('Error in name template modal:', error); - throw error; + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Unexpected error in name template modal:', error); + throw new TitanBotError( + `Modal error: ${error.message}`, + ErrorTypes.UNKNOWN, + 'An error occurred while updating the template.' + ); } } @@ -439,6 +444,15 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, time: 60000 }); + // Recheck permissions + if (!hasManageGuildPermission(modalSubmission.member)) { + await modalSubmission.reply({ + content: 'โŒ You need **Manage Server** permission to modify these settings.', + flags: MessageFlags.Ephemeral + }); + return; + } + const userInput = modalSubmission.fields.getTextInputValue('user_limit').trim(); await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { @@ -459,8 +473,15 @@ async function handleUserLimitModal(interaction, triggerChannel, currentConfig, if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - logger.error('Error in user limit modal:', error); - throw error; + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Unexpected error in user limit modal:', error); + throw new TitanBotError( + `Modal error: ${error.message}`, + ErrorTypes.UNKNOWN, + 'An error occurred while updating the user limit.' + ); } } @@ -492,6 +513,15 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl time: 60000 }); + // Recheck permissions + if (!hasManageGuildPermission(modalSubmission.member)) { + await modalSubmission.reply({ + content: 'โŒ You need **Manage Server** permission to modify these settings.', + flags: MessageFlags.Ephemeral + }); + return; + } + const userInput = modalSubmission.fields.getTextInputValue('bitrate').trim(); await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { @@ -512,8 +542,15 @@ async function handleBitrateModal(interaction, triggerChannel, currentConfig, cl if (error.code === 'INTERACTION_COLLECTOR_ERROR') { return; } - logger.error('Error in bitrate modal:', error); - throw error; + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Unexpected error in bitrate modal:', error); + throw new TitanBotError( + `Modal error: ${error.message}`, + ErrorTypes.UNKNOWN, + 'An error occurred while updating the bitrate.' + ); } } @@ -549,6 +586,15 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, deleteCollector.on('collect', async (buttonInteraction) => { try { + // Recheck permissions + if (!hasManageGuildPermission(buttonInteraction.member)) { + await buttonInteraction.reply({ + content: 'โŒ You need **Manage Server** permission to remove channels.', + flags: MessageFlags.Ephemeral + }); + return; + } + if (buttonInteraction.customId === `jtc_delete_confirm_${triggerChannel.id}`) { await removeTriggerChannel(client, interaction.guild.id, triggerChannel.id); @@ -596,8 +642,15 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, }); } catch (error) { - logger.error('Error in handleChannelDeletion:', error); - throw error; + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Unexpected error in handleChannelDeletion:', error); + throw new TitanBotError( + `Deletion error: ${error.message}`, + ErrorTypes.UNKNOWN, + 'An error occurred while removing the channel.' + ); } } diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js index 700d87ff05..44d7b389d5 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.js @@ -11,6 +11,8 @@ import { ButtonStyle } from 'discord.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; import { getJoinToCreateConfig, updateJoinToCreateConfig, @@ -27,7 +29,11 @@ export default { const currentConfig = await getJoinToCreateConfig(client, guildId); if (!currentConfig.triggerChannels.includes(triggerChannel.id)) { - throw new Error(`${triggerChannel} is not configured as a Join to Create trigger channel.`); + throw new TitanBotError( + `Channel ${triggerChannel.id} is not a Join to Create trigger`, + ErrorTypes.VALIDATION, + `${triggerChannel} is not configured as a Join to Create trigger channel.` + ); } const embed = new EmbedBuilder() @@ -86,7 +92,7 @@ export default { embeds: [embed], components: [row], }).catch(error => { - console.error('Failed to edit reply in config_setup:', error); + logger.error('Failed to edit reply in config_setup:', error); }); const collector = interaction.channel.createMessageComponentCollector({ @@ -119,11 +125,20 @@ time: 60000 break; } } catch (error) { - console.error('Configuration menu error:', error); + if (error instanceof TitanBotError) { + logger.debug(`Configuration validation error: ${error.message}`, error.context || {}); + } else { + logger.error('Unexpected configuration menu error:', error); + } + + const errorMessage = error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An error occurred while processing your selection.'; + await selectInteraction.followUp({ - embeds: [errorEmbed('Configuration Error', 'An error occurred while processing your selection.')], + embeds: [errorEmbed('Configuration Error', errorMessage)], flags: MessageFlags.Ephemeral, - }); + }).catch(() => {}); } }); @@ -139,8 +154,15 @@ time: 60000 } }); } catch (error) { - console.error('Error in config_setup:', error); - throw new Error(`Failed to configure Join to Create: ${error.message}`); + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Unexpected error in config_setup:', error); + throw new TitanBotError( + `Config setup failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to configure Join to Create system.' + ); } } }; @@ -201,11 +223,20 @@ time: 30000, await message.delete().catch(() => {}); } catch (error) { - console.error('Template update error:', error); + if (error instanceof TitanBotError) { + logger.debug(`Template validation error: ${error.message}`); + } else { + logger.error('Template update error:', error); + } + + const errorMessage = error instanceof TitanBotError + ? error.userMessage || 'Could not update the channel name template.' + : 'Could not update the channel name template.'; + await interaction.followUp({ - embeds: [errorEmbed('Update Failed', 'Could not update the channel name template.')], + embeds: [errorEmbed('Update Failed', errorMessage)], flags: MessageFlags.Ephemeral, - }); + }).catch(() => {}); } }); @@ -270,11 +301,20 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, await message.delete().catch(() => {}); } catch (error) { - console.error('User limit update error:', error); + if (error instanceof TitanBotError) { + logger.debug(`User limit validation error: ${error.message}`); + } else { + logger.error('User limit update error:', error); + } + + const errorMessage = error instanceof TitanBotError + ? error.userMessage || 'Could not update the user limit.' + : 'Could not update the user limit.'; + await interaction.followUp({ - embeds: [errorEmbed('Update Failed', 'Could not update the user limit.')], + embeds: [errorEmbed('Update Failed', errorMessage)], flags: MessageFlags.Ephemeral, - }); + }).catch(() => {}); } }); @@ -344,11 +384,20 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c await message.delete().catch(() => {}); } catch (error) { - console.error('Bitrate update error:', error); + if (error instanceof TitanBotError) { + logger.debug(`Bitrate validation error: ${error.message}`); + } else { + logger.error('Bitrate update error:', error); + } + + const errorMessage = error instanceof TitanBotError + ? error.userMessage || 'Could not update the bitrate.' + : 'Could not update the bitrate.'; + await interaction.followUp({ - embeds: [errorEmbed('Update Failed', 'Could not update the bitrate.')], + embeds: [errorEmbed('Update Failed', errorMessage)], flags: MessageFlags.Ephemeral, - }); + }).catch(() => {}); } }); @@ -413,11 +462,20 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c }); } } catch (error) { - console.error('Remove trigger error:', error); + if (error instanceof TitanBotError) { + logger.debug(`Trigger removal validation error: ${error.message}`); + } else { + logger.error('Remove trigger error:', error); + } + + const errorMessage = error instanceof TitanBotError + ? error.userMessage || 'An error occurred while removing the trigger channel.' + : 'An error occurred while removing the trigger channel.'; + await buttonInteraction.followUp({ - embeds: [errorEmbed('Removal Failed', 'An error occurred while removing the trigger channel.')], + embeds: [errorEmbed('Removal Failed', errorMessage)], flags: MessageFlags.Ephemeral, - }); + }).catch(() => {}); } } else { await buttonInteraction.followUp({ diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js index 3390d615e8..e359c8ffde 100644 --- a/src/commands/JoinToCreate/modules/setup.js +++ b/src/commands/JoinToCreate/modules/setup.js @@ -1,5 +1,7 @@ import { ChannelType, MessageFlags, PermissionFlagsBits } from 'discord.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { logger } from '../../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; import { addJoinToCreateTrigger, getJoinToCreateConfig } from '../../../utils/database.js'; export default { @@ -50,19 +52,26 @@ export default { await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); } } catch (responseError) { - console.error('Error responding to interaction:', responseError); + logger.error('Error responding to interaction:', responseError); try { if (!interaction.replied) { await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); } } catch (e) { - console.error('All response attempts failed:', e); + logger.error('All response attempts failed:', e); } } } catch (error) { - console.error('Error in JoinToCreate setup:', error); - throw new Error(`Failed to set up Join to Create: ${error.message}`); + if (error instanceof TitanBotError) { + throw error; + } + logger.error('Error in JoinToCreate setup:', error); + throw new TitanBotError( + `Setup failed: ${error.message}`, + ErrorTypes.DISCORD_API, + 'Failed to set up Join to Create system.' + ); } } }; diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.js index 6b11528801..6d0c17ddee 100644 --- a/src/commands/Tools/baseconvert.js +++ b/src/commands/Tools/baseconvert.js @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getColor } from '../../config/bot.js'; const BASE_ALPHABETS = { @@ -16,6 +17,82 @@ const BASE_ALPHABETS = { }; const BASE_NAMES = Object.entries(BASE_ALPHABETS).map(([key, { name }]) => ({ name: `${key} (${name})`, value: key })); +const BASE_CHARSETS = { + BIN: '01', + OCT: '01234567', + DEC: '0123456789', + HEX: '0123456789ABCDEF', + B36: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', + B58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz', + B62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', +}; + +function parseBigIntFromBase(value, baseKey) { + if (baseKey === 'B64') { + const bytes = Buffer.from(value, 'base64'); + return bytes.reduce((acc, byte) => (acc * 256n) + BigInt(byte), 0n); + } + + const charset = BASE_CHARSETS[baseKey]; + if (!charset) { + throw new Error(`Unsupported base: ${baseKey}`); + } + + const normalized = ['BIN', 'OCT', 'DEC', 'HEX', 'B36'].includes(baseKey) + ? value.toUpperCase() + : value; + + let result = 0n; + const base = BigInt(charset.length); + + for (const char of normalized) { + const digit = charset.indexOf(char); + if (digit < 0) { + throw new Error(`Invalid character '${char}' for base ${baseKey}`); + } + result = (result * base) + BigInt(digit); + } + + return result; +} + +function formatBigIntToBase(value, baseKey) { + if (baseKey === 'B64') { + if (value === 0n) { + return Buffer.from([0]).toString('base64'); + } + + const bytes = []; + let n = value; + while (n > 0n) { + bytes.unshift(Number(n & 0xffn)); + n >>= 8n; + } + + return Buffer.from(bytes).toString('base64'); + } + + const charset = BASE_CHARSETS[baseKey]; + if (!charset) { + throw new Error(`Unsupported base: ${baseKey}`); + } + + if (value === 0n) { + return '0'; + } + + const base = BigInt(charset.length); + let n = value; + let output = ''; + + while (n > 0n) { + const index = Number(n % base); + output = charset[index] + output; + n /= base; + } + + return output; +} export default { data: new SlashCommandBuilder() @@ -37,23 +114,32 @@ export default { .addChoices(...BASE_NAMES)), async execute(interaction) { -try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`BaseConvert interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'baseconvert' + }); + return; + } + + try { const numberStr = interaction.options.getString('number').trim(); const fromBase = interaction.options.getString('from'); const toBase = interaction.options.getString('to'); - const { base: fromBaseValue, prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; + const { prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; const cleanNumber = fromPrefix && numberStr.startsWith(fromPrefix) ? numberStr.slice(fromPrefix.length) : numberStr; if (!cleanNumber) { - const embed = errorEmbed('Error', 'Please provide a valid number to convert.'); + const embed = errorEmbed('โŒ Empty Input', 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`'); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } @@ -61,52 +147,49 @@ try { const regex = new RegExp(`^[${alphabet}]+$`, 'i'); if (!regex.test(cleanNumber)) { + let examples = ''; + if (fromBase === 'BIN') { + examples = '\n\n**Valid:** 101, 1010, 11111 | **Invalid:** 5 (digit 5 not allowed)'; + } else if (fromBase === 'OCT') { + examples = '\n\n**Valid:** 77, 123, 755 | **Invalid:** 8 (only 0-7 allowed)'; + } else if (fromBase === 'DEC') { + examples = '\n\n**Valid:** 42, 123, 999 | **Invalid:** 12.34 (no decimals)'; + } else if (fromBase === 'HEX') { + examples = '\n\n**Valid:** FF, A1B2, DEADBEEF | **Invalid:** G (only 0-9, A-F)'; + } const embed = errorEmbed( - 'Invalid Input', - `The input is not a valid ${fromName} number. ` + - `Valid characters for ${fromBase} (${fromName}): ${alphabet}` + `โŒ Invalid ${fromName}`, + `You provided: \`${cleanNumber}\`\n\nValid characters: \`${alphabet}\`${examples}` ); embed.setColor(getColor('error')); logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } let decimalValue; try { if (fromBase === 'B64') { - decimalValue = BigInt(Buffer.from(cleanNumber, 'base64').reduce((a, b) => a * 256n + BigInt(b), 0n)); + decimalValue = parseBigIntFromBase(cleanNumber, fromBase); } else { - decimalValue = BigInt(cleanNumber, fromBaseValue); + decimalValue = parseBigIntFromBase(cleanNumber, fromBase); } } catch (error) { logger.error('Base conversion parse error:', error); - const embed = errorEmbed('Conversion Error', 'Failed to parse the input number. It may be too large or invalid.'); + const embed = errorEmbed('โš ๏ธ Conversion Failed', 'The number is too large to process.\n\nTry with a smaller number.'); embed.setColor(getColor('error')); return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } if (toBase) { - const { base: toBaseValue, prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; + const { prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; let result; try { - if (toBase === 'B64') { - const bytes = []; - let n = decimalValue; - while (n > 0n) { - bytes.unshift(Number(n & 0xffn)); - n >>= 8n; - } - result = Buffer.from(bytes).toString('base64'); - } else { - result = decimalValue.toString(toBaseValue).toUpperCase(); - } + result = formatBigIntToBase(decimalValue, toBase); const embed = successEmbed( '๐Ÿ”„ Base Conversion Result', @@ -120,7 +203,7 @@ try { } catch (error) { logger.error(`Base conversion error to ${toName}:`, error); - const embed = errorEmbed('Conversion Error', `Failed to convert to ${toName}. The number might be too large.`); + const embed = errorEmbed(`โš ๏ธ Failed to Convert to ${toName}`, 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.'); embed.setColor(getColor('error')); await interaction.editReply({ embeds: [embed] @@ -131,22 +214,11 @@ try { let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; - for (const [baseKey, { base, prefix, name }] of Object.entries(BASE_ALPHABETS)) { + for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { if (baseKey === fromBase) continue; try { - let value; - if (baseKey === 'B64') { - const bytes = []; - let n = decimalValue; - while (n > 0n) { - bytes.unshift(Number(n & 0xffn)); - n >>= 8n; - } - value = Buffer.from(bytes).toString('base64'); - } else { - value = decimalValue.toString(base).toUpperCase(); - } + let value = formatBigIntToBase(decimalValue, baseKey); description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; } catch (error) { diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js index 8d490771d1..f939d500ba 100644 --- a/src/commands/Tools/calculate.js +++ b/src/commands/Tools/calculate.js @@ -3,6 +3,10 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +// Store calculation context for modal handlers +const calculationContexts = new Map(); function evaluate(expression) { let expr = expression.replace(/\s/g, '').toLowerCase(); @@ -37,6 +41,8 @@ function evaluate(expression) { const calculationHistory = new Map(); const MAX_HISTORY = 5; +export { calculationContexts }; + export default { data: new SlashCommandBuilder() .setName("calculate") @@ -51,6 +57,16 @@ export default { ), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Calculate interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'calculate' + }); + return; + } + try { const expression = interaction.options.getString("expression"); @@ -58,12 +74,13 @@ try { if ( !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-zโˆšฯ€โˆžยฐ]+$/i.test(expression) ) { - return interaction.reply({ + return interaction.editReply({ embeds: [ errorEmbed( - "Invalid Expression", - "The expression contains invalid characters. " + - "Only basic math operations, numbers, and common functions are allowed.", + "โŒ Invalid Expression", + "**Contains unsupported characters.**\n\n" + + "โœ… Supported: Numbers, decimals, + - * / ^ %, sin cos tan sqrt abs log exp, pi e, ()\n" + + "โŒ Not supported: Brackets, curly braces, and other symbols", ), ], }); @@ -83,8 +100,10 @@ try { return interaction.editReply({ embeds: [ errorEmbed( - "Security Alert", - "The expression contains potentially dangerous code and cannot be evaluated.", + "๐Ÿ”’ Security Alert", + "**Contains blocked code patterns.**\n\n" + + "๐Ÿšซ **Blocked:** import, require, eval, Function, setTimeout, setInterval, process, fs, document, window, fetch, loops, async/await\n\n" + + "Code-like syntax is not allowed in calculations.", ), ], flags: ["Ephemeral"], @@ -236,8 +255,18 @@ const BUTTON_TIMEOUT = 300000; } try { + const contextKey = `${i.user.id}_${operation}`; + calculationContexts.set(contextKey, { + expression, + formattedResult, + operator, + messageId: interaction.message?.id, + channelId: interaction.channelId, + userId: i.user.id + }); + await i.showModal({ - customId: `calc_modal_${i.user.id}_${operation}`, + customId: `calc_modal:${operation}`, title: `Enter a number to ${operation}`, components: [ { @@ -245,10 +274,9 @@ const BUTTON_TIMEOUT = 300000; components: [ { type: 4, - customId: "operand", + customId: `operand:${contextKey}`, label: `Number to ${operator} with ${formattedResult}`, - placeholder: - "Enter a number...", + placeholder: "Enter a number...", style: 1, required: true, maxLength: 50, @@ -258,94 +286,23 @@ const BUTTON_TIMEOUT = 300000; ], }); } catch (modalError) { - console.error("Failed to show modal:", modalError); - if (!i.replied) { - await i - .reply({ - content: - "Failed to open calculator. Please try again.", - flags: ["Ephemeral"], - }) - .catch(console.error); + logger.error("Failed to show modal:", modalError); + if (!i.replied && !i.deferred) { + await i.reply({ + content: "Failed to open calculator. Please try again.", + flags: ["Ephemeral"], + }).catch(console.error); } return; } - try { - const modalResponse = await i.awaitModalSubmit({ - filter: (m) => - m.customId === - `calc_modal_${i.user.id}_${operation}`, -time: 300000, - }); - - await modalResponse.deferUpdate(); - - const operand = - modalResponse.fields.getTextInputValue( - "operand", - ); - const newExpression = `(${expression}) ${operator} (${operand})`; - - let newResult; - try { - newResult = evaluate(newExpression); - - let formattedNewResult; - if (typeof newResult === "number") { - formattedNewResult = newResult.toLocaleString("en-US", { - maximumFractionDigits: 10, - }); - - if ( - Math.abs(newResult) > 0 && - (Math.abs(newResult) >= 1e10 || Math.abs(newResult) < 1e-3) - ) { - formattedNewResult = newResult.toExponential(6); - } - } else { - formattedNewResult = String(newResult); - } - - const updatedEmbed = successEmbed( - "๐Ÿงฎ Calculation Result", - `**Expression:** \`${newExpression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedNewResult}\`\n\n` + - `*Use the buttons below to perform operations with the result.*`, - ); - - await modalResponse.editReply({ - embeds: [updatedEmbed], - }); - - } catch (calcError) { - await modalResponse.followUp({ - embeds: [errorEmbed("Calculation Error", "Failed to evaluate the new expression.")], - flags: ["Ephemeral"], - }); - } - } catch (error) { - console.error("Modal error:", error); - if (!i.deferred && !i.replied) { - await i - .followUp({ - content: - "An error occurred while processing your input.", - flags: ["Ephemeral"], - }) - .catch(console.error); - } - } } catch (error) { - console.error("Button interaction error:", error); + logger.error("Button interaction error:", error); if (!i.deferred && !i.replied) { - await i - .followUp({ - content: - "An error occurred while processing your request.", - flags: ["Ephemeral"], - }) - .catch(console.error); + await i.followUp({ + content: "An error occurred while processing your request.", + flags: ["Ephemeral"], + }).catch(console.error); } } }); diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js index 74450d2c78..1fa7a8d4ab 100644 --- a/src/commands/Tools/countdown.js +++ b/src/commands/Tools/countdown.js @@ -1,22 +1,14 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; + const activeCountdowns = new Map(); -const createControlButtons = (countdownId, isPaused = false) => { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`countdown_pause_${countdownId}`) - .setLabel(isPaused ? "โ–ถ๏ธ Resume" : "โธ๏ธ Pause") - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(`countdown_cancel_${countdownId}`) - .setLabel("โŒ Cancel") - .setStyle(ButtonStyle.Danger), - ); -}; +export { activeCountdowns }; + export default { data: new SlashCommandBuilder() .setName("countdown") @@ -45,6 +37,16 @@ export default { ), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Countdown interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'countdown' + }); + return; + } + try { const minutes = interaction.options.getInteger("minutes") || 0; const seconds = interaction.options.getInteger("seconds") || 0; @@ -85,106 +87,10 @@ export default { interval: null, }; - startCountdown(countdownId, countdownData); - activeCountdowns.set(countdownId, countdownData); + startCountdown(countdownId, countdownData, activeCountdowns); - const filter = (i) => { - return i.customId.startsWith('countdown_') && - i.customId.endsWith(countdownId) && - i.user.id === interaction.user.id; - }; - - const collector = message.createMessageComponentCollector({ - filter, - time: totalSeconds * 1000 + 60000 - }); - - collector.on('collect', async (i) => { - const [action, id] = i.customId.split('_').slice(1); - - if (id !== countdownId) return; - - const countdownData = activeCountdowns.get(countdownId); - if (!countdownData) { - await i.reply({ - content: "This countdown has expired or was cancelled.", - flags: ["Ephemeral"], - }); - return; - } - - if (!i.member.permissions.has("MANAGE_MESSAGES")) { - await i.reply({ - content: 'You need the "Manage Messages" permission to control countdowns.', - flags: ["Ephemeral"], - }); - return; - } - - switch (action) { - case "pause": - if (countdownData.isPaused) { - countdownData.isPaused = false; - countdownData.endTime = Date.now() + countdownData.remainingTime; - startCountdown(countdownId, countdownData); - - const currentEmbed = countdownData.message.embeds[0]; - await countdownData.message.edit({ - embeds: [currentEmbed], - components: [createControlButtons(countdownId, false)], - }); - - await i.reply({ - content: "โ–ถ๏ธ Countdown resumed!", - flags: ["Ephemeral"], - }); - } else { - clearInterval(countdownData.interval); - countdownData.isPaused = true; - countdownData.remainingTime = countdownData.endTime - Date.now(); - - const currentEmbed = countdownData.message.embeds[0]; - await countdownData.message.edit({ - embeds: [currentEmbed], - components: [createControlButtons(countdownId, true)], - }); - - await i.reply({ - content: "โธ๏ธ Countdown paused!", - flags: ["Ephemeral"], - }); - } - break; - - case "cancel": - clearInterval(countdownData.interval); - - const embed = successEmbed( - `โฑ๏ธ ${countdownData.title} (Cancelled)`, - "The countdown was cancelled.", - ); - - await countdownData.message.edit({ - embeds: [embed], - components: [], - }); - - cleanupCountdown(countdownId); - - await i.reply({ - content: "โŒ Countdown cancelled!", - flags: ["Ephemeral"], - }); - break; - } - }); - - collector.on("end", () => { - cleanupCountdown(countdownId); - }); - - await interaction.reply({ + await interaction.editReply({ content: "โœ… Countdown started!", flags: MessageFlags.Ephemeral, }); @@ -196,180 +102,3 @@ export default { } }, }; - -export async function handleCountdownInteraction(interaction) { - if (!interaction.isButton()) return false; - - const [action, countdownId] = interaction.customId.split("_").slice(1); - const countdownData = activeCountdowns.get(countdownId); - - if (!countdownData) { - await interaction.editReply({ - content: "This countdown has expired or was cancelled.", - flags: ["Ephemeral"], - }); - return true; - } - - if (!interaction.member.permissions.has("MANAGE_MESSAGES")) { - await interaction.editReply({ - content: - 'You need the "Manage Messages" permission to control countdowns.', - flags: ["Ephemeral"], - }); - return true; - } - - switch (action) { - case "pause": - if (countdownData.isPaused) { - countdownData.isPaused = false; - countdownData.endTime = - Date.now() + countdownData.remainingTime; - startCountdown(countdownId, countdownData); - - const currentEmbed = countdownData.message.embeds[0]; - await countdownData.message.edit({ - embeds: [currentEmbed], - components: [createControlButtons(countdownId, false)], - }); - - await interaction.editReply({ - content: "โ–ถ๏ธ Countdown resumed!", - flags: ["Ephemeral"], - }); - } else { - clearInterval(countdownData.interval); - countdownData.isPaused = true; - countdownData.remainingTime = - countdownData.endTime - Date.now(); - - const currentEmbed = countdownData.message.embeds[0]; - await countdownData.message.edit({ - embeds: [currentEmbed], - components: [createControlButtons(countdownId, true)], - }); - - await interaction.editReply({ - content: "โธ๏ธ Countdown paused!", - flags: ["Ephemeral"], - }); - } - break; - - case "cancel": - clearInterval(countdownData.interval); - - const embed = successEmbed( - `โฑ๏ธ ${countdownData.title} (Cancelled)`, - "The countdown was cancelled.", - ); - - await countdownData.message.edit({ - embeds: [embed], - components: [], - }); - - cleanupCountdown(countdownId); - - await interaction.editReply({ - content: "โŒ Countdown cancelled!", - flags: ["Ephemeral"], - }); - break; - } - - return true; -} - -function startCountdown(countdownId, countdownData) { - if (countdownData.interval) { - clearInterval(countdownData.interval); - countdownData.interval = null; - } - - console.log("Starting countdown with data:", { - endTime: new Date(countdownData.endTime).toISOString(), - remaining: countdownData.endTime - Date.now(), - }); - - logger.info(`Countdown started: ${countdownData.title} (${countdownData.remainingTime / 1000}s remaining)`); - - countdownData.interval = setInterval(async () => { - try { - if (countdownData.isPaused) return; - - const now = Date.now(); - const remaining = Math.max(0, countdownData.endTime - now); - countdownData.remainingTime = remaining; - - if (now - countdownData.lastUpdate >= 1000) { - countdownData.lastUpdate = now; - - const embed = successEmbed( - `โฑ๏ธ ${countdownData.title}`, - `Time remaining: **${formatTime(Math.ceil(remaining / 1000))}**`, - ); - - try { - await countdownData.message.edit({ - embeds: [embed], - components: [ - createControlButtons( - countdownId, - countdownData.isPaused, - ), - ], - }); - } catch (error) { - logger.error("Error updating countdown message:", error); - } - } - - if (remaining <= 0) { - clearInterval(countdownData.interval); - - const finishedEmbed = successEmbed( - `โฑ๏ธ ${countdownData.title} (Finished!)`, - "รขยยฐ Time's up!", - ); - - await countdownData.message.edit({ - embeds: [finishedEmbed], - components: [], - }); - - cleanupCountdown(countdownId); - } - } catch (error) { - logger.error("Countdown update error:", error); - cleanupCountdown(countdownId); - } - }, 100); -} - -function cleanupCountdown(countdownId) { - const countdownData = activeCountdowns.get(countdownId); - if (countdownData) { - clearInterval(countdownData.interval); - activeCountdowns.delete(countdownId); - } -} - -function formatTime(seconds) { - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - - return [ - h > 0 ? h.toString().padStart(2, "0") : null, - m.toString().padStart(2, "0"), - s.toString().padStart(2, "0"), - ] - .filter(Boolean) - .join(":"); -} - - - - diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.js index 201dca700b..64a77d4563 100644 --- a/src/commands/Tools/generatepassword.js +++ b/src/commands/Tools/generatepassword.js @@ -35,7 +35,7 @@ export default { if (length < 8 || length > 50) { await interaction.reply({ - embeds: [errorEmbed('Error', 'Password length must be between 8 and 50 characters.')], + embeds: [errorEmbed('โŒ Invalid Length', 'Password must be 8-50 characters. You provided: ' + length)], flags: MessageFlags.Ephemeral }); return; diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.js index 85e8a084ef..c1f8eaaf00 100644 --- a/src/commands/Tools/hexcolor.js +++ b/src/commands/Tools/hexcolor.js @@ -24,8 +24,8 @@ try { } else { hexColor = hexColor.replace('#', ''); if (!/^[0-9A-Fa-f]{3,6}$/.test(hexColor)) { - return interaction.editReply({ - embeds: [errorEmbed('Error', 'Please provide a valid hex color code (e.g., #FF5733 or FF5733)')], + return interaction.reply({ + embeds: [errorEmbed('โŒ Invalid Hex Color', 'Please provide a valid hex code.\n\n**Valid formats:**\nโ€ข `#FF5733` (with hash)\nโ€ข `FF5733` (without hash)\nโ€ข `F57` (3-digit shorthand)\n\n**Invalid:** `#GG5733` (G is not a hex digit)')], flags: ["Ephemeral"] }); } @@ -62,7 +62,7 @@ try { embed.setFooter({ text: 'โœจ Randomly generated color' }); } - await interaction.editReply({ embeds: [embed] }); + await interaction.reply({ embeds: [embed] }); } catch (error) { await handleInteractionError(interaction, error, { diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.js index 27efb3ccfb..520cdfdd99 100644 --- a/src/commands/Tools/poll.js +++ b/src/commands/Tools/poll.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const EMOJIS = ['1๏ธโƒฃ', '2๏ธโƒฃ', '3๏ธโƒฃ', '4๏ธโƒฃ', '5๏ธโƒฃ', '6๏ธโƒฃ', '7๏ธโƒฃ', '8๏ธโƒฃ', '9๏ธโƒฃ', '๐Ÿ”Ÿ']; const MAX_OPTIONS = 10; export default { @@ -59,6 +60,16 @@ export default { .setRequired(false)), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) { + logger.warn(`Poll interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'poll' + }); + return; + } + try { const question = interaction.options.getString('question'); const isAnonymous = interaction.options.getBoolean('anonymous') || false; @@ -96,9 +107,8 @@ export default { await new Promise(resolve => setTimeout(resolve, 500)); } - await interaction.reply({ + await interaction.editReply({ content: 'โœ… Poll created successfully!', - flags: MessageFlags.Ephemeral }); } catch (error) { await handleInteractionError(interaction, error, { diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.js index 80c32e0dbd..d610db34b7 100644 --- a/src/commands/Tools/randomuser.js +++ b/src/commands/Tools/randomuser.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -26,11 +27,20 @@ export default { .setRequired(false)), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`RandomUser interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'randomuser' + }); + return; + } + try { if (!interaction.guild) { - return interaction.reply({ - embeds: [errorEmbed('Error', 'This command can only be used in a server.')], - flags: ["Ephemeral"] + return interaction.editReply({ + embeds: [errorEmbed('โŒ Server Only', 'This command can only be used in a server/guild.')], }); } @@ -56,12 +66,13 @@ try { } if (memberArray.length === 0) { - let errorMessage = 'No users found matching the criteria.'; - if (role) errorMessage += ` No users have the ${role.name} role.`; - if (onlineOnly) errorMessage += ' No online users found.'; + let errorMessage = 'Could not find any users matching your filters:'; + if (role) errorMessage = `No users have the **${role.name}** role.`; + if (onlineOnly) errorMessage = 'No users are currently online.'; + if (role && onlineOnly) errorMessage = `No **${role.name}** members are online.`; return interaction.editReply({ - embeds: [errorEmbed('No Users Found', errorMessage)], + embeds: [errorEmbed('โŒ No Users Found', errorMessage + '\n\nTry adjusting your filters.')], flags: ["Ephemeral"] }); } @@ -87,7 +98,7 @@ try { { name: '๐Ÿค– Bot', value: user.bot ? 'Yes' : 'No', inline: true }, { name: `๐ŸŽญ Roles (${roles.length})`, value: roles.length > 0 ? roles.slice(0, 5).join(' ') + (roles.length > 5 ? ` +${roles.length - 5} more` : '') : 'No roles', inline: false } ) - .setColor(selectedMember.displayHexColor || '#3498db'); + .setColor('primary'); const row = new ActionRowBuilder() .addComponents( diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js index fabd9830a1..7bf43ccbb8 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -24,6 +25,16 @@ export default { category: "Tools", async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Shorten interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'shorten' + }); + return; + } + try { const url = interaction.options.getString("url"); const custom = interaction.options.getString("custom"); @@ -33,18 +44,16 @@ export default { } catch (e) { const embed = errorEmbed("Invalid URL", "Invalid URL format. Include http:// or https://"); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { const embed = errorEmbed("Invalid Custom URL", "Custom URL can only contain letters, numbers, underscores, and hyphens."); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } @@ -62,29 +71,26 @@ export default { if (shortUrl.includes("already exists")) { const embed = errorEmbed("URL Already Taken", "That custom URL is already taken. Try a different one."); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } else if (shortUrl.includes("invalid")) { const embed = errorEmbed("Invalid URL", "Invalid URL. Include http:// or https://"); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } const embed = errorEmbed("URL Shortening Failed", `URL shortening failed: ${shortUrl}`); embed.setColor(getColor('error')); - return interaction.reply({ + return interaction.editReply({ embeds: [embed], - flags: ['Ephemeral'] }); } const embed = successEmbed("URL Shortened", `Here's your shortened URL: ${shortUrl}`); embed.setColor(getColor('success')); - await interaction.reply({ + await interaction.editReply({ embeds: [embed], }); } catch (error) { diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js index b918da6f0d..108837f1ae 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.js @@ -4,6 +4,7 @@ import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getGuildConfig } from '../../services/guildConfig.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("report") @@ -31,6 +32,16 @@ export default { async execute(interaction, config, client) { try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Report interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'report' + }); + return; + } + const targetUser = interaction.options.getUser("user"); const reason = interaction.options.getString("reason"); const guildId = interaction.guildId; diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js index b48c60173f..7e796e753a 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/serverinfo.js @@ -1,7 +1,8 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -10,6 +11,16 @@ export default { async execute(interaction) { try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`ServerInfo interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'serverinfo' + }); + return; + } + const guild = interaction.guild; const owner = await guild.fetchOwner(); diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js index 0e8a1e8c02..1d2c856d4d 100644 --- a/src/commands/Utility/todo.js +++ b/src/commands/Utility/todo.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { getFromDb, setInDb } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import crypto from 'crypto'; function generateShareId() { @@ -111,6 +112,23 @@ export default { .setRequired(true) ) ) + .addSubcommand(subcommand => + subcommand + .setName("remove") + .setDescription("Remove a task from a shared to-do list") + .addStringOption(option => + option + .setName("list_id") + .setDescription("ID of the shared list") + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName("number") + .setDescription("The number of the task to remove") + .setRequired(true) + ) + ) ) .setDMPermission(false) .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), @@ -152,6 +170,16 @@ export default { } try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Todo interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'todo' + }); + return; + } + if (shareSubcommand) { switch (shareSubcommand) { case 'create': { @@ -264,7 +292,11 @@ export default { new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) .setLabel('Complete Task') - .setStyle(ButtonStyle.Success) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) ) ] }); @@ -304,7 +336,11 @@ export default { new ButtonBuilder() .setCustomId(`shared_todo_complete_${listId}`) .setLabel('Complete Task') - .setStyle(ButtonStyle.Success) + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) ) ] }); @@ -345,6 +381,41 @@ export default { ] }); } + + case 'remove': { + const listId = interaction.options.getString('list_id'); + const taskNumber = interaction.options.getInteger('number'); + + const listData = await getOrCreateSharedList(listId); + + if (!listData) { + return await interaction.editReply({ + embeds: [errorEmbed("Error", "Shared list not found.")] + }); + } + + if (!listData.members.includes(userId)) { + return await interaction.editReply({ + embeds: [errorEmbed("Error", "You don't have access to this list.")] + }); + } + + const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); + if (taskIndex === -1) { + return await interaction.editReply({ + embeds: [errorEmbed("Error", "Task not found.")] + }); + } + + const [removedTask] = listData.tasks.splice(taskIndex, 1); + await setInDb(`shared_todo_${listId}`, listData); + + return await interaction.editReply({ + embeds: [ + successEmbed("Task Removed", `Removed "${removedTask.text}" from the shared list "${listData.name}".`) + ] + }); + } } return; } @@ -413,6 +484,12 @@ export default { embeds: [errorEmbed("Error", "Task not found.")], }); } + + if (task.completed) { + return await interaction.editReply({ + embeds: [errorEmbed("Task Already Completed", `Task #${task.id} is already completed.`)], + }); + } task.completed = true; await setInDb(`todo_${userId}`, userData); diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js index e43816ce6c..54e6a98317 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/userinfo.js @@ -1,7 +1,8 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("userinfo") @@ -14,6 +15,16 @@ export default { async execute(interaction) { try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`UserInfo interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'userinfo' + }); + return; + } + const user = interaction.options.getUser("target") || interaction.user; const member = interaction.guild.members.cache.get(user.id); diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js index d56a37e3d4..5005747b7b 100644 --- a/src/commands/Utility/weather.js +++ b/src/commands/Utility/weather.js @@ -2,6 +2,7 @@ import { SlashCommandBuilder } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search"; const WEATHER_URL = "https://api.open-meteo.com/v1/forecast"; @@ -18,6 +19,16 @@ export default { async execute(interaction) { try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Weather interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'weather' + }); + return; + } + const city = interaction.options.getString("city"); const geoResponse = await fetch( diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js index 0ef5b54d28..265c38dcce 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.js @@ -3,6 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, Me import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -31,6 +32,16 @@ export default { .setDescription('List all auto-assigned roles')), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Autorole interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'autorole' + }); + return; + } + const { options, guild, client } = interaction; const subcommand = options.getSubcommand(); diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 467c3945d8..4d3fd5c696 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -4,6 +4,7 @@ import { createEmbed, errorEmbed } from '../../utils/embeds.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -33,6 +34,16 @@ export default { .setDescription('Enable or disable goodbye messages')), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Goodbye interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'goodbye' + }); + return; + } + const { options, guild, client } = interaction; const subcommand = options.getSubcommand(); diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 12d486725c..0e057e8ff9 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -4,6 +4,7 @@ import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -37,6 +38,21 @@ export default { .setDescription('Enable or disable welcome messages')), async execute(interaction) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Welcome interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'welcome' + }); + return; + } + } catch (deferError) { + logger.error(`Welcome defer error`, { error: deferError.message }); + return; + } + const { options, guild, client } = interaction; const subcommand = options.getSubcommand(); diff --git a/src/config/bot.js b/src/config/bot.js index b7dd80cdda..444ee9ae45 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -447,12 +447,18 @@ export function getColor(path, fallback = "#99AAB5") { return parseInt(path.replace("#", ""), 16); } - return path + const result = path .split(".") .reduce( (obj, key) => (obj && obj[key] !== undefined ? obj[key] : fallback), botConfig.embeds.colors, ); + + // Convert the result to integer if it's a hex string + if (typeof result === "string" && result.startsWith("#")) { + return parseInt(result.replace("#", ""), 16); + } + return result; } export function getRandomColor() { diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index ce8da82f29..430e38185a 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -91,6 +91,10 @@ const buttonType = parts.slice(0, 3).join('_'); const button = client.buttons.get(customId); if (!button) { + if (!interaction.customId.includes(':')) { + return; + } + throw createError( `No button handler found for ${customId}`, ErrorTypes.CONFIGURATION, @@ -158,6 +162,13 @@ const buttonType = parts.slice(0, 3).join('_'); return; } + // Skip modals that are awaited inline by commands (Join to Create modals) + if (interaction.customId.startsWith('jtc_')) { + // These modals are handled by awaitModalSubmit() in the command + logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`); + return; + } + const [customId, ...args] = interaction.customId.split(':'); const modal = client.modals.get(customId); diff --git a/src/handlers/calculateButtonLoader.js b/src/handlers/calculateButtonLoader.js new file mode 100644 index 0000000000..840d3cfdc9 --- /dev/null +++ b/src/handlers/calculateButtonLoader.js @@ -0,0 +1,11 @@ +import calculateModalHandler from './calculateModals.js'; +import { logger } from '../utils/logger.js'; + +export default async function loadCalculateButtons(client) { + try { + client.modals.set('calc_modal', calculateModalHandler); + logger.info('Calculate modal handlers loaded'); + } catch (error) { + logger.error('Error loading calculate button handlers:', error); + } +} diff --git a/src/handlers/calculateButtons.js b/src/handlers/calculateButtons.js new file mode 100644 index 0000000000..cef9ef86b4 --- /dev/null +++ b/src/handlers/calculateButtons.js @@ -0,0 +1,138 @@ +import { errorEmbed, successEmbed } from '../utils/embeds.js'; +import { logger } from '../utils/logger.js'; + +function evaluate(expression) { + let expr = expression.replace(/\s/g, '').toLowerCase(); + + const math = { + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + sqrt: Math.sqrt, + abs: Math.abs, + log: Math.log, + log10: Math.log10, + exp: Math.exp, + pi: Math.PI, + e: Math.E + }; + + expr = expr.replace(/sin|cos|tan|sqrt|abs|log|log10|exp|pi|e/g, (match) => `math.${match}`); + expr = expr.replace(/(\d+)\s*deg/g, (match, num) => `(${num} * Math.PI / 180)`); + expr = expr.replace(/\^/g, '**'); + + try { + const func = new Function('math', `return ${expr}`); + return func(math); + } catch (error) { + throw new Error(`Invalid expression: ${error.message}`); + } +} + +async function calculateModalHandler(interaction, client, args) { + try { + const operation = args[0]; + const operandInput = interaction.fields.first(); + const contextKey = operandInput?.customId?.split(':')[1]; + + if (!contextKey) { + return await interaction.reply({ + embeds: [errorEmbed('โŒ Error', 'Failed to retrieve calculation context.')], + flags: ['Ephemeral'] + }); + } + + const { calculationContexts } = await import('../commands/Tools/calculate.js'); + const context = calculationContexts.get(contextKey); + + if (!context) { + return await interaction.reply({ + embeds: [errorEmbed('โŒ Expired', 'This calculation has expired. Please start a new calculation.')], + flags: ['Ephemeral'] + }); + } + + await interaction.deferReply({ ephemeral: false }); + + const operand = interaction.fields.getTextInputValue(operandInput.customId); + + if (!operand || isNaN(operand)) { + return await interaction.editReply({ + embeds: [errorEmbed('โŒ Invalid Input', 'Please provide a valid number.')] + }); + } + + const { expression, formattedResult, operator } = context; + const newExpression = `(${expression}) ${operator} (${operand})`; + + let newResult; + try { + newResult = evaluate(newExpression); + + let formattedNewResult; + if (typeof newResult === "number") { + formattedNewResult = newResult.toLocaleString("en-US", { + maximumFractionDigits: 10, + }); + + if ( + Math.abs(newResult) > 0 && + (Math.abs(newResult) >= 1e10 || Math.abs(newResult) < 1e-3) + ) { + formattedNewResult = newResult.toExponential(6); + } + } else { + formattedNewResult = String(newResult); + } + + const updatedEmbed = successEmbed( + "๐Ÿงฎ Calculation Result", + `**Expression:** \`${newExpression.replace(/`/g, "\`")}\`\n` + + `**Result:** \`${formattedNewResult}\`\n\n` + + `*Use the buttons in the channel message to perform more operations.*`, + ); + + try { + if (context.messageId && context.channelId) { + const channel = await client.channels.fetch(context.channelId); + const message = await channel.messages.fetch(context.messageId); + await message.edit({ + embeds: [updatedEmbed], + }); + } + } catch (editError) { + logger.warn('Could not edit original message:', editError.message); + } + + calculationContexts.delete(contextKey); + + await interaction.editReply({ + embeds: [successEmbed('โœ… Calculated', `\`${newExpression}\` = \`${formattedNewResult}\``)], + }); + + } catch (calcError) { + logger.error('Calculate evaluation error:', calcError); + await interaction.editReply({ + embeds: [errorEmbed("โŒ Calculation Error", "Failed to evaluate the expression.")], + }); + } + } catch (error) { + logger.error('Calculate modal handler error:', error); + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred processing your calculation.')], + flags: ['Ephemeral'] + }); + } else { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred processing your calculation.')] + }); + } + } catch (err) { + logger.error('Failed to send error message:', err); + } + } +} + +export default calculateModalHandler; diff --git a/src/handlers/calculateModalLoader.js b/src/handlers/calculateModalLoader.js new file mode 100644 index 0000000000..8df646202a --- /dev/null +++ b/src/handlers/calculateModalLoader.js @@ -0,0 +1,11 @@ +import calculateModalHandler from './calculateModals.js'; +import { logger } from '../utils/logger.js'; + +export default async function loadCalculateModals(client) { + try { + client.modals.set('calc_modal', calculateModalHandler); + logger.info('Calculate modal handlers loaded'); + } catch (error) { + logger.error('Error loading calculate modal handlers:', error); + } +} diff --git a/src/handlers/calculateModals.js b/src/handlers/calculateModals.js new file mode 100644 index 0000000000..b104161e1f --- /dev/null +++ b/src/handlers/calculateModals.js @@ -0,0 +1,140 @@ +import { errorEmbed, successEmbed } from '../utils/embeds.js'; +import { logger } from '../utils/logger.js'; + +function evaluate(expression) { + let expr = expression.replace(/\s/g, '').toLowerCase(); + + const math = { + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + sqrt: Math.sqrt, + abs: Math.abs, + log: Math.log, + log10: Math.log10, + exp: Math.exp, + pi: Math.PI, + e: Math.E + }; + + expr = expr.replace(/sin|cos|tan|sqrt|abs|log|log10|exp|pi|e/g, (match) => `math.${match}`); + expr = expr.replace(/(\d+)\s*deg/g, (match, num) => `(${num} * Math.PI / 180)`); + expr = expr.replace(/\^/g, '**'); + + try { + const func = new Function('math', `return ${expr}`); + return func(math); + } catch (error) { + throw new Error(`Invalid expression: ${error.message}`); + } +} + +async function calculateModalHandler(interaction, client, args) { + try { + const operation = args[0]; + const operandInput = interaction.fields.first(); + const contextKey = operandInput?.customId?.split(':')[1]; + + if (!contextKey) { + return await interaction.reply({ + embeds: [errorEmbed('โŒ Error', 'Failed to retrieve calculation context.')], + flags: ['Ephemeral'] + }); + } + + const { calculationContexts } = await import('../commands/Tools/calculate.js'); + const context = calculationContexts.get(contextKey); + + if (!context) { + return await interaction.reply({ + embeds: [errorEmbed('โŒ Expired', 'This calculation has expired. Please start a new calculation.')], + flags: ['Ephemeral'] + }); + } + + await interaction.deferReply({ ephemeral: false }); + + const operand = interaction.fields.getTextInputValue(operandInput.customId); + + if (!operand || isNaN(operand)) { + return await interaction.editReply({ + embeds: [errorEmbed('โŒ Invalid Input', 'Please provide a valid number.')] + }); + } + + const { expression, formattedResult, operator } = context; + const newExpression = `(${expression}) ${operator} (${operand})`; + + let newResult; + try { + newResult = evaluate(newExpression); + + let formattedNewResult; + if (typeof newResult === "number") { + formattedNewResult = newResult.toLocaleString("en-US", { + maximumFractionDigits: 10, + }); + + if ( + Math.abs(newResult) > 0 && + (Math.abs(newResult) >= 1e10 || Math.abs(newResult) < 1e-3) + ) { + formattedNewResult = newResult.toExponential(6); + } + } else { + formattedNewResult = String(newResult); + } + + const updatedEmbed = successEmbed( + "๐Ÿงฎ Calculation Result", + `**Expression:** \`${newExpression.replace(/`/g, "\`")}\`\n` + + `**Result:** \`${formattedNewResult}\`\n\n` + + `*Use the buttons in the channel message to perform more operations.*`, + ); + + try { + if (context.messageId && context.channelId) { + const channel = await client.channels.fetch(context.channelId); + const message = await channel.messages.fetch(context.messageId); + await message.edit({ + embeds: [updatedEmbed], + }); + } + } catch (editError) { + logger.warn('Could not edit original message:', editError.message); + } + + calculationContexts.delete(contextKey); + + await interaction.editReply({ + embeds: [successEmbed('โœ… Calculated', `\`${newExpression}\` = \`${formattedNewResult}\``)], + }); + + } catch (calcError) { + logger.error('Calculate evaluation error:', calcError); + await interaction.editReply({ + embeds: [errorEmbed("โŒ Calculation Error", "Failed to evaluate the expression.")], + }); + } + } catch (error) { + logger.error('Calculate modal handler error:', error); + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred processing your calculation.')], + flags: ['Ephemeral'] + }); + } else { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred processing your calculation.')] + }); + } + } catch (err) { + logger.error('Failed to send error message:', err); + } + } +} + +export default { + execute: calculateModalHandler +}; diff --git a/src/handlers/countdownButtonLoader.js b/src/handlers/countdownButtonLoader.js new file mode 100644 index 0000000000..44b91b6aaa --- /dev/null +++ b/src/handlers/countdownButtonLoader.js @@ -0,0 +1,13 @@ +import countdownButtonHandler from './countdownButtons.js'; +import { logger } from '../utils/logger.js'; + +export default async function loadCountdownButtons(client) { + try { + // Register both pause and cancel button handlers under countdown_pause and countdown_cancel + client.buttons.set('countdown_pause', countdownButtonHandler); + client.buttons.set('countdown_cancel', countdownButtonHandler); + logger.info('Countdown button handlers loaded'); + } catch (error) { + logger.error('Error loading countdown button handlers:', error); + } +} diff --git a/src/handlers/countdownButtons.js b/src/handlers/countdownButtons.js new file mode 100644 index 0000000000..2b5e916ddb --- /dev/null +++ b/src/handlers/countdownButtons.js @@ -0,0 +1,194 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; +import { successEmbed, errorEmbed } from '../utils/embeds.js'; +import { logger } from '../utils/logger.js'; + +function createControlButtons(countdownId, isPaused = false) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`countdown_pause:${countdownId}`) + .setLabel(isPaused ? "โ–ถ๏ธ Resume" : "โธ๏ธ Pause") + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`countdown_cancel:${countdownId}`) + .setLabel("โŒ Cancel") + .setStyle(ButtonStyle.Danger), + ); +} + +function formatTime(seconds) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + + return [ + h > 0 ? h.toString().padStart(2, "0") : null, + m.toString().padStart(2, "0"), + s.toString().padStart(2, "0"), + ] + .filter(Boolean) + .join(":"); +} + +function startCountdown(countdownId, countdownData, activeCountdowns) { + if (countdownData.interval) { + clearInterval(countdownData.interval); + countdownData.interval = null; + } + + logger.info(`Countdown started: ${countdownData.title} (${countdownData.remainingTime / 1000}s remaining)`); + + countdownData.interval = setInterval(async () => { + try { + if (countdownData.isPaused) return; + + const now = Date.now(); + const remaining = Math.max(0, countdownData.endTime - now); + countdownData.remainingTime = remaining; + + if (now - countdownData.lastUpdate >= 1000) { + countdownData.lastUpdate = now; + + const embed = successEmbed( + `โฑ๏ธ ${countdownData.title}`, + `Time remaining: **${formatTime(Math.ceil(remaining / 1000))}**`, + ); + + try { + await countdownData.message.edit({ + embeds: [embed], + components: [ + createControlButtons( + countdownId, + countdownData.isPaused, + ), + ], + }); + } catch (error) { + logger.error("Error updating countdown message:", error); + } + } + + if (remaining <= 0) { + clearInterval(countdownData.interval); + + const finishedEmbed = successEmbed( + `โฑ๏ธ ${countdownData.title} (Finished!)`, + "โฐ Time's up!", + ); + + await countdownData.message.edit({ + embeds: [finishedEmbed], + components: [], + }); + + cleanupCountdown(countdownId, activeCountdowns); + } + } catch (error) { + logger.error("Countdown update error:", error); + cleanupCountdown(countdownId, activeCountdowns); + } + }, 100); +} + +function cleanupCountdown(countdownId, activeCountdowns) { + const countdownData = activeCountdowns.get(countdownId); + if (countdownData) { + clearInterval(countdownData.interval); + activeCountdowns.delete(countdownId); + } +} + +async function countdownButtonHandler(interaction, client, args) { + try { + const { activeCountdowns } = await import('../commands/Tools/countdown.js'); + const action = args[0]; + const countdownId = args[1]; + + const countdownData = activeCountdowns.get(countdownId); + if (!countdownData) { + return await interaction.reply({ + content: "This countdown has expired or was cancelled.", + flags: ["Ephemeral"], + }); + } + + if (!interaction.member.permissions.has("MANAGE_MESSAGES")) { + return await interaction.reply({ + content: 'You need the "Manage Messages" permission to control countdowns.', + flags: ["Ephemeral"], + }); + } + + switch (action) { + case "pause": + if (countdownData.isPaused) { + countdownData.isPaused = false; + countdownData.endTime = Date.now() + countdownData.remainingTime; + startCountdown(countdownId, countdownData, activeCountdowns); + + const currentEmbed = countdownData.message.embeds[0]; + await countdownData.message.edit({ + embeds: [currentEmbed], + components: [createControlButtons(countdownId, false)], + }); + + await interaction.reply({ + content: "โ–ถ๏ธ Countdown resumed!", + flags: ["Ephemeral"], + }); + } else { + clearInterval(countdownData.interval); + countdownData.isPaused = true; + countdownData.remainingTime = countdownData.endTime - Date.now(); + + const currentEmbed = countdownData.message.embeds[0]; + await countdownData.message.edit({ + embeds: [currentEmbed], + components: [createControlButtons(countdownId, true)], + }); + + await interaction.reply({ + content: "โธ๏ธ Countdown paused!", + flags: ["Ephemeral"], + }); + } + break; + + case "cancel": + clearInterval(countdownData.interval); + + const embed = successEmbed( + `โฑ๏ธ ${countdownData.title} (Cancelled)`, + "The countdown was cancelled.", + ); + + await countdownData.message.edit({ + embeds: [embed], + components: [], + }); + + cleanupCountdown(countdownId, activeCountdowns); + + await interaction.reply({ + content: "โŒ Countdown cancelled!", + flags: ["Ephemeral"], + }); + break; + } + } catch (error) { + logger.error('Countdown button handler error:', error); + try { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred controlling the countdown.')], + flags: ['Ephemeral'] + }); + } + } catch (err) { + logger.error('Failed to send error message:', err); + } + } +} + +export { createControlButtons, formatTime, startCountdown, cleanupCountdown, countdownButtonHandler }; +export default countdownButtonHandler; diff --git a/src/handlers/counterButtonLoader.js b/src/handlers/counterButtonLoader.js new file mode 100644 index 0000000000..c755f4a2c6 --- /dev/null +++ b/src/handlers/counterButtonLoader.js @@ -0,0 +1,11 @@ +import counterDeleteActionHandler from './counterButtons.js'; +import { logger } from '../utils/logger.js'; + +export default async function loadCounterButtons(client) { + try { + client.buttons.set(counterDeleteActionHandler.name, counterDeleteActionHandler); + logger.info('Counter button handlers loaded'); + } catch (error) { + logger.error('Error loading counter button handlers:', error); + } +} diff --git a/src/handlers/counterButtons.js b/src/handlers/counterButtons.js new file mode 100644 index 0000000000..88380289ce --- /dev/null +++ b/src/handlers/counterButtons.js @@ -0,0 +1,95 @@ +import { MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed } from '../utils/embeds.js'; +import { performDeletionByCounterId } from '../commands/Counter/modules/counter_delete.js'; +import { logger } from '../utils/logger.js'; + +export const counterDeleteActionHandler = { + name: 'counter-delete', + async execute(interaction, client, args = []) { + try { + // Defer update immediately to ensure interaction is acknowledged + try { + await interaction.deferUpdate(); + } catch (error) { + logger.error("Failed to defer button interaction:", error); + return; + } + + const [action, counterId, ownerId] = args; + + if (!interaction.inGuild()) { + await interaction.editReply({ + embeds: [errorEmbed('Guild Only', 'This action can only be used in a server.')], + components: [] + }).catch(logger.error); + return; + } + + if (!action || !counterId) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Action', 'Counter delete action data is missing.')], + components: [] + }).catch(logger.error); + return; + } + + if (ownerId && interaction.user.id !== ownerId) { + await interaction.editReply({ + embeds: [errorEmbed('Not Allowed', 'Only the user who initiated this deletion can use these buttons.')], + components: [] + }).catch(logger.error); + return; + } + + if (action === 'cancel') { + await interaction.editReply({ + embeds: [createEmbed({ + title: 'โŒ Cancelled', + description: 'Counter deletion cancelled.', + color: 'error' + })], + components: [] + }).catch(logger.error); + return; + } + + if (action !== 'confirm') { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Action', 'Unknown counter delete action.')], + components: [] + }).catch(logger.error); + return; + } + + const result = await performDeletionByCounterId(client, interaction.guild, counterId); + + if (!result.success) { + await interaction.editReply({ + embeds: [errorEmbed(result.message)], + components: [] + }).catch(logger.error); + return; + } + + await interaction.editReply({ + embeds: [successEmbed(result.message)], + components: [] + }).catch(logger.error); + } catch (error) { + logger.error('Error handling counter-delete button:', error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while processing this action.')], + flags: MessageFlags.Ephemeral + }).catch(() => null); + } else { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while processing this action.')], + components: [] + }).catch(() => null); + } + } + } +}; + +export default counterDeleteActionHandler; diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js index 11b56dbaeb..2bb6555191 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/helpSelectMenus.js @@ -13,6 +13,9 @@ const BACK_BUTTON_ID = "help-back-to-main"; const ALL_COMMANDS_ID = "help-all-commands"; const PAGINATION_PREFIX = "help-page"; const CATEGORY_SELECT_ID = "help-category-select"; +const FOOTER_TEXT = "Made with โค๏ธ"; +const SUBCOMMAND_TYPE = 1; +const SUBCOMMAND_GROUP_TYPE = 2; const CATEGORY_ICONS = { Core: "โ„น๏ธ", @@ -33,6 +36,79 @@ const CATEGORY_ICONS = { Config: "โš™๏ธ", }; +function buildHelpEntries(command, category) { + const commandData = normalizeCommandData(command); + if (!commandData?.name) { + return []; + } + + const baseName = commandData.name; + const baseDescription = commandData.description || "No description"; + const options = commandData.options || []; + + const entries = []; + + for (const option of options) { + if (!option) continue; + + if (option.type === SUBCOMMAND_TYPE) { + entries.push({ + baseName, + displayName: `${baseName} ${option.name}`, + description: option.description || baseDescription, + category, + }); + continue; + } + + if (option.type === SUBCOMMAND_GROUP_TYPE) { + const nestedOptions = option.options || []; + for (const nested of nestedOptions) { + if (nested?.type !== SUBCOMMAND_TYPE) continue; + + entries.push({ + baseName, + displayName: `${baseName} ${option.name} ${nested.name}`, + description: nested.description || option.description || baseDescription, + category, + }); + } + } + } + + if (entries.length === 0) { + entries.push({ + baseName, + displayName: baseName, + description: baseDescription, + category, + }); + } + + return entries; +} + +function normalizeCommandData(command) { + const rawData = command?.data; + if (!rawData) { + return null; + } + + const jsonData = typeof rawData.toJSON === 'function' ? rawData.toJSON() : rawData; + if (!jsonData?.name) { + return null; + } + + return { + ...jsonData, + options: Array.isArray(jsonData.options) + ? jsonData.options.map((option) => + typeof option?.toJSON === 'function' ? option.toJSON() : option, + ) + : [], + }; +} + async function createCategoryCommandsMenu(category, client) { const categoryName = category.charAt(0).toUpperCase() + category.slice(1).toLowerCase(); @@ -50,19 +126,16 @@ async function createCategoryCommandsMenu(category, client) { const filePath = path.join(categoryPath, file); const commandModule = await import(`file://${filePath}`); const command = commandModule.default; + const commandData = normalizeCommandData(command); - if (command && command.data && command.data.name) { + if (commandData) { if ( - command.data.name === "help" || - command.data.name === "commandlist" + commandData.name === "help" || + commandData.name === "commandlist" ) continue; - categoryCommands.push({ - name: command.data.name, - description: command.data.description || "No description", - options: command.data.options || [], - }); + categoryCommands.push(...buildHelpEntries(command, categoryName)); } } } catch (error) { @@ -72,7 +145,7 @@ async function createCategoryCommandsMenu(category, client) { ); } - categoryCommands.sort((a, b) => a.name.localeCompare(b.name)); + categoryCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); let registeredCommands = new Collection(); try { @@ -96,11 +169,11 @@ async function createCategoryCommandsMenu(category, client) { if (categoryCommands.length > 0) { const commandMentions = categoryCommands .map((cmd) => { - const registeredCmd = registeredCommands.get(cmd.name); + const registeredCmd = registeredCommands.get(cmd.baseName); if (registeredCmd && registeredCmd.id) { - return ` ยท ${cmd.description}`; + return ` ยท ${cmd.description}`; } - return `\`/${cmd.name}\` ยท ${cmd.description}`; + return `\`/${cmd.displayName}\` ยท ${cmd.description}`; }) .join("\n"); @@ -136,6 +209,9 @@ async function createCategoryCommandsMenu(category, client) { } } + embed.setFooter({ text: FOOTER_TEXT }); + embed.setTimestamp(); + const backButton = createButton( BACK_BUTTON_ID, "Back", @@ -179,22 +255,20 @@ export async function createAllCommandsMenu(page = 1, client) { const filePath = path.join(categoryPath, file); const commandModule = await import(`file://${filePath}`); const command = commandModule.default; + const commandData = normalizeCommandData(command); - if (command && command.data && command.data.name) { + if (commandData) { if ( - command.data.name === "help" || - command.data.name === "commandlist" + commandData.name === "help" || + commandData.name === "commandlist" ) continue; - allCommands.push({ - name: command.data.name, - description: - command.data.description || "No description", - category: - category.charAt(0).toUpperCase() + - category.slice(1).toLowerCase(), - }); + const categoryName = + category.charAt(0).toUpperCase() + + category.slice(1).toLowerCase(); + + allCommands.push(...buildHelpEntries(command, categoryName)); } } } catch (error) { @@ -205,7 +279,7 @@ export async function createAllCommandsMenu(page = 1, client) { } } - allCommands.sort((a, b) => a.name.localeCompare(b.name)); + allCommands.sort((a, b) => a.displayName.localeCompare(b.displayName)); let registeredCommands = new Collection(); try { @@ -226,16 +300,19 @@ export async function createAllCommandsMenu(page = 1, client) { const embed = createEmbed({ title: "๐Ÿ“‹ All Commands", - description: `(${allCommands.length}+ total commands)` + description: `(${allCommands.length} total commands, including subcommands)` }); + embed.setFooter({ text: FOOTER_TEXT }); + embed.setTimestamp(); + if (pageCommands.length > 0) { const commandMentions = pageCommands.map((cmd) => { - const registeredCmd = registeredCommands.get(cmd.name); + const registeredCmd = registeredCommands.get(cmd.baseName); if (registeredCmd && registeredCmd.id) { - return ` ยท ${cmd.category}`; + return ` ยท ${cmd.category}`; } - return `\`/${cmd.name}\` ยท ${cmd.category}`; + return `\`/${cmd.displayName}\` ยท ${cmd.category}`; }); const columnCount = pageCommands.length > 20 ? 3 : (pageCommands.length > 10 ? 2 : 1); diff --git a/src/handlers/todoButtonLoader.js b/src/handlers/todoButtonLoader.js index f2f3f31e39..f6caf78031 100644 --- a/src/handlers/todoButtonLoader.js +++ b/src/handlers/todoButtonLoader.js @@ -1,4 +1,4 @@ -import todoAddHandler, { sharedTodoCompleteHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler } from './todoButtons.js'; +import todoAddHandler, { sharedTodoCompleteHandler, sharedTodoRemoveHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler } from './todoButtons.js'; import { logger } from '../utils/logger.js'; export default async function loadTodoButtons(client) { @@ -6,9 +6,11 @@ export default async function loadTodoButtons(client) { client.buttons.set('shared_todo_add', todoAddHandler); client.buttons.set('shared_todo_complete', sharedTodoCompleteHandler); + client.buttons.set('shared_todo_remove', sharedTodoRemoveHandler); client.modals.set('shared_todo_add_modal', sharedTodoAddModalHandler); client.modals.set('shared_todo_complete_modal', sharedTodoCompleteModalHandler); + client.modals.set('shared_todo_remove_modal', sharedTodoRemoveModalHandler); logger.info('Todo button handlers loaded'); } catch (error) { diff --git a/src/handlers/todoButtons.js b/src/handlers/todoButtons.js index 4539699568..baf67ee51d 100644 --- a/src/handlers/todoButtons.js +++ b/src/handlers/todoButtons.js @@ -1,13 +1,122 @@ -import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { errorEmbed, successEmbed } from '../utils/embeds.js'; import { getFromDb, setInDb } from '../utils/database.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { logger } from '../utils/logger.js'; +function buildSharedTodoViewPayload(listData, listId, guild) { + const memberList = (listData.members || []).map(memberId => { + const member = guild?.members?.cache?.get(memberId); + return member ? member.user.username : `<@${memberId}>`; + }).join(', '); + + const owner = guild?.members?.cache?.get(listData.creatorId); + const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; + + const tasks = Array.isArray(listData.tasks) ? listData.tasks : []; + + if (tasks.length === 0) { + return { + embeds: [ + successEmbed( + `๐Ÿ“‹ **${listData.name}**\n\n` + + `๐Ÿ‘‘ **Owner:** ${ownerName}\n` + + `๐Ÿ‘ฅ **Members:** ${memberList}\n\n` + + '*This list is currently empty. Use the "Add Task" button to add tasks!*', + `Shared List (ID: \`${listId}\`)` + ) + ], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`shared_todo_add_${listId}`) + .setLabel('Add Task') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`shared_todo_complete_${listId}`) + .setLabel('Complete Task') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) + ) + ] + }; + } + + const taskList = tasks + .map(task => + `${task.completed ? 'โœ…' : '๐Ÿ“'} #${task.id} ${task.text} ` + + `\`[${new Date(task.createdAt).toLocaleDateString()}]` + + (task.completed ? ` โ€ข Completed by <@${task.completedBy}>` : '') + '`' + ) + .join('\n'); + + return { + embeds: [ + successEmbed( + `๐Ÿ“‹ **${listData.name}**\n\n` + + `๐Ÿ‘‘ **Owner:** ${ownerName}\n` + + `๐Ÿ‘ฅ **Members:** ${memberList}\n\n` + + `**Tasks:**\n${taskList}`, + `Shared List (ID: \`${listId}\`)` + ) + ], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`shared_todo_add_${listId}`) + .setLabel('Add Task') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`shared_todo_complete_${listId}`) + .setLabel('Complete Task') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`shared_todo_remove_${listId}`) + .setLabel('Remove Task') + .setStyle(ButtonStyle.Danger) + ) + ] + }; +} + +async function refreshSharedTodoMessage(interaction, listId, messageId) { + if (!messageId || !interaction.channel) { + return; + } + + const listKey = `shared_todo_${listId}`; + const listData = await getFromDb(listKey, null); + if (!listData) { + return; + } + + try { + const targetMessage = await interaction.channel.messages.fetch(messageId); + if (!targetMessage) { + return; + } + + const updatedPayload = buildSharedTodoViewPayload(listData, listId, interaction.guild); + await targetMessage.edit(updatedPayload); + } catch (error) { + logger.warn('Unable to refresh shared todo view message', { + listId, + messageId, + guildId: interaction.guildId, + channelId: interaction.channelId, + error: error.message + }); + } +} + const sharedTodoAddHandler = { name: 'shared_todo_add', async execute(interaction, client, args) { const listId = args[0]; + const sourceMessageId = interaction.message?.id; if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { await interaction.reply({ @@ -18,7 +127,7 @@ const sharedTodoAddHandler = { } const modal = new ModalBuilder() - .setCustomId(`shared_todo_add_modal:${listId}`) + .setCustomId(`shared_todo_add_modal:${listId}:${sourceMessageId || ''}`) .setTitle('Add Task to Shared List'); const taskInput = new TextInputBuilder() @@ -39,6 +148,7 @@ const sharedTodoCompleteHandler = { name: 'shared_todo_complete', async execute(interaction, client, args) { const listId = args[0]; + const sourceMessageId = interaction.message?.id; if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { await interaction.reply({ @@ -49,7 +159,7 @@ const sharedTodoCompleteHandler = { } const modal = new ModalBuilder() - .setCustomId(`shared_todo_complete_modal:${listId}`) + .setCustomId(`shared_todo_complete_modal:${listId}:${sourceMessageId || ''}`) .setTitle('Complete Task in Shared List'); const taskIdInput = new TextInputBuilder() @@ -66,10 +176,43 @@ const sharedTodoCompleteHandler = { } }; +const sharedTodoRemoveHandler = { + name: 'shared_todo_remove', + async execute(interaction, client, args) { + const listId = args[0]; + const sourceMessageId = interaction.message?.id; + + if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'Invalid shared list ID.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId(`shared_todo_remove_modal:${listId}:${sourceMessageId || ''}`) + .setTitle('Remove Task from Shared List'); + + const taskIdInput = new TextInputBuilder() + .setCustomId('task_id') + .setLabel('Enter the task ID to remove') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setPlaceholder('e.g., 1, 2, 3'); + + const actionRow = new ActionRowBuilder().addComponents(taskIdInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + } +}; + const sharedTodoAddModalHandler = { name: 'shared_todo_add_modal', async execute(interaction, client, args) { const listId = args[0]; + const sourceMessageId = args[1] || null; const taskText = interaction.fields.getTextInputValue('task_text'); const userId = interaction.user.id; @@ -127,6 +270,8 @@ const sharedTodoAddModalHandler = { listData.tasks.push(newTask); await setInDb(listKey, listData); + await refreshSharedTodoMessage(interaction, listId, sourceMessageId); + return interaction.reply({ embeds: [successEmbed("Task Added", `Added "${taskText}" to the shared list.`)], flags: MessageFlags.Ephemeral @@ -146,6 +291,7 @@ const sharedTodoCompleteModalHandler = { name: 'shared_todo_complete_modal', async execute(interaction, client, args) { const listId = args[0]; + const sourceMessageId = args[1] || null; const taskId = parseInt(interaction.fields.getTextInputValue('task_id'), 10); const userId = interaction.user.id; @@ -199,12 +345,21 @@ const sharedTodoCompleteModalHandler = { flags: MessageFlags.Ephemeral }); } + + if (task.completed) { + return interaction.reply({ + embeds: [errorEmbed("Task Already Completed", `Task #${task.id} is already completed.`)], + flags: MessageFlags.Ephemeral + }); + } task.completed = true; task.completedBy = userId; task.completedAt = new Date().toISOString(); await setInDb(listKey, listData); + + await refreshSharedTodoMessage(interaction, listId, sourceMessageId); return interaction.reply({ embeds: [successEmbed("Task Completed", `Marked "${task.text}" as complete!`)], @@ -221,8 +376,87 @@ const sharedTodoCompleteModalHandler = { } }; +const sharedTodoRemoveModalHandler = { + name: 'shared_todo_remove_modal', + async execute(interaction, client, args) { + const listId = args[0]; + const sourceMessageId = args[1] || null; + const taskId = parseInt(interaction.fields.getTextInputValue('task_id'), 10); + const userId = interaction.user.id; + + try { + const allowed = await checkRateLimit(`${userId}:shared_todo_remove`, 5, 30000); + if (!allowed) { + return interaction.reply({ + embeds: [errorEmbed('Rate Limited', 'You are removing tasks too quickly. Please wait and try again.')], + flags: MessageFlags.Ephemeral + }); + } + + if (!listId || !/^[a-zA-Z0-9_-]{1,64}$/.test(listId)) { + return interaction.reply({ + embeds: [errorEmbed('Error', 'Invalid shared list ID.')], + flags: MessageFlags.Ephemeral + }); + } + + if (!Number.isInteger(taskId) || taskId <= 0) { + return interaction.reply({ + embeds: [errorEmbed('Error', 'Task ID must be a positive number.')], + flags: MessageFlags.Ephemeral + }); + } + + const listKey = `shared_todo_${listId}`; + const listData = await getFromDb(listKey, null); + + if (!listData) { + return interaction.reply({ + embeds: [errorEmbed('Error', 'Shared list not found.')], + flags: MessageFlags.Ephemeral + }); + } + + if (!listData.members || !listData.members.includes(userId)) { + return interaction.reply({ + embeds: [errorEmbed('Error', "You don't have access to this list.")], + flags: MessageFlags.Ephemeral + }); + } + + if (!Array.isArray(listData.tasks)) { + listData.tasks = []; + } + + const taskIndex = listData.tasks.findIndex(task => task.id === taskId); + if (taskIndex === -1) { + return interaction.reply({ + embeds: [errorEmbed('Error', 'Task not found.')], + flags: MessageFlags.Ephemeral + }); + } + + const [removedTask] = listData.tasks.splice(taskIndex, 1); + await setInDb(listKey, listData); + + await refreshSharedTodoMessage(interaction, listId, sourceMessageId); + + return interaction.reply({ + embeds: [successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list.`)], + flags: MessageFlags.Ephemeral + }); + } catch (error) { + logger.error('Error in shared todo remove modal:', error); + return interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while removing the task.')], + flags: MessageFlags.Ephemeral + }); + } + } +}; + export default sharedTodoAddHandler; -export { sharedTodoCompleteHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler }; +export { sharedTodoCompleteHandler, sharedTodoRemoveHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler }; diff --git a/src/services/counterService.js b/src/services/counterService.js index 23a4f0d429..d575f3b93f 100644 --- a/src/services/counterService.js +++ b/src/services/counterService.js @@ -2,6 +2,46 @@ import { logger } from '../utils/logger.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; +function isValidCounterShape(counter) { + return Boolean( + counter && + typeof counter === 'object' && + typeof counter.id === 'string' && + counter.id.length > 0 && + typeof counter.type === 'string' && + typeof counter.channelId === 'string' && + counter.channelId.length > 0 + ); +} + +function normalizeCounter(counter, guildId) { + const normalized = { + id: String(counter.id), + type: String(counter.type), + channelId: String(counter.channelId), + guildId: String(counter.guildId || guildId), + createdAt: counter.createdAt || new Date().toISOString(), + enabled: typeof counter.enabled === 'boolean' ? counter.enabled : true + }; + + if (counter.updatedAt) { + normalized.updatedAt = counter.updatedAt; + } + + return normalized; +} + +function sanitizeCounters(counters, guildId) { + if (!Array.isArray(counters)) { + return []; + } + + return counters + .filter(isValidCounterShape) + .map(counter => normalizeCounter(counter, guildId)); +} + + @@ -133,25 +173,23 @@ export async function getServerCounters(client, guildId) { counters = data.value; } else if (Array.isArray(data)) { counters = data; - } else if (data && typeof data === 'object' && !data.ok) { - counters = Object.values(data); + } else if (typeof data === 'string') { + try { + const parsed = JSON.parse(data); + counters = Array.isArray(parsed) ? parsed : []; + } catch { + counters = []; + } + } else if (data && typeof data === 'object' && !data.ok && isValidCounterShape(data)) { + counters = [data]; } else { if (process.env.NODE_ENV !== 'production') { logger.debug('No counter data found, returning empty array'); } return []; } - - - const validCounters = counters.filter(counter => - counter && - typeof counter === 'object' && - counter.type && - counter.channelId && - counter.id - ); - - return validCounters; + + return sanitizeCounters(counters, guildId); } catch (error) { logger.error("Error getting server counters:", error); return []; @@ -172,10 +210,13 @@ export async function saveServerCounters(client, guildId, counters) { return false; } + const sanitizedCounters = sanitizeCounters(counters, guildId); + if (process.env.NODE_ENV !== 'production') { - logger.debug(`Saving ${counters.length} counters for guild ${guildId}:`, counters); + logger.debug(`Saving ${sanitizedCounters.length} counters for guild ${guildId}:`, sanitizedCounters); } - await client.db.set(`counters:${guildId}`, counters); + + await client.db.set(`counters:${guildId}`, sanitizedCounters); if (process.env.NODE_ENV !== 'production') { logger.debug('Counters saved successfully'); } diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 924949f9f3..269f5ded8d 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -389,10 +389,11 @@ export async function endGiveaway(client, giveaway, guildId, endedBy) { participantCount: participants.length }; } catch (error) { - logger.error('Error ending giveaway:', error); if (error instanceof TitanBotError) { + logger.debug(`Giveaway end validation error: ${error.message}`, error.context || {}); throw error; } + logger.error('Error ending giveaway:', error); throw new TitanBotError( 'Failed to end giveaway', ErrorTypes.UNKNOWN, diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index aefd206ebb..08aade4043 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -45,6 +45,7 @@ export function validateChannelNameTemplate(template) { ); } + // Remove only control characters, keep emojis and punctuation for templates const normalizedTemplate = template.normalize('NFKC').replace(CONTROL_AND_INVISIBLE_CHARS_REGEX, '').trim(); if (normalizedTemplate.length > CHANNEL_NAME_MAX_LENGTH) { @@ -55,13 +56,12 @@ export function validateChannelNameTemplate(template) { ); } - - const validPattern = /^[a-zA-Z0-9\s\-\{\}_]*$/; - if (!validPattern.test(normalizedTemplate)) { + // Check for Discord-forbidden channel name characters (only @#: and backticks are problematic) + if (/[@#:`]/.test(normalizedTemplate)) { throw new TitanBotError( - 'Channel template contains invalid characters', + 'Channel template contains forbidden characters', ErrorTypes.VALIDATION, - 'Channel template can only contain letters, numbers, spaces, hyphens, and template variables like {username}.' + 'Channel template cannot contain @, #, :, or backtick characters.' ); } @@ -154,19 +154,19 @@ export function formatChannelName(template, variables) { ); } - + // Sanitize each variable to prevent injection and ensure Discord compatibility const sanitized = {}; for (const [key, value] of Object.entries(variables)) { if (value === null || value === undefined) { sanitized[key] = 'Unknown'; } else { - + // Remove dangerous and Discord-incompatible characters sanitized[key] = String(value) .normalize('NFKC') .replace(CONTROL_AND_INVISIBLE_CHARS_REGEX, '') - .replace(/[^a-zA-Z0-9\s-]/g, '') + .replace(/[@#:`\n\r\t]/g, '') // Remove Discord-forbidden chars .trim() - .substring(0, CHANNEL_VARIABLE_MAX_LENGTH); + .substring(0, CHANNEL_VARIABLE_MAX_LENGTH); } } @@ -186,11 +186,12 @@ export function formatChannelName(template, variables) { formatted = formatted.replace(new RegExp(placeholder.replace(/[{}]/g, '\\$&'), 'g'), value); } - + // Final sanitization: preserve emojis but remove Discord-forbidden characters + // Discord allows emojis but not @#:` and control characters formatted = formatted .normalize('NFKC') .replace(CONTROL_AND_INVISIBLE_CHARS_REGEX, '') - .replace(/[^a-zA-Z0-9\s-]/g, '') + .replace(/[@#:`\n\r\t]/g, '') // Remove only Discord-forbidden chars, keep emojis .replace(/\s+/g, ' ') .trim(); @@ -304,7 +305,8 @@ export async function updateChannelConfig(client, guildId, channelId, updates) { if (!client || !client.db) { throw new TitanBotError( 'Database service not available', - ErrorTypes.DATABASE + ErrorTypes.DATABASE, + 'Database service is currently unavailable. Please try again later.' ); } @@ -371,7 +373,8 @@ export async function removeTriggerChannel(client, guildId, channelId) { if (!client || !client.db) { throw new TitanBotError( 'Database service not available', - ErrorTypes.DATABASE + ErrorTypes.DATABASE, + 'Database service is currently unavailable. Please try again later.' ); } @@ -432,7 +435,8 @@ export async function getConfiguration(client, guildId) { if (!client || !client.db) { throw new TitanBotError( 'Database service not available', - ErrorTypes.DATABASE + ErrorTypes.DATABASE, + 'Database service is currently unavailable. Please try again later.' ); } @@ -479,7 +483,7 @@ export async function getChannelConfiguration(client, guildId, channelId) { try { const config = await getConfiguration(client, guildId); - if (!config.triggerChannels.includes(channelId)) { + if (!config.triggerChannels || !Array.isArray(config.triggerChannels) || !config.triggerChannels.includes(channelId)) { throw new TitanBotError( 'Channel is not a valid Join to Create trigger', ErrorTypes.VALIDATION, @@ -498,7 +502,8 @@ export async function getChannelConfiguration(client, guildId, channelId) { } throw new TitanBotError( `Failed to get channel configuration: ${error.message}`, - ErrorTypes.DATABASE + ErrorTypes.DATABASE, + 'Failed to retrieve channel configuration. Please try again.' ); } } diff --git a/src/services/loggingService.js b/src/services/loggingService.js index cbceadbae6..d1b22316b0 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.js @@ -53,6 +53,7 @@ const EVENT_TYPES = { GIVEAWAY_CREATE: 'giveaway.create', GIVEAWAY_WINNER: 'giveaway.winner', GIVEAWAY_REROLL: 'giveaway.reroll', + GIVEAWAY_DELETE: 'giveaway.delete', COUNTER_UPDATE: 'counter.update' @@ -89,6 +90,7 @@ const EVENT_COLORS = { 'giveaway.create': 0x57F287, 'giveaway.winner': 0xFEE75C, 'giveaway.reroll': 0x3498DB, + 'giveaway.delete': 0xE74C3C, 'counter.update': 0x0099ff, }; @@ -123,6 +125,7 @@ const EVENT_ICONS = { 'giveaway.create': '๐ŸŽ', 'giveaway.winner': '๐ŸŽ‰', 'giveaway.reroll': '๐Ÿ”„', + 'giveaway.delete': '๐Ÿ—‘๏ธ', 'counter.update': '๐Ÿ“Š', }; @@ -219,6 +222,11 @@ function isLoggingEnabled(config, eventType) { return false; } + if (!eventType || typeof eventType !== 'string') { + logger.debug('isLoggingEnabled called with invalid eventType', { eventType }); + return false; + } + const category = eventType.split('.')[0]; const enabledEvents = config.logging.enabledEvents || {}; @@ -299,6 +307,10 @@ function createLogEmbed(guild, eventType, data) { function formatEventType(eventType) { + if (!eventType || typeof eventType !== 'string') { + return 'Unknown Event'; + } + return eventType .split('.') .map(part => part.charAt(0).toUpperCase() + part.slice(1)) diff --git a/src/utils/logger.js b/src/utils/logger.js index 230fd42352..b5e363fd53 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -30,7 +30,7 @@ if (requestedLogLevel && !validLogLevels.has(requestedLogLevel)) { ); } -const shouldPromoteStartupLogs = process.env.NODE_ENV === 'production' && resolvedLogLevel === 'warn'; +const shouldPromoteUserFacingLogs = process.env.NODE_ENV === 'production' && resolvedLogLevel === 'warn'; const logFormat = printf(({ level, message, timestamp, stack, displayLevel }) => { const visibleLevel = displayLevel || level; @@ -108,7 +108,7 @@ logger.stream = { }; function startupLog(message) { - if (shouldPromoteStartupLogs) { + if (shouldPromoteUserFacingLogs) { logger.log({ level: 'warn', message, @@ -124,7 +124,24 @@ function startupLog(message) { }); } -export { logger, startupLog }; +function shutdownLog(message) { + if (shouldPromoteUserFacingLogs) { + logger.log({ + level: 'warn', + message, + displayLevel: 'status', + }); + return; + } + + logger.log({ + level: 'info', + message, + displayLevel: 'status', + }); +} + +export { logger, startupLog, shutdownLog }; export default logger; diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index 537f8c49c5..5d2b9605f7 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -448,13 +448,14 @@ class PostgreSQLDatabase { const parsedKey = this.parseKey(key); const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null; + const jsonValue = JSON.stringify(value ?? null); if (parsedKey.type === 'temp') { await this.pool.query( `INSERT INTO ${pgConfig.tables.temp_data} (key, value, expires_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`, - [parsedKey.fullKey, value, expiresAt] + [parsedKey.fullKey, jsonValue, expiresAt] ); return true; } @@ -464,7 +465,7 @@ class PostgreSQLDatabase { `INSERT INTO ${pgConfig.tables.cache_data} (key, value, expires_at) VALUES ($1, $2, $3) ON CONFLICT (key) DO UPDATE SET value = $2, expires_at = $3`, - [parsedKey.fullKey, value, expiresAt] + [parsedKey.fullKey, jsonValue, expiresAt] ); return true; } @@ -870,8 +871,15 @@ class PostgreSQLDatabase { ); await this.pool.query(`DELETE FROM ${pgConfig.tables.giveaways} WHERE guild_id = $1`, [parsedKey.guildId]); - - for (const giveaway of value) { + + const giveaways = Array.isArray(value) + ? value + : (value && typeof value === 'object' ? Object.values(value) : []); + + for (const giveaway of giveaways) { + if (!giveaway?.messageId) { + continue; + } await this.pool.query( `INSERT INTO ${pgConfig.tables.giveaways} (guild_id, message_id, data, ends_at) VALUES ($1, $2, $3, $4)`, @@ -1030,31 +1038,20 @@ class PostgreSQLDatabase { } logger.debug('Saving counter data to PostgreSQL', { type: typeof value, isArray: Array.isArray(value) }); - + + const normalizedCounters = Array.isArray(value) ? value : []; + const jsonString = JSON.stringify(normalizedCounters); + try { await this.pool.query( `INSERT INTO ${pgConfig.tables.guilds} (id, counters, updated_at) - VALUES ($1, $2, CURRENT_TIMESTAMP) - ON CONFLICT (id) DO UPDATE SET counters = $2, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, value] + VALUES ($1, $2::jsonb, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO UPDATE SET counters = $2::jsonb, updated_at = CURRENT_TIMESTAMP`, + [parsedKey.guildId, jsonString] ); } catch (queryError) { logger.error('PostgreSQL query error', { message: queryError.message, detail: queryError.detail, hint: queryError.hint }); - - try { - const jsonString = JSON.stringify(value); - logger.debug('Attempting fallback: save counter data as stringified JSON'); - await this.pool.query( - `INSERT INTO ${pgConfig.tables.guilds} (id, counters, updated_at) - VALUES ($1, $2::jsonb, CURRENT_TIMESTAMP) - ON CONFLICT (id) DO UPDATE SET counters = $2::jsonb, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, jsonString] - ); - logger.debug('Successfully saved counter data as stringified JSON'); - } catch (fallbackError) { - logger.error('Fallback approach for counter data also failed', fallbackError); - throw queryError; - } + throw queryError; } return true; diff --git a/todolist.md b/todolist.md index 431707e4ae..a07dae7b78 100644 --- a/todolist.md +++ b/todolist.md @@ -6,25 +6,24 @@ Are all commands within the Welcome subfolder within the commands folder up to d - Scan each subfolder with this: - โœ… Welcome - โœ… Voice - + - โœ… Verification - โœ… Utility - โœ… Tools - โœ… Ticket - โœ… Search - โœ… Reaction roles - - - - - - + - โœ… Moderation + - โœ… Leveling + - โœ… JoinToCreate + - โœ… Giveaway + - โœ… Fun + - โœ… Economy - โœ… Counter - โœ… Core - - + - โœ… Config + - โœ… Community - โœ… Birthday -- Implement better security using tiktok vid (on phone) - Review each file within events, handlers, interactions, services and utils (Remove comments and ensure production-ready). (Done: config) From 3b521a86bd5044ab2f72998ba9882047b2f1b1f2 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Mon, 23 Feb 2026 22:14:45 +1100 Subject: [PATCH 02/10] Fixed bugs across all bot modules: emoji corruption, UI inconsistencies, deferred commands, and permission issues. --- src/app.js | 21 +- src/commands/Birthday/birthday.js | 3 +- .../Birthday/modules/birthday_info.js | 7 +- .../Birthday/modules/birthday_list.js | 7 +- .../Birthday/modules/birthday_remove.js | 7 +- src/commands/Birthday/modules/birthday_set.js | 5 +- .../Birthday/modules/next_birthdays.js | 7 +- src/commands/Community/app-admin.js | 47 ++-- src/commands/Community/apply.js | 29 +-- src/commands/Config/config.js | 9 +- .../Config/modules/config_birthday_toggle.js | 7 +- .../Config/modules/config_logging_filter.js | 15 +- .../modules/config_logging_setchannel.js | 23 +- .../Config/modules/config_logging_status.js | 9 +- .../Config/modules/config_premium_setrole.js | 7 +- .../modules/config_reports_setchannel.js | 5 +- src/commands/Core/bug.js | 17 +- src/commands/Core/help.js | 7 +- src/commands/Core/ping.js | 8 +- src/commands/Core/stats.js | 7 +- src/commands/Core/support.js | 5 +- src/commands/Core/uptime.js | 7 +- src/commands/Counter/counter.js | 15 +- .../Counter/modules/counter_create.js | 47 ++-- .../Counter/modules/counter_delete.js | 22 +- src/commands/Counter/modules/counter_list.js | 79 +++--- .../Counter/modules/counter_update.js | 71 ++---- src/commands/Economy/balance.js | 2 +- src/commands/Economy/beg.js | 2 +- src/commands/Economy/buy.js | 2 +- src/commands/Economy/crime.js | 6 +- src/commands/Economy/daily.js | 5 +- src/commands/Economy/deposit.js | 2 +- src/commands/Economy/eleaderboard.js | 2 +- src/commands/Economy/fish.js | 2 +- src/commands/Economy/gamble.js | 2 +- src/commands/Economy/inventory.js | 2 +- src/commands/Economy/mine.js | 2 +- src/commands/Economy/pay.js | 2 +- src/commands/Economy/rob.js | 4 +- src/commands/Economy/shop.js | 142 ++--------- src/commands/Economy/slut.js | 231 ++++++++++-------- src/commands/Economy/withdraw.js | 5 +- src/commands/Economy/work.js | 5 +- src/commands/Fun/fact.js | 3 +- src/commands/Fun/fight.js | 28 ++- src/commands/Fun/filp.js | 3 +- src/commands/Fun/mock.js | 3 +- src/commands/Fun/reverse.js | 3 +- src/commands/Fun/roll.js | 5 +- src/commands/Fun/ship.js | 7 +- src/commands/Fun/wanted.js | 9 +- src/commands/Giveaway/gcreate.js | 3 +- src/commands/Giveaway/gdelete.js | 78 +++++- src/commands/Giveaway/gend.js | 3 +- src/commands/Giveaway/greroll.js | 7 +- src/commands/JoinToCreate/jointocreate.js | 13 +- .../JoinToCreate/modules/config_setup.js | 5 +- src/commands/JoinToCreate/modules/setup.js | 7 +- src/commands/Leveling/leaderboard.js | 5 +- src/commands/Leveling/leveladd.js | 5 +- src/commands/Leveling/levelconfig.js | 11 +- src/commands/Leveling/levelremove.js | 5 +- src/commands/Leveling/levelset.js | 5 +- src/commands/Leveling/rank.js | 5 +- src/commands/Moderation/dm.js | 11 +- src/commands/Moderation/lock.js | 9 +- src/commands/Moderation/massban.js | 15 +- src/commands/Moderation/masskick.js | 15 +- src/commands/Moderation/purge.js | 11 +- src/commands/Moderation/timeout.js | 5 +- src/commands/Moderation/unban.js | 3 +- src/commands/Moderation/unlock.js | 9 +- src/commands/Moderation/untimeout.js | 3 +- src/commands/Moderation/usernotes.js | 27 +- src/commands/Moderation/warn.js | 3 +- src/commands/Moderation/warnings.js | 5 +- src/commands/Reaction_roles/rdelete.js | 2 +- src/commands/Reaction_roles/rlist.js | 4 +- src/commands/Reaction_roles/rsetup.js | 6 +- src/commands/Search/define.js | 8 +- src/commands/Search/google.js | 3 +- src/commands/Search/movie.js | 14 +- src/commands/Search/urban.js | 37 ++- src/commands/Ticket/claim.js | 31 ++- src/commands/Ticket/close.js | 28 ++- .../Ticket/modules/ticket_limits_check.js | 11 +- .../Ticket/modules/ticket_limits_set.js | 2 +- .../Ticket/modules/ticket_limits_toggle_dm.js | 2 +- src/commands/Ticket/priority.js | 31 ++- src/commands/Ticket/ticket.js | 8 +- src/commands/Tools/baseconvert.js | 12 +- src/commands/Tools/calculate.js | 8 +- src/commands/Tools/countdown.js | 2 +- src/commands/Tools/generatepassword.js | 20 +- src/commands/Tools/hexcolor.js | 111 ++++----- src/commands/Tools/poll.js | 2 +- src/commands/Tools/shorten.js | 51 +++- src/commands/Tools/time.js | 92 +++---- src/commands/Tools/unixtime.js | 50 ++-- src/commands/Utility/avatar.js | 3 +- src/commands/Utility/firstmsg.js | 6 +- src/commands/Utility/report.js | 6 +- src/commands/Utility/serverinfo.js | 2 +- src/commands/Utility/todo.js | 50 ++-- src/commands/Utility/userinfo.js | 2 +- src/commands/Utility/weather.js | 6 +- src/commands/Utility/wipedata.js | 3 +- .../Verification/modules/autoVerify.js | 15 +- src/commands/Verification/verification.js | 25 +- src/commands/Voice/activity.js | 14 +- src/commands/Welcome/autorole.js | 22 +- src/commands/Welcome/goodbye.js | 12 +- src/commands/Welcome/welcome.js | 12 +- src/events/channelDelete.js | 18 ++ src/events/interactionCreate.js | 3 + src/handlers/giveawayButtonLoader.js | 3 +- src/handlers/giveawayButtons.js | 62 +++++ src/handlers/shopButtonLoader.js | 18 -- src/handlers/shopButtons.js | 149 ----------- src/handlers/ticketButtonLoader.js | 2 + src/handlers/ticketButtons.js | 147 +++++++---- src/services/counterService.js | 106 ++++++-- src/services/database.js | 40 +++ src/services/giveawayService.js | 1 - src/services/ticket.js | 140 +++++++---- src/utils/errorHandler.js | 5 +- src/utils/interactionHelper.js | 51 +++- src/utils/ticketPermissions.js | 28 +++ 129 files changed, 1528 insertions(+), 1198 deletions(-) delete mode 100644 src/handlers/shopButtonLoader.js delete mode 100644 src/handlers/shopButtons.js create mode 100644 src/utils/ticketPermissions.js diff --git a/src/app.js b/src/app.js index da5587f30f..f5c3989a29 100644 --- a/src/app.js +++ b/src/app.js @@ -12,7 +12,7 @@ if (typeof global.ReadableStream === 'undefined') { import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/guildConfig.js'; -import { getServerCounters, updateCounter } from './services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter } from './services/counterService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; @@ -247,11 +247,27 @@ class TitanBot extends Client { for (const [guildId, guild] of this.guilds.cache) { try { const counters = await getServerCounters(this, guildId); + const validCounters = []; + const orphanedCounters = []; + for (const counter of counters) { if (counter && counter.type && counter.channelId && counter.enabled !== false) { - await updateCounter(this, guild, counter); + const channel = guild.channels.cache.get(counter.channelId); + if (channel) { + validCounters.push(counter); + await updateCounter(this, guild, counter); + } else { + orphanedCounters.push(counter); + logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guildId}`); + } } } + + // Save cleaned counters if any were orphaned + if (orphanedCounters.length > 0) { + await saveServerCounters(this, guildId, validCounters); + logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guildId} during scheduled update`); + } } catch (error) { logger.error(`Error updating counters for guild ${guildId}:`, error); } @@ -265,7 +281,6 @@ class TitanBot extends Client { { path: 'todoButtonLoader', type: 'default', required: false }, { path: 'counterButtonLoader', type: 'default', required: false }, { path: 'ticketButtonLoader', type: 'default', required: false }, - { path: 'shopButtonLoader', type: 'default', required: false }, { path: 'giveawayButtonLoader', type: 'named:loadGiveawayButtons', required: false }, { path: 'helpButtonLoader', type: 'default', required: false }, { path: 'helpSelectMenuLoader', type: 'default', required: false }, diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js index 81f667b53c..92bab80fe5 100644 --- a/src/commands/Birthday/birthday.js +++ b/src/commands/Birthday/birthday.js @@ -9,6 +9,7 @@ import birthdayList from './modules/birthday_list.js'; import birthdayRemove from './modules/birthday_remove.js'; import nextBirthdays from './modules/next_birthdays.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('birthday') @@ -77,7 +78,7 @@ export default { case 'next': return await nextBirthdays.execute(interaction, config, client); default: - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Error', 'Unknown subcommand')], flags: MessageFlags.Ephemeral }); diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.js index f6856cab9d..16a2393a1a 100644 --- a/src/commands/Birthday/modules/birthday_info.js +++ b/src/commands/Birthday/modules/birthday_info.js @@ -4,10 +4,11 @@ import { getUserBirthday } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const targetUser = interaction.options.getUser("user") || interaction.user; const userId = targetUser.id; @@ -17,7 +18,7 @@ export default { const birthdayData = await getUserBirthday(client, guildId, userId); if (!birthdayData) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'โŒ No Birthday Found', description: targetUser.id === interaction.user.id @@ -35,7 +36,7 @@ export default { footer: targetUser.id === interaction.user.id ? "Your Birthday" : `${targetUser.username}'s Birthday` }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Birthday info retrieved successfully', { userId: interaction.user.id, diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js index 9c6996c4dd..8baf355914 100644 --- a/src/commands/Birthday/modules/birthday_list.js +++ b/src/commands/Birthday/modules/birthday_list.js @@ -4,10 +4,11 @@ import { getAllBirthdays } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const guildId = interaction.guildId; @@ -15,7 +16,7 @@ export default { const sortedBirthdays = await getAllBirthdays(client, guildId); if (sortedBirthdays.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'โŒ No Birthdays', description: 'No birthdays have been set in this server yet.', @@ -39,7 +40,7 @@ export default { embed.setDescription(birthdayList || "No birthdays found"); embed.setFooter({ text: `Total: ${sortedBirthdays.length} birthdays` }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Birthday list retrieved successfully', { userId: interaction.user.id, diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.js index ddafbaf5c8..998d7e071e 100644 --- a/src/commands/Birthday/modules/birthday_remove.js +++ b/src/commands/Birthday/modules/birthday_remove.js @@ -4,10 +4,11 @@ import { deleteBirthday } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; const guildId = interaction.guildId; @@ -16,14 +17,14 @@ export default { const result = await deleteBirthday(client, guildId, userId); if (result.success) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "Your birthday has been successfully removed from the server.", "Birthday Removed ๐Ÿ—‘๏ธ" )] }); } else if (result.notFound) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'โŒ No Birthday Found', description: "You don't have a birthday set to remove.", diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.js index 93d7f0c537..dad5616d2d 100644 --- a/src/commands/Birthday/modules/birthday_set.js +++ b/src/commands/Birthday/modules/birthday_set.js @@ -4,10 +4,11 @@ import { setBirthday } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const month = interaction.options.getInteger("month"); const day = interaction.options.getInteger("day"); @@ -17,7 +18,7 @@ export default { const result = await setBirthday(client, guildId, userId, month, day); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( `Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`, "Birthday Set! ๐ŸŽ‚" diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js index c0ea54841b..73bba8969b 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.js @@ -4,16 +4,17 @@ import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); if (next5.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โŒ No Birthdays Found', @@ -56,7 +57,7 @@ export default { iconURL: interaction.guild.iconURL() }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Next birthdays retrieved successfully', { userId: interaction.user.id, diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index 0e823c9dbc..f3574c753c 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -13,6 +13,7 @@ import { getApplicationRoles, saveApplicationRoles } from '../../utils/database.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -153,13 +154,13 @@ export default { execute: withErrorHandling(async (interaction) => { if (!interaction.inGuild()) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed("This command can only be used in a server.")], flags: ["Ephemeral"], }); } - await interaction.deferReply({ flags: ["Ephemeral"] }); + await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); @@ -270,7 +271,7 @@ async function showCurrentSettings(interaction, settings) { }, ); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"], }); @@ -285,7 +286,7 @@ async function handleView(interaction) { ); if (!application) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Application not found.")], flags: ["Ephemeral"], }); @@ -331,13 +332,13 @@ async function handleView(interaction) { .setEmoji("โŒ"), ); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [row], flags: ["Ephemeral"], }); } else { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"], }); @@ -356,14 +357,14 @@ async function handleReview(interaction) { appId, ); if (!application) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Application not found.")], flags: ["Ephemeral"], }); } if (application.status !== "pending") { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("This application has already been processed."), ], @@ -459,7 +460,7 @@ components: [], } } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `Application ${status}`, @@ -510,9 +511,9 @@ async function handleList(interaction) { text: "Users can apply with /apply submit or see available roles with /apply list" }); - return interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } else { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "No applications found and no application roles configured.\n" + @@ -549,7 +550,7 @@ async function handleList(interaction) { }); }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"], }); @@ -644,7 +645,7 @@ async function handleRoles(interaction) { if (action === "list") { if (currentRoles.length === 0) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("No application roles have been configured.")], flags: ["Ephemeral"], }); @@ -664,12 +665,12 @@ async function handleRoles(interaction) { }); }); - return interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } if (action === "add") { const customName = name || role.name; - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "Role Added", `**${customName}** has been added to the application system.\nUsers can now apply for this role using \`/apply submit\`.` @@ -680,7 +681,7 @@ async function handleRoles(interaction) { if (action === "remove") { const removedRole = currentRoles.find(r => r.roleId === role.id); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "Role Removed", `**${removedRole?.name || 'Role'}** has been removed from the application system.` @@ -702,14 +703,14 @@ export async function handleApplicationButton(interaction) { try { const application = await getApplication(interaction.client, interaction.guild.id, appId); if (!application) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Application not found.')], flags: ["Ephemeral"] }); } if (application.status !== 'pending') { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('This application has already been processed.')], flags: ["Ephemeral"] }); @@ -720,7 +721,7 @@ export async function handleApplicationButton(interaction) { (settings.managerRoles && settings.managerRoles.some(roleId => interaction.member.roles.cache.has(roleId))); if (!isManager) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('You do not have permission to manage applications.')], flags: ["Ephemeral"] }); @@ -744,7 +745,7 @@ export async function handleApplicationButton(interaction) { } catch (error) { console.error('Error handling application button:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('An error occurred while processing the application.')], flags: ["Ephemeral"] }); @@ -764,7 +765,7 @@ export async function handleApplicationReviewModal(interaction) { try { const application = await getApplication(interaction.client, interaction.guild.id, appId); if (!application) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Application not found.')], flags: ["Ephemeral"] }); @@ -829,7 +830,7 @@ export async function handleApplicationReviewModal(interaction) { } } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `Application ${status}`, @@ -841,7 +842,7 @@ export async function handleApplicationReviewModal(interaction) { } catch (error) { console.error('Error processing application review:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('An error occurred while processing the application.')], flags: ["Ephemeral"] }); diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index 13c678d455..9b3ebcf17f 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -4,6 +4,7 @@ import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import ApplicationService from '../../services/applicationService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getApplicationSettings, getUserApplications, @@ -49,13 +50,13 @@ export default { execute: withErrorHandling(async (interaction) => { if (!interaction.inGuild()) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed("This command can only be used in a server.")], flags: ["Ephemeral"], }); } - await interaction.deferReply({ flags: ["Ephemeral"] }); + await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); @@ -102,7 +103,7 @@ export async function handleApplicationModal(interaction) { const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); if (!applicationRole) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Application configuration not found.')], flags: ["Ephemeral"] }); @@ -111,7 +112,7 @@ export async function handleApplicationModal(interaction) { const role = interaction.guild.roles.cache.get(roleId); if (!role) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Role not found.')], flags: ["Ephemeral"] }); @@ -147,7 +148,7 @@ export async function handleApplicationModal(interaction) { `You can check the status with \`/apply status id:${application.id}\`` ); - await interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); const settings = await getApplicationSettings(interaction.client, interaction.guild.id); if (settings.logChannelId) { @@ -192,7 +193,7 @@ async function handleList(interaction) { const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); if (applicationRoles.length === 0) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("No applications are currently available.")], flags: ["Ephemeral"], }); @@ -217,7 +218,7 @@ async function handleList(interaction) { text: "Use /apply submit application: to apply for any of these roles." }); - return interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } catch (error) { logger.error('Error listing applications:', { error: error.message, @@ -245,7 +246,7 @@ async function handleSubmit(interaction, settings) { ); if (!applicationRole) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Application not found.", @@ -264,7 +265,7 @@ async function handleSubmit(interaction, settings) { const pendingApp = userApps.find((app) => app.status === "pending"); if (pendingApp) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( `You already have a pending application. Please wait for it to be reviewed.`, @@ -276,7 +277,7 @@ async function handleSubmit(interaction, settings) { const role = interaction.guild.roles.cache.get(applicationRole.roleId); if (!role) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('The role for this application no longer exists.')], flags: ["Ephemeral"] }); @@ -319,7 +320,7 @@ async function handleStatus(interaction) { ); if (!application || application.userId !== interaction.user.id) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Application not found or you do not have permission to view it.", @@ -331,7 +332,7 @@ async function handleStatus(interaction) { const embed = createEmbed({ title: `Application #${application.id} - ${application.roleName}`, description: `**Status:** ${application.status.charAt(0).toUpperCase() + application.status.slice(1)}` }); - return interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } else { const applications = await getUserApplications( interaction.client, @@ -340,7 +341,7 @@ async function handleStatus(interaction) { ); if (applications.length === 0) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("You have not submitted any applications yet."), ], @@ -350,7 +351,7 @@ async function handleStatus(interaction) { const embed = createEmbed({ title: "Your Applications", description: "Here are your recent applications." }); - return interaction.editReply({ embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } } diff --git a/src/commands/Config/config.js b/src/commands/Config/config.js index 49f9dd358b..e44e205bf1 100644 --- a/src/commands/Config/config.js +++ b/src/commands/Config/config.js @@ -8,6 +8,7 @@ import loggingFilter from './modules/config_logging_filter.js'; import reportsSetchannel from './modules/config_reports_setchannel.js'; import premiumSetrole from './modules/config_premium_setrole.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("config") @@ -155,7 +156,7 @@ export default { async execute(interaction, config, client) { if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageGuild)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -192,7 +193,7 @@ export default { } } - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("Error", "Unknown subcommand or subcommand group."), ], @@ -212,9 +213,9 @@ export default { }; if (interaction.deferred || interaction.replied) { - return interaction.editReply(errorMessage); + return InteractionHelper.safeEditReply(interaction, errorMessage); } else { - return interaction.editReply(errorMessage); + return InteractionHelper.safeEditReply(interaction, errorMessage); } } }, diff --git a/src/commands/Config/modules/config_birthday_toggle.js b/src/commands/Config/modules/config_birthday_toggle.js index 585224d7e3..3af157b325 100644 --- a/src/commands/Config/modules/config_birthday_toggle.js +++ b/src/commands/Config/modules/config_birthday_toggle.js @@ -2,6 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord. import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { @@ -14,7 +15,7 @@ try { guildConfig.birthdayChannelId = channel.id; await setGuildConfig(client, guildId, guildConfig); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "๐ŸŽ‚ Birthday Announcements Enabled", @@ -27,7 +28,7 @@ try { guildConfig.birthdayChannelId = null; await setGuildConfig(client, guildId, guildConfig); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "๐ŸŽ‚ Birthday Announcements Disabled", @@ -39,7 +40,7 @@ try { } } catch (error) { console.error("config_birthday_toggle error:", error); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Configuration Error", diff --git a/src/commands/Config/modules/config_logging_filter.js b/src/commands/Config/modules/config_logging_filter.js index e271528b15..320a6fd529 100644 --- a/src/commands/Config/modules/config_logging_filter.js +++ b/src/commands/Config/modules/config_logging_filter.js @@ -2,10 +2,11 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelT import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { logEvent } from '../../../utils/moderation.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -15,7 +16,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) }); } if (!client.db) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("Database Error", "Database not initialized."), ], @@ -49,7 +50,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) const channel = interaction.guild.channels.cache.get(entityId); entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; } else { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Invalid Type", @@ -63,7 +64,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) if (subcommand === "add") { if (targetArray.includes(entityId)) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Already Filtered", @@ -77,7 +78,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) } else if (subcommand === "remove") { const index = targetArray.indexOf(entityId); if (index === -1) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Not Filtered", @@ -123,12 +124,12 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed("Success!", successMessage)], }); } catch (error) { console.error("Error saving log filter:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Database Error", diff --git a/src/commands/Config/modules/config_logging_setchannel.js b/src/commands/Config/modules/config_logging_setchannel.js index b39ce66f75..58814a95fa 100644 --- a/src/commands/Config/modules/config_logging_setchannel.js +++ b/src/commands/Config/modules/config_logging_setchannel.js @@ -4,10 +4,11 @@ import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js import { logEvent } from '../../../utils/moderation.js'; import { validateLogChannel } from '../../../utils/ticketLogging.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -18,7 +19,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) } if (!client.db) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("Database Error", "Database not initialized."), ], @@ -38,7 +39,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) if (ticketLifecycle) { const validation = validateLogChannel(ticketLifecycle, interaction.guild.members.me); if (!validation.valid) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Invalid Channel", validation.error)], }); } @@ -49,7 +50,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) currentConfig.ticketLogging.lifecycleChannelId = ticketLifecycle.id; await setGuildConfig(client, guildId, currentConfig); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "๐ŸŽซ Ticket Lifecycle Channel Set", @@ -62,7 +63,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) if (ticketTranscript) { const validation = validateLogChannel(ticketTranscript, interaction.guild.members.me); if (!validation.valid) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Invalid Channel", validation.error)], }); } @@ -73,7 +74,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) currentConfig.ticketLogging.transcriptChannelId = ticketTranscript.id; await setGuildConfig(client, guildId, currentConfig); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "๐Ÿ“œ Ticket Transcript Channel Set", @@ -93,7 +94,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) }; await setGuildConfig(client, guildId, currentConfig); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Logging Disabled ๐Ÿšซ", @@ -113,7 +114,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) ) || !permissionsInChannel.has(PermissionsBitField.Flags.EmbedLinks) ) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Bot Permission Error", @@ -133,7 +134,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) await setGuildConfig(client, guildId, currentConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Log Channel Set ๐Ÿ“", @@ -160,7 +161,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) return; } - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "No Option Provided", @@ -171,7 +172,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) } catch (error) { console.error("Error setting log channel:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Configuration Error", diff --git a/src/commands/Config/modules/config_logging_status.js b/src/commands/Config/modules/config_logging_status.js index 8771ec4de5..0d167da9ea 100644 --- a/src/commands/Config/modules/config_logging_status.js +++ b/src/commands/Config/modules/config_logging_status.js @@ -5,12 +5,13 @@ import { getGuildConfig } from '../../../services/guildConfig.js'; import { getLoggingStatus } from '../../../services/loggingService.js'; import { getLevelingConfig, getWelcomeConfig, getApplicationSettings, getModlogSettings } from '../../../utils/database.js'; import { createStatusIndicatorButtons } from '../../../utils/loggingUi.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { try { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -20,7 +21,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) }); } - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const currentConfig = await getGuildConfig(client, interaction.guildId); const loggingStatus = await getLoggingStatus(client, interaction.guildId); @@ -216,13 +217,13 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) const components = [...statusButtons, refreshButton]; - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [statusEmbed], components }); } catch (error) { console.error("config_logging_status error:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Configuration Error", diff --git a/src/commands/Config/modules/config_premium_setrole.js b/src/commands/Config/modules/config_premium_setrole.js index e8cc076336..d092b85aa8 100644 --- a/src/commands/Config/modules/config_premium_setrole.js +++ b/src/commands/Config/modules/config_premium_setrole.js @@ -2,10 +2,11 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelT import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -25,7 +26,7 @@ export default { await setGuildConfig(client, guildId, currentConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "โœ… Configuration Saved", @@ -35,7 +36,7 @@ export default { }); } catch (error) { console.error("SetPremiumRole command error:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "System Error", diff --git a/src/commands/Config/modules/config_reports_setchannel.js b/src/commands/Config/modules/config_reports_setchannel.js index 3011cf31c8..e9a42ac377 100644 --- a/src/commands/Config/modules/config_reports_setchannel.js +++ b/src/commands/Config/modules/config_reports_setchannel.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { const channel = interaction.options.getChannel("channel"); @@ -13,7 +14,7 @@ export default { await setGuildConfig(client, guildId, guildConfig); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "โœ… Report Channel Set!", @@ -23,7 +24,7 @@ export default { }); } catch (error) { console.error("Error setting report channel:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Database Error", diff --git a/src/commands/Core/bug.js b/src/commands/Core/bug.js index 59d2910996..9703be0ee6 100644 --- a/src/commands/Core/bug.js +++ b/src/commands/Core/bug.js @@ -1,6 +1,7 @@ -๏ปฟimport { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("bug") @@ -8,26 +9,26 @@ export default { async execute(interaction) { const githubButton = new ButtonBuilder() - .setLabel('๐Ÿ› Report Bug on GitHub') + .setLabel('?? Report Bug on GitHub') .setStyle(ButtonStyle.Link) .setURL('https://github.com/codebymitch/TitanBot/issues'); const row = new ActionRowBuilder().addComponents(githubButton); const bugReportEmbed = createEmbed({ - title: '๐Ÿ› Bug Report', + title: '?? Bug Report', description: 'Found a bug? Please report it on our GitHub Issues page!\n\n' + '**When reporting a bug, please include:**\n' + - 'โ€ข ๐Ÿ“ Detailed description of the issue\n' + - 'โ€ข ๐Ÿ”„ Steps to reproduce the problem\n' + - 'โ€ข ๐Ÿ“ธ Screenshots if applicable\n' + - 'โ€ข ๐Ÿ’ป Your bot version and environment\n\n' + + '• ?? Detailed description of the issue\n' + + '• ?? Steps to reproduce the problem\n' + + '• ?? Screenshots if applicable\n' + + '• ?? Your bot version and environment\n\n' + 'This helps us fix issues faster and more effectively!', color: 'error' }) .setTimestamp(); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [bugReportEmbed], components: [row], }); diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js index 4b5ab22204..5e0a58035e 100644 --- a/src/commands/Core/help.js +++ b/src/commands/Core/help.js @@ -4,6 +4,7 @@ import { ButtonBuilder, ButtonStyle, } from "discord.js"; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { createEmbed } from "../../utils/embeds.js"; import { createSelectMenu, @@ -201,11 +202,11 @@ export default { async execute(interaction, guildConfig, client) { const { MessageFlags } = await import('discord.js'); - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const { embeds, components } = await createInitialHelpMenu(); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds, components, }); @@ -218,7 +219,7 @@ export default { color: "secondary", }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [closedEmbed], components: [], }); diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js index a82410d5d7..45737397c9 100644 --- a/src/commands/Core/ping.js +++ b/src/commands/Core/ping.js @@ -20,11 +20,11 @@ export default { } try { - const reply = await interaction.reply({ + await InteractionHelper.safeEditReply(interaction, { content: "Pinging...", }); - const latency = reply.createdTimestamp - interaction.createdTimestamp; + const latency = Date.now() - interaction.createdTimestamp; const apiLatency = Math.round(interaction.client.ws.ping); const embed = createEmbed({ title: "๐Ÿ“ Pong!", description: null }).addFields( @@ -32,14 +32,14 @@ export default { { name: "API Latency", value: `${apiLatency}ms`, inline: true }, ); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: null, embeds: [embed], }); } catch (error) { console.error('Ping command error:', error); try { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.js index c34799e483..6b46d7351f 100644 --- a/src/commands/Core/stats.js +++ b/src/commands/Core/stats.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, version, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("stats") @@ -8,7 +9,7 @@ export default { async execute(interaction) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const totalGuilds = interaction.client.guilds.cache.size; const totalMembers = interaction.client.guilds.cache.reduce( @@ -29,10 +30,10 @@ export default { }, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { console.error('Stats command error:', error); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not fetch system statistics.', color: 'error' })], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js index 58cdc9df47..f5210e298e 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const SUPPORT_SERVER_URL = "https://discord.gg/QnWNz2dKCE"; export default { data: new SlashCommandBuilder() @@ -16,7 +17,7 @@ export default { const actionRow = new ActionRowBuilder().addComponents(supportButton); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [ createEmbed({ title: "๐Ÿš‘ Need Help?", description: "Join our official support server for assistance, report bugs, or suggest features. If you are customizing this bot, remember to change the link in the code!" }), ], @@ -27,7 +28,7 @@ export default { console.error('Support command error:', error); try { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not display support information.', color: 'error' })], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.js index 061675dfaf..f25f3eb1f2 100644 --- a/src/commands/Core/uptime.js +++ b/src/commands/Core/uptime.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("uptime") @@ -8,7 +9,7 @@ export default { async execute(interaction) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); let totalSeconds = interaction.client.uptime / 1000; let days = Math.floor(totalSeconds / 86400); @@ -20,7 +21,7 @@ export default { const uptimeStr = `${days}d ${hours}h ${minutes}m ${seconds}s`; - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: "โฑ๏ธ System Uptime", description: `\`\`\`${uptimeStr}\`\`\`` @@ -30,7 +31,7 @@ export default { console.error('Uptime command error:', error); try { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not compute uptime.', color: 'error' })], flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Counter/counter.js b/src/commands/Counter/counter.js index ccc035cdc5..fc576460ab 100644 --- a/src/commands/Counter/counter.js +++ b/src/commands/Counter/counter.js @@ -8,6 +8,7 @@ import { handleList } from './modules/counter_list.js'; import { handleUpdate } from './modules/counter_update.js'; import { handleDelete } from './modules/counter_delete.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("counter") @@ -23,7 +24,7 @@ export default { .setDescription("The type of counter to create") .setRequired(true) .addChoices( - { name: "members and bots", value: "members" }, + { name: "members + bots", value: "members" }, { name: "members only", value: "members_only" }, { name: "bots only", value: "bots" } ) @@ -67,17 +68,11 @@ export default { .setDescription("The new counter type") .setRequired(false) .addChoices( - { name: "members and bots", value: "members" }, + { name: "members + bots", value: "members" }, { name: "members only", value: "members_only" }, { name: "bots only", value: "bots" } ) ) - .addChannelOption(option => - option - .setName("channel") - .setDescription("The new channel for the counter") - .setRequired(false) - ) ) .addSubcommand(subcommand => subcommand @@ -109,7 +104,7 @@ export default { await handleDelete(interaction, client); break; default: - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed("Unknown subcommand.")], flags: MessageFlags.Ephemeral }); @@ -124,7 +119,7 @@ export default { }); if (!interaction.replied && !interaction.deferred) { - await interaction.reply({ embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); + await InteractionHelper.safeReply(interaction, { embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); } else { await interaction.followUp({ embeds: [errorEmbedMsg], flags: MessageFlags.Ephemeral }).catch(logger.error); } diff --git a/src/commands/Counter/modules/counter_create.js b/src/commands/Counter/modules/counter_create.js index f7a0cc953f..9a98999ca5 100644 --- a/src/commands/Counter/modules/counter_create.js +++ b/src/commands/Counter/modules/counter_create.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -8,6 +8,7 @@ import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export async function handleCreate(interaction, client) { const guild = interaction.guild; const type = interaction.options.getString("type"); @@ -16,7 +17,7 @@ export async function handleCreate(interaction, client) { // Defer reply immediately to ensure interaction is acknowledged try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); } catch (error) { logger.error("Failed to defer reply:", error); return; @@ -24,7 +25,7 @@ export async function handleCreate(interaction, client) { // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("You need **Manage Channels** permission to create counters.")] }).catch(logger.error); return; @@ -32,31 +33,23 @@ export async function handleCreate(interaction, client) { try { if (!category || category.type !== ChannelType.GuildCategory) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Please select a valid category for the counter channel.")] }).catch(logger.error); return; } const targetChannelType = channelType === 'voice' ? ChannelType.GuildVoice : ChannelType.GuildText; - const baseNameMap = { - members: 'Members', - bots: 'Bots', - members_only: 'Humans' - }; - const baseChannelName = baseNameMap[type] || 'Counter'; + const baseChannelName = getCounterBaseName(type); const counters = await getServerCounters(client, guild.id); - const duplicateInCategory = counters.find(counter => { - if (counter.type !== type) return false; - const existingChannel = guild.channels.cache.get(counter.channelId); - return existingChannel?.parentId === category.id; - }); + const duplicateType = counters.find(counter => counter.type === type); - if (duplicateInCategory) { - await interaction.editReply({ - embeds: [errorEmbed(`A **${type.replace('_', ' ')}** counter already exists in ${category}. `)] + if (duplicateType) { + const duplicateChannel = guild.channels.cache.get(duplicateType.channelId); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed(`A **${getCounterTypeLabel(type)}** counter already exists for this server${duplicateChannel ? ` in ${duplicateChannel}` : ''}. Delete it first before creating another.`)] }).catch(logger.error); return; } @@ -70,7 +63,7 @@ export async function handleCreate(interaction, client) { const existingCounter = counters.find(c => c.channelId === targetChannel.id); if (existingCounter) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed(`A counter already exists for channel **${targetChannel.name}**. Please delete it first or choose a different type.`)] }).catch(logger.error); return; @@ -90,7 +83,7 @@ export async function handleCreate(interaction, client) { const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { await targetChannel.delete('Counter creation failed during save').catch(() => null); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Failed to save counter data. Please try again.")] }).catch(logger.error); return; @@ -98,25 +91,19 @@ export async function handleCreate(interaction, client) { const updated = await updateCounter(client, guild, newCounter); if (!updated) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Counter created but failed to update channel name. The counter will update on the next scheduled run.")] }).catch(logger.error); return; } - const typeDisplay = { - members: "members and bots", - bots: "bots only", - members_only: "members only" - }; - - await interaction.editReply({ - embeds: [successEmbed(`โœ… **Counter Created Successfully!**\n\n**Type:** ${typeDisplay[type]}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/counter list\` to view all counters.`)] + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed(`โœ… **Counter Created Successfully!**\n\n**Type:** ${getCounterTypeLabel(type)}\n**Channel Type:** ${targetChannel.type === ChannelType.GuildVoice ? 'voice' : 'text'}\n**Category:** ${category}\n**Channel:** ${targetChannel}\n**Channel Name:** ${targetChannel.name}\n**Counter ID:** \`${newCounter.id}\`\n\nThe counter will automatically update every 15 minutes.\n\nUse \`/counter list\` to view all counters.`)] }).catch(logger.error); } catch (error) { logger.error("Error creating counter:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("An error occurred while creating the counter. Please try again.")] }).catch(logger.error); } diff --git a/src/commands/Counter/modules/counter_delete.js b/src/commands/Counter/modules/counter_delete.js index b2cca04a7e..9a466e308a 100644 --- a/src/commands/Counter/modules/counter_delete.js +++ b/src/commands/Counter/modules/counter_delete.js @@ -1,7 +1,7 @@ import { getColor } from '../../../config/bot.js'; import { PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -9,13 +9,14 @@ import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export async function handleDelete(interaction, client) { const guild = interaction.guild; const counterId = interaction.options.getString("counter-id"); // Defer reply immediately to ensure interaction is acknowledged try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); } catch (error) { logger.error("Failed to defer reply:", error); return; @@ -23,7 +24,7 @@ export async function handleDelete(interaction, client) { // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("You need **Manage Channels** permission to delete counters.")] }).catch(logger.error); return; @@ -33,7 +34,7 @@ export async function handleDelete(interaction, client) { const counters = await getServerCounters(client, guild.id); if (counters.length === 0) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("No counters found to delete.")] }).catch(logger.error); return; @@ -41,7 +42,7 @@ export async function handleDelete(interaction, client) { const counterToDelete = counters.find(c => c.id === counterId); if (!counterToDelete) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] }).catch(logger.error); return; @@ -66,11 +67,11 @@ export async function handleDelete(interaction, client) { .setStyle(ButtonStyle.Secondary) ); - await interaction.editReply({ embeds: [embed], components: [row] }).catch(logger.error); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [row] }).catch(logger.error); } catch (error) { logger.error("Error in handleDelete:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] }).catch(logger.error); } @@ -146,12 +147,7 @@ export async function performDeletionByCounterId(client, guild, counterId) { function getCounterTypeDisplay(type) { - const types = { - members: "๐Ÿ‘ฅ Members", - bots: "๐Ÿค– Bots", - members_only: "๐Ÿ‘ค Humans" - }; - return types[type] || "โ“ Unknown"; + return `${getCounterEmoji(type)} ${getCounterTypeLabel(type)}`; } diff --git a/src/commands/Counter/modules/counter_list.js b/src/commands/Counter/modules/counter_list.js index 5e408b97bc..b443ce473b 100644 --- a/src/commands/Counter/modules/counter_list.js +++ b/src/commands/Counter/modules/counter_list.js @@ -1,7 +1,7 @@ import { getColor } from '../../../config/bot.js'; import { PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; -import { getServerCounters } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -9,12 +9,13 @@ import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export async function handleList(interaction, client) { const guild = interaction.guild; // Defer reply immediately to ensure interaction is acknowledged try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); } catch (error) { logger.error("Failed to defer reply:", error); return; @@ -22,7 +23,7 @@ export async function handleList(interaction, client) { // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("You need **Manage Channels** permission to view counters.")] }).catch(logger.error); return; @@ -30,8 +31,29 @@ export async function handleList(interaction, client) { try { const counters = await getServerCounters(client, guild.id); + const stats = await getGuildCounterStats(guild); - if (counters.length === 0) { + // Clean up counters with deleted channels + const validCounters = []; + const orphanedCounters = []; + + for (const counter of counters) { + const channel = guild.channels.cache.get(counter.channelId); + if (channel) { + validCounters.push(counter); + } else { + orphanedCounters.push(counter); + logger.info(`Removing orphaned counter ${counter.id} (type: ${counter.type}, deleted channel: ${counter.channelId}) from guild ${guild.id}`); + } + } + + // Save cleaned counters if any were orphaned + if (orphanedCounters.length > 0) { + await saveServerCounters(client, guild.id, validCounters); + logger.info(`Cleaned up ${orphanedCounters.length} orphaned counter(s) from guild ${guild.id}`); + } + + if (validCounters.length === 0) { const embed = createEmbed({ title: "๐Ÿ“‹ Server Counters", description: "No counters have been set up for this server yet.\n\nUse `/counter create` to set up your first counter!", @@ -40,7 +62,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "๐Ÿ”ง **Available Counter Types**", - value: "๐Ÿ‘ฅ **Members** - Total server members\n๐Ÿค– **Bots** - Bot count only\n๐Ÿ‘ค **Humans** - Human members only", + value: "๐Ÿ‘ฅ **Members + Bots** - Total server members\n๐Ÿ‘ค **Members Only** - Human members only\n๐Ÿค– **Bots Only** - Bot members only", inline: false }); @@ -54,34 +76,31 @@ export async function handleList(interaction, client) { text: "Counter System โ€ข Automatic updates every 15 minutes" }); - await interaction.editReply({ embeds: [embed] }).catch(logger.error); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }).catch(logger.error); return; } const embed = createEmbed({ - title: `๐Ÿ“‹ Server Counters (${counters.length})`, + title: `๐Ÿ“‹ Server Counters (${validCounters.length})`, description: "Here are all the active counters for this server.\n\nCounters automatically update every 15 minutes.", color: getColor('info') }); - for (let i = 0; i < counters.length; i++) { - const counter = counters[i]; + for (let i = 0; i < validCounters.length; i++) { + const counter = validCounters[i]; const channel = guild.channels.cache.get(counter.channelId); if (!channel) { - embed.addFields({ - name: `โŒ Counter #${i + 1} - Channel Missing`, - value: `**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}\n**Channel:** Deleted channel (ID: ${counter.channelId})\n**Status:** โš ๏ธ Channel no longer exists\n**Created:** ${new Date(counter.createdAt).toLocaleDateString()}`, - inline: false - }); + // This should not happen since we filtered above, but keep as safety check + logger.warn(`Counter ${counter.id} still has missing channel after cleanup`); continue; } - const currentCount = getCurrentCount(guild, counter.type); + const currentCount = getCurrentCount(stats, counter.type); const status = channel.name.includes(':') ? 'โœ… Active' : 'โš ๏ธ Not Updated'; embed.addFields({ - name: `${getCounterEmoji(counter.type)} Counter #${i + 1} - ${channel.name}`, + name: `${getCounterTypeEmoji(counter.type)} Counter #${i + 1} - ${channel.name}`, value: `**ID:** \`${counter.id}\`\n**Type:** ${getCounterTypeDisplay(counter.type)}\n**Channel:** ${channel}\n**Current Count:** ${currentCount}\n**Status:** ${status}\n**Created:** ${new Date(counter.createdAt).toLocaleDateString()}`, inline: false }); @@ -89,7 +108,7 @@ export async function handleList(interaction, client) { embed.addFields({ name: "๐Ÿ“Š **Statistics**", - value: `**Total Counters:** ${counters.length}\n**Active Counters:** ${counters.filter(c => { + value: `**Total Counters:** ${validCounters.length}\n**Active Counters:** ${validCounters.filter(c => { const channel = guild.channels.cache.get(c.channelId); return channel && channel.name.includes(':'); }).length}\n**Next Update:** `, @@ -107,11 +126,11 @@ export async function handleList(interaction, client) { }); embed.setTimestamp(); - await interaction.editReply({ embeds: [embed] }).catch(logger.error); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }).catch(logger.error); } catch (error) { logger.error("Error displaying counters:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("An error occurred while fetching counters. Please try again.")] }).catch(logger.error); } @@ -123,12 +142,7 @@ export async function handleList(interaction, client) { function getCounterTypeDisplay(type) { - const types = { - members: "๐Ÿ‘ฅ Members", - bots: "๐Ÿค– Bots", - members_only: "๐Ÿ‘ค Humans" - }; - return types[type] || "โ“ Unknown"; + return `${getCounterTypeEmoji(type)} ${getCounterTypeLabel(type)}`; } @@ -137,12 +151,7 @@ function getCounterTypeDisplay(type) { function getCounterEmoji(type) { - const emojis = { - members: "๐Ÿ‘ฅ", - bots: "๐Ÿค–", - members_only: "๐Ÿ‘ค" - }; - return emojis[type] || "โ“"; + return getCounterTypeEmoji(type); } @@ -151,14 +160,14 @@ function getCounterEmoji(type) { -function getCurrentCount(guild, type) { +function getCurrentCount(stats, type) { switch (type) { case "members": - return guild.memberCount; + return stats.totalCount; case "bots": - return guild.members.cache.filter((m) => m.user.bot).size; + return stats.botCount; case "members_only": - return guild.members.cache.filter((m) => !m.user.bot).size; + return stats.humanCount; default: return 0; } diff --git a/src/commands/Counter/modules/counter_update.js b/src/commands/Counter/modules/counter_update.js index 1442495001..18d0ed4e46 100644 --- a/src/commands/Counter/modules/counter_update.js +++ b/src/commands/Counter/modules/counter_update.js @@ -1,6 +1,6 @@ -import { PermissionFlagsBits, ChannelType } from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; import { logger } from '../../../utils/logger.js'; @@ -8,15 +8,15 @@ import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export async function handleUpdate(interaction, client) { const guild = interaction.guild; const counterId = interaction.options.getString("counter-id"); const newType = interaction.options.getString("type"); - const newChannel = interaction.options.getChannel("channel"); // Defer reply immediately to ensure interaction is acknowledged try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); } catch (error) { logger.error("Failed to defer reply:", error); return; @@ -24,22 +24,15 @@ export async function handleUpdate(interaction, client) { // Check permissions after deferring if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("You need **Manage Channels** permission to update counters.")] }).catch(logger.error); return; } - if (newChannel && newChannel.type !== ChannelType.GuildText && newChannel.type !== ChannelType.GuildVoice) { - await interaction.editReply({ - embeds: [errorEmbed("Counters can only be placed on text or voice channels.")] - }).catch(logger.error); - return; - } - - if (!newType && !newChannel) { - await interaction.editReply({ - embeds: [errorEmbed("You must provide at least one option to update (type or channel).")] + if (!newType) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed("You must provide a new counter type to update.")] }).catch(logger.error); return; } @@ -49,7 +42,7 @@ export async function handleUpdate(interaction, client) { const counterIndex = counters.findIndex(c => c.id === counterId); if (counterIndex === -1) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed(`Counter with ID \`${counterId}\` not found. Use \`/counter list\` to see all counters.`)] }).catch(logger.error); return; @@ -59,32 +52,31 @@ export async function handleUpdate(interaction, client) { const oldChannel = guild.channels.cache.get(counter.channelId); if (!oldChannel) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("The channel for this counter no longer exists. You cannot update a counter for a deleted channel.")] }).catch(logger.error); return; } - if (newChannel && newChannel.id !== counter.channelId) { - const existingCounter = counters.find(c => c.channelId === newChannel.id); - if (existingCounter) { - await interaction.editReply({ - embeds: [errorEmbed("The selected channel already has a counter. Please choose a different channel.")] + if (newType !== counter.type) { + const existingTypeCounter = counters.find(c => c.type === newType && c.id !== counter.id); + if (existingTypeCounter) { + const existingChannel = guild.channels.cache.get(existingTypeCounter.channelId); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed(`A **${getCounterTypeLabel(newType)}** counter already exists for this server${existingChannel ? ` in ${existingChannel}` : ''}. Delete it first before reusing that type.`)] }).catch(logger.error); return; } } const oldType = counter.type; - const oldChannelId = counter.channelId; - if (newType) counter.type = newType; - if (newChannel) counter.channelId = newChannel.id; + counter.type = newType; counter.updatedAt = new Date().toISOString(); const saved = await saveServerCounters(client, guild.id, counters); if (!saved) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Failed to save updated counter data. Please try again.")] }).catch(logger.error); return; @@ -93,40 +85,21 @@ export async function handleUpdate(interaction, client) { const updatedCounter = counters[counterIndex]; const updated = await updateCounter(client, guild, updatedCounter); if (!updated) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Counter updated but failed to update channel name. The counter will update on the next scheduled run.")] }).catch(logger.error); return; } const finalChannel = guild.channels.cache.get(updatedCounter.channelId); - const typeEmoji = { - members: "๐Ÿ‘ฅ", - bots: "๐Ÿค–", - members_only: "๐Ÿ‘ค" - }; - - const typeDisplay = { - members: "Members", - bots: "Bots", - members_only: "Humans" - }; - - const changes = []; - if (newType && newType !== oldType) { - changes.push(`**Type:** ${typeEmoji[oldType]} ${typeDisplay[oldType]} โ†’ ${typeEmoji[newType]} ${typeDisplay[newType]}`); - } - if (newChannel && newChannel.id !== oldChannelId) { - changes.push(`**Channel:** ${oldChannel} โ†’ ${finalChannel}`); - } - await interaction.editReply({ - embeds: [successEmbed(`โœ… **Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n\n${changes.join('\n')}\n\n**New Settings:**\n**Type:** ${typeEmoji[updatedCounter.type]} ${typeDisplay[updatedCounter.type]}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel.name}\n\nThe counter will automatically update every 15 minutes.`)] + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed(`โœ… **Counter Updated Successfully!**\n\n**Counter ID:** \`${counterId}\`\n**Type Changed:** ${getCounterEmoji(oldType)} ${getCounterTypeLabel(oldType)} โ†’ ${getCounterEmoji(newType)} ${getCounterTypeLabel(newType)}\n\n**Current Settings:**\n**Type:** ${getCounterEmoji(updatedCounter.type)} ${getCounterTypeLabel(updatedCounter.type)}\n**Channel:** ${finalChannel}\n**Channel Name:** ${finalChannel.name}\n\nThe counter will automatically update every 15 minutes.`)] }).catch(logger.error); } catch (error) { logger.error("Error updating counter:", error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("An error occurred while updating the counter. Please try again.")] }).catch(logger.error); } diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js index 434ab320ea..16cd240eb3 100644 --- a/src/commands/Economy/balance.js +++ b/src/commands/Economy/balance.js @@ -77,7 +77,7 @@ export default { logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'balance' }) }; diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js index 694d831ed5..260ebc656f 100644 --- a/src/commands/Economy/beg.js +++ b/src/commands/Economy/beg.js @@ -96,7 +96,7 @@ userData.lastBeg = Date.now(); await setEconomyData(client, guildId, userId, userData); - await interaction.editReply({ embeds: [replyEmbed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); }, { command: 'beg' }) }; diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js index b4ce04d57c..72f4c62bb9 100644 --- a/src/commands/Economy/buy.js +++ b/src/commands/Economy/buy.js @@ -153,7 +153,7 @@ export default { inline: true, }); - await interaction.editReply({ embeds: [embed], flags: [MessageFlags.Ephemeral] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); }, { command: 'buy' }) }; diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js index 729b1f1555..2403fe4613 100644 --- a/src/commands/Economy/crime.js +++ b/src/commands/Economy/crime.js @@ -38,7 +38,7 @@ export default { ), execute: withErrorHandling(async (interaction, config, client) => { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; const guildId = interaction.guildId; @@ -100,7 +100,7 @@ export default { `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } else { const fine = Math.floor(amountEarned * 0.2); userData.wallet = Math.max(0, (userData.wallet || 0) - fine); @@ -114,7 +114,7 @@ export default { `You were fined ${fine} coins and will be in jail for 2 hours.` ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } }, { command: 'crime' }) }; diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js index e07520a353..ba3d9bee3d 100644 --- a/src/commands/Economy/daily.js +++ b/src/commands/Economy/daily.js @@ -41,11 +41,10 @@ export default { if (now < lastDaily + DAILY_COOLDOWN) { const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - logger.warn(`[ECONOMY] Daily cooldown active`, { userId, timeRemaining }); throw createError( "Daily cooldown active", ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in ${formatDuration(timeRemaining)}.`, + `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, { timeRemaining, cooldownType: 'daily' } ); } @@ -99,7 +98,7 @@ export default { : `Next claim in 24 hours.`, }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'daily' }) }; diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js index a8e1cbbfb9..79cab9b424 100644 --- a/src/commands/Economy/deposit.js +++ b/src/commands/Economy/deposit.js @@ -134,7 +134,7 @@ export default { }, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'deposit' }) }; diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js index 1620329873..9048d6ea58 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.js @@ -122,7 +122,7 @@ return b.net_worth - a.net_worth; text: `Your Rank: ${userRank > 0 ? userRank : "Not Ranked"} | Data sorted by ${fieldNameMap[sortBy]}`, }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'eleaderboard' }) }; diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js index 7da9a3bc33..367ad453eb 100644 --- a/src/commands/Economy/fish.js +++ b/src/commands/Economy/fish.js @@ -130,6 +130,6 @@ export default { ) .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'fish' }) }; diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js index c13628f431..d0bb7c3bde 100644 --- a/src/commands/Economy/gamble.js +++ b/src/commands/Economy/gamble.js @@ -127,7 +127,7 @@ userData.lastGamble = now; }); } - await interaction.editReply({ embeds: [resultEmbed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'gamble' }) }; diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js index 7f7f77415d..7369c83883 100644 --- a/src/commands/Economy/inventory.js +++ b/src/commands/Economy/inventory.js @@ -65,7 +65,7 @@ export default { description: inventoryDescription, }).setThumbnail(interaction.user.displayAvatarURL()); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'inventory' }) }; diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js index db3188c526..f8dd54cba9 100644 --- a/src/commands/Economy/mine.js +++ b/src/commands/Economy/mine.js @@ -89,7 +89,7 @@ userData.lastMine = now; }) .setFooter({ text: `Next mine available in 1 hour.` }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'mine' }) }; diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js index a74e8923d7..609f91e2fc 100644 --- a/src/commands/Economy/pay.js +++ b/src/commands/Economy/pay.js @@ -126,7 +126,7 @@ export default { iconURL: receiver.displayAvatarURL(), }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`[ECONOMY] Payment sent successfully`, { senderId, diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js index 06726ecebd..d6ae214966 100644 --- a/src/commands/Economy/rob.js +++ b/src/commands/Economy/rob.js @@ -90,7 +90,7 @@ export default { robberData.lastRob = now; await setEconomyData(client, guildId, robberId, robberData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ MessageTemplates.ERRORS.CONFIGURATION_REQUIRED( "robbery protection", @@ -148,7 +148,7 @@ export default { ) .setFooter({ text: `Next robbery available in 4 hours.` }); - await interaction.editReply({ embeds: [resultEmbed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); }, { command: 'rob' }) }; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index f7a61dd8d1..298e535474 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -1,6 +1,6 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder } from 'discord.js'; +import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; import { shopItems } from '../../config/shop/items.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getColor } from '../../config/bot.js'; export default { data: new SlashCommandBuilder() @@ -9,7 +9,8 @@ export default { async execute(interaction, config, client) { try { - const ITEMS_PER_PAGE = 2; + const TARGET_MAX_PAGES = 3; + const ITEMS_PER_PAGE = Math.max(1, Math.ceil(shopItems.length / TARGET_MAX_PAGES)); const totalPages = Math.ceil(shopItems.length / ITEMS_PER_PAGE); let currentPage = 1; @@ -20,13 +21,13 @@ export default { const embed = new EmbedBuilder() .setTitle("๐Ÿ›’ Store") - .setColor(0x2b2d31) - .setDescription(`Click a button below to instantly buy an item, or use the \`/item buy\` command.\nFor more details before purchasing, use the \`/item info\` command.`); + .setColor(getColor('primary')) + .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); pageItems.forEach(item => { embed.addFields({ - name: `${item.name}`, - value: `๐Ÿท๏ธ **Type:** ${item.type}\n${item.description}\n๐Ÿ’š **Price:** ${item.price}`, + name: `${item.name} (${item.id})`, + value: `๐Ÿท๏ธ **Type:** ${item.type}\n๐Ÿ’š **Price:** $${item.price.toLocaleString()}\n${item.description}`, inline: false, }); }); @@ -36,42 +37,24 @@ export default { }; const createShopComponents = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, endIndex); - - const components = []; - - pageItems.forEach((item, index) => { - const isLastItem = index === pageItems.length - 1; - const row = new ActionRowBuilder(); + if (totalPages <= 1) { + return []; + } - row.addComponents( + return [ + new ActionRowBuilder().addComponents( new ButtonBuilder() - .setCustomId(`shop_buy:${item.id}`) - .setLabel('Buy') - .setStyle(ButtonStyle.Primary) - ); - - if (isLastItem && totalPages > 1) { - row.addComponents( - new ButtonBuilder() - .setCustomId('shop_prev') - .setLabel('โฌ…๏ธ Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1), - new ButtonBuilder() - .setCustomId('shop_next') - .setLabel('Next โžก๏ธ') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages) - ); - } - - components.push(row); - }); - - return components; + .setCustomId('shop_prev') + .setLabel('โฌ…๏ธ Previous') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === 1), + new ButtonBuilder() + .setCustomId('shop_next') + .setLabel('Next โžก๏ธ') + .setStyle(ButtonStyle.Secondary) + .setDisabled(page === totalPages) + ) + ]; }; const message = await interaction.reply({ @@ -107,81 +90,6 @@ export default { embeds: [createShopEmbed(currentPage)], components: createShopComponents(currentPage) }); - return; - } - - if (customId.startsWith('shop_buy:')) { - const deferred = await InteractionHelper.safeDefer(buttonInteraction, { flags: 64 }); - if (!deferred) return; - - const itemId = customId.split(':')[1]; - const item = shopItems.find(i => i.id === itemId); - - if (!item) { - return await buttonInteraction.editReply({ - content: 'โŒ Item not found.' - }); - } - - try { - const { getEconomyData, setEconomyData } = await import('../../utils/economy.js'); - const { getGuildConfig } = await import('../../services/guildConfig.js'); - const { successEmbed, errorEmbed } = await import('../../utils/embeds.js'); - - const userId = buttonInteraction.user.id; - const guildId = buttonInteraction.guildId; - const totalCost = item.price; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - return await buttonInteraction.editReply({ - embeds: [errorEmbed( - 'Insufficient Funds', - `You need **$${totalCost.toLocaleString()}** but only have **$${userData.wallet.toLocaleString()}**` - )] - }); - } - - if (item.type === 'role' && itemId === 'premium_role') { - const guildConfig = await getGuildConfig(client, guildId); - const roleId = guildConfig.premiumRoleId; - - if (!roleId) { - return await buttonInteraction.editReply({ - embeds: [errorEmbed('Error', 'Premium role not configured')] - }); - } - - if (buttonInteraction.member.roles.cache.has(roleId)) { - return await buttonInteraction.editReply({ - embeds: [errorEmbed('Already Own', `You already have this role`)] - }); - } - - await buttonInteraction.member.roles.add(roleId); - } - - if (!userData.inventory) userData.inventory = {}; - userData.inventory[itemId] = (userData.inventory[itemId] || 0) + 1; - userData.wallet -= totalCost; - - await setEconomyData(client, guildId, userId, userData); - - await buttonInteraction.editReply({ - embeds: [successEmbed( - `โœ… Purchased **${item.name}** for **$${totalCost.toLocaleString()}**\n\n**New Balance:** $${userData.wallet.toLocaleString()}`, - '๐Ÿ›’ Purchase Complete' - )] - }); - - console.log(`โœ… User ${buttonInteraction.user.tag} purchased ${item.id} for $${totalCost}`); - } catch (error) { - console.error('Purchase error:', error); - await buttonInteraction.editReply({ - embeds: [errorEmbed('Error', 'Failed to process purchase')] - }); - } } }); @@ -203,7 +111,7 @@ export default { console.error('Shop command error:', error); await interaction.reply({ content: 'โŒ An error occurred while loading the shop.', - flags: 64 + flags: MessageFlags.Ephemeral }); } }, diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js index 57feaa0850..3d4791cf95 100644 --- a/src/commands/Economy/slut.js +++ b/src/commands/Economy/slut.js @@ -1,56 +1,110 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { createEmbed } from '../../utils/embeds.js'; import { getEconomyData, setEconomyData } from '../../utils/economy.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; const SLUT_COOLDOWN = 45 * 60 * 1000; -const MIN_SLUT_AMOUNT = 100; -const MAX_SLUT_AMOUNT = 800; -const FAILURE_RATE = 0.25; const SLUT_ACTIVITIES = [ - { name: "Street Walking", min: 100, max: 400, risk: 0.2 }, - { name: "Escort Service", min: 200, max: 600, risk: 0.25 }, - { name: "Private Party", min: 300, max: 800, risk: 0.3 }, - { name: "VIP Client", min: 500, max: 1200, risk: 0.35 }, - { name: "Exclusive Service", min: 800, max: 2000, risk: 0.4 }, + { name: "Cam Stream", min: 120, max: 450, risk: 0.2 }, + { name: "Private Dance Session", min: 220, max: 700, risk: 0.25 }, + { name: "After-Hours Club Host", min: 320, max: 900, risk: 0.3 }, + { name: "VIP Companion Booking", min: 550, max: 1400, risk: 0.35 }, + { name: "Exclusive Livestream", min: 850, max: 2200, risk: 0.4 }, ]; -const SLUT_RESPONSES = [ - "You had an amazing night and earned ๐Ÿ’ฐ", - "Your client was very generous! You made ๐Ÿ’ฐ", - "Business is booming! You earned ๐Ÿ’ฐ", - "That was profitable! You received ๐Ÿ’ฐ", - "Your skills paid off! You got ๐Ÿ’ฐ", +const POSITIVE_OUTCOMES = [ + "Your stream blew up and tips poured in.", + "A VIP booking paid far above average.", + "Your after-hours shift was packed and profitable.", + "Premium requests came through and your payout jumped.", ]; -const FAILURE_RESPONSES = [ - "Unfortunately, things didn't go well tonight. You earned nothing.", - "Your client wasn't satisfied. No payment for you.", - "It was a slow night. Better luck next time!", - "Things went wrong and you had to leave empty-handed.", - "Unfortunately, you got caught and had to run away!", +const FINE_OUTCOMES = [ + "Venue security issued a compliance fine.", + "A moderation strike triggered a platform fee.", + "You were flagged and had to pay a penalty.", ]; +const ROBBED_OUTCOMES = [ + "A fake buyer chargeback wiped part of your earnings.", + "A scam booking cleaned out a chunk of your cash.", + "You got baited by a fraud account and lost money.", +]; + +const LOSS_OUTCOMES = [ + "The set flopped and you had to cover operating costs.", + "You burned budget on prep and made no return.", + "The shift went sideways and left you in the red.", +]; + +function randomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +function randomChoice(items) { + return items[Math.floor(Math.random() * items.length)]; +} + +function resolveOutcome(activity, wallet) { + const successChance = Math.max(0.35, 0.55 - activity.risk * 0.2); + const fineChance = 0.22; + const robbedChance = 0.2; + const roll = Math.random(); + + if (roll < successChance) { + const amount = randomInt(activity.min, activity.max); + return { + type: 'payout', + delta: amount, + message: randomChoice(POSITIVE_OUTCOMES), + title: `๐Ÿ’ฐ ${activity.name} - Payout` + }; + } + + const remainingAfterSuccess = roll - successChance; + + if (remainingAfterSuccess < fineChance) { + const maxFine = Math.min(wallet, Math.max(150, Math.floor(activity.max * 0.4))); + const minFine = Math.min(maxFine, Math.max(50, Math.floor(activity.min * 0.2))); + const amount = maxFine > 0 ? randomInt(minFine, maxFine) : 0; + return { + type: 'fine', + delta: -amount, + message: randomChoice(FINE_OUTCOMES), + title: `๐Ÿšจ ${activity.name} - Fined` + }; + } + + if (remainingAfterSuccess < fineChance + robbedChance) { + const maxRobbed = Math.min(wallet, Math.max(200, Math.floor(wallet * 0.35))); + const minRobbed = Math.min(maxRobbed, Math.max(75, Math.floor(wallet * 0.1))); + const amount = maxRobbed > 0 ? randomInt(minRobbed, maxRobbed) : 0; + return { + type: 'robbed', + delta: -amount, + message: randomChoice(ROBBED_OUTCOMES), + title: `๐Ÿ•ต๏ธ ${activity.name} - Robbed` + }; + } + + const maxLoss = Math.min(wallet, Math.max(100, Math.floor(activity.max * 0.3))); + const minLoss = Math.min(maxLoss, Math.max(40, Math.floor(activity.min * 0.15))); + const amount = maxLoss > 0 ? randomInt(minLoss, maxLoss) : 0; + return { + type: 'loss', + delta: -amount, + message: randomChoice(LOSS_OUTCOMES), + title: `โŒ ${activity.name} - Loss` + }; +} + export default { data: new SlashCommandBuilder() .setName('slut') - .setDescription('Do risky work to earn money') - .addStringOption(option => - option - .setName('activity') - .setDescription('Type of activity to perform') - .setRequired(false) - .addChoices( - { name: 'Street Walking', value: 'street_walking' }, - { name: 'Escort Service', value: 'escort_service' }, - { name: 'Private Party', value: 'private_party' }, - { name: 'VIP Client', value: 'vip_client' }, - { name: 'Exclusive Service', value: 'exclusive_service' }, - ) - ), + .setDescription('Take a risky provocative job for random payout or loss'), execute: withErrorHandling(async (interaction, config, client) => { const deferred = await InteractionHelper.safeDefer(interaction); @@ -77,7 +131,6 @@ export default { if (now - lastSlut < SLUT_COOLDOWN) { const remainingTime = lastSlut + SLUT_COOLDOWN - now; - logger.warn(`[ECONOMY] Slut cooldown active`, { userId, timeRemaining: remainingTime }); throw createError( "Slut cooldown active", ErrorTypes.RATE_LIMIT, @@ -86,75 +139,51 @@ export default { ); } - const activityType = interaction.options.getString("activity"); - const activity = activityType - ? SLUT_ACTIVITIES.find(a => a.name.toLowerCase().replace(/\s+/g, '_') === activityType) - : SLUT_ACTIVITIES[Math.floor(Math.random() * SLUT_ACTIVITIES.length)]; + const activity = randomChoice(SLUT_ACTIVITIES); - if (!activity) { - throw createError( - "Invalid activity selected", - ErrorTypes.VALIDATION, - "Please select a valid activity from the choices." - ); - } + const outcome = resolveOutcome(activity, userData.wallet || 0); - const success = Math.random() > activity.risk; - let earnings = 0; - - if (success) { - earnings = Math.floor(Math.random() * (activity.max - activity.min + 1)) + activity.min; - - userData.wallet += earnings; - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + earnings; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity succeeded`, { - userId, - guildId, - activity: activity.name, - earnings, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const response = SLUT_RESPONSES[Math.floor(Math.random() * SLUT_RESPONSES.length)]; - const embed = successEmbed( - `๐Ÿ’ฐ ${activity.name} - Success!`, - `${response} **$${earnings.toLocaleString()}**!\n\n` + - `๐Ÿ’ณ **New Balance:** $${userData.wallet.toLocaleString()}\n` + - `๐Ÿ“Š **Total Sessions:** ${userData.totalSluts}\n` + - `๐Ÿ’ต **Total Earnings:** $${userData.totalSlutEarnings.toLocaleString()}` - ); - await interaction.editReply({ embeds: [embed] }); - } else { - userData.lastSlut = now; - userData.totalSluts = (userData.totalSluts || 0) + 1; - userData.failedSluts = (userData.failedSluts || 0) + 1; + userData.lastSlut = now; + userData.totalSluts = (userData.totalSluts || 0) + 1; + userData.totalSlutEarnings = (userData.totalSlutEarnings || 0) + Math.max(0, outcome.delta); + userData.totalSlutLosses = (userData.totalSlutLosses || 0) + Math.max(0, -outcome.delta); - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Slut activity failed`, { - userId, - guildId, - activity: activity.name, - failedCount: userData.failedSluts, - timestamp: new Date().toISOString() - }); - - const response = FAILURE_RESPONSES[Math.floor(Math.random() * FAILURE_RESPONSES.length)]; - const embed = errorEmbed( - `โŒ ${activity.name} - Failed`, - `${response}\n\n` + - `๐Ÿ’ณ **Current Balance:** $${userData.wallet.toLocaleString()}\n` + - `๐Ÿ“Š **Total Sessions:** ${userData.totalSluts}\n` + - `โŒ **Failed Sessions:** ${userData.failedSluts}` - ); - await interaction.editReply({ embeds: [embed] }); + if (outcome.type !== 'payout') { + userData.failedSluts = (userData.failedSluts || 0) + 1; } + + userData.wallet = Math.max(0, (userData.wallet || 0) + outcome.delta); + + await setEconomyData(client, guildId, userId, userData); + + logger.info(`[ECONOMY_TRANSACTION] Slut activity resolved`, { + userId, + guildId, + activity: activity.name, + outcomeType: outcome.type, + amountDelta: outcome.delta, + newWallet: userData.wallet, + timestamp: new Date().toISOString() + }); + + const amountLabel = `${outcome.delta >= 0 ? '+' : '-'}$${Math.abs(outcome.delta).toLocaleString()}`; + const summaryLines = [ + `${outcome.message}`, + `๐Ÿ’ธ **Net Result:** ${amountLabel}`, + `๐Ÿ’ณ **Current Balance:** $${userData.wallet.toLocaleString()}`, + `๐Ÿ“Š **Total Sessions:** ${userData.totalSluts}`, + `๐Ÿ’ต **Total Earned:** $${(userData.totalSlutEarnings || 0).toLocaleString()}`, + `๐Ÿงพ **Total Lost:** $${(userData.totalSlutLosses || 0).toLocaleString()}` + ]; + + const embed = createEmbed({ + title: outcome.title, + description: summaryLines.join('\n'), + color: outcome.delta >= 0 ? 'success' : 'error', + timestamp: true + }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'slut' }) }; diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js index 14d211ba0e..2fc0b46a17 100644 --- a/src/commands/Economy/withdraw.js +++ b/src/commands/Economy/withdraw.js @@ -4,6 +4,7 @@ import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/ import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { MessageTemplates } from '../../utils/messageTemplates.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('withdraw') @@ -17,7 +18,7 @@ export default { ), execute: withErrorHandling(async (interaction, config, client) => { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const userId = interaction.user.id; const guildId = interaction.guildId; @@ -80,6 +81,6 @@ export default { }, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'withdraw' }) }; \ No newline at end of file diff --git a/src/commands/Economy/work.js b/src/commands/Economy/work.js index 4e13c7d9f3..ca39b2077b 100644 --- a/src/commands/Economy/work.js +++ b/src/commands/Economy/work.js @@ -62,7 +62,6 @@ export default { usedConsumable = true; } else { const remaining = lastWork + WORK_COOLDOWN - now; - logger.warn(`[ECONOMY] Work cooldown active`, { userId, timeRemaining: remaining }); throw createError( "Work cooldown active", ErrorTypes.RATE_LIMIT, @@ -109,7 +108,7 @@ export default { inline: true, }, { - name: "รขยยฐ Next Work", + name: "โฐ Next Work", value: ``, inline: true, } @@ -119,7 +118,7 @@ export default { iconURL: interaction.user.displayAvatarURL(), }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); }, { command: 'work' }) }; diff --git a/src/commands/Fun/fact.js b/src/commands/Fun/fact.js index 36904e0559..1925b2331b 100644 --- a/src/commands/Fun/fact.js +++ b/src/commands/Fun/fact.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const facts = [ "A day on Venus is longer than a year on Venus.", "The shortest war in history was between Britain and Zanzibar on August 27, 1896. It lasted 38 to 45 minutes.", @@ -24,7 +25,7 @@ export default { const embed = successEmbed("๐Ÿง  Did You Know?", `๐Ÿ’ก **${randomFact}**`); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.debug(`Fact command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Fact command error:', error); diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js index c6083fd06b..23625102de 100644 --- a/src/commands/Fun/fight.js +++ b/src/commands/Fun/fight.js @@ -3,7 +3,9 @@ import { successEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const rand = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const EMBED_DESCRIPTION_LIMIT = 4096; export default { data: new SlashCommandBuilder() @@ -19,7 +21,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const challenger = interaction.user; const opponent = interaction.options.getUser("opponent"); @@ -27,19 +29,19 @@ export default { if (challenger.id === opponent.id) { const embed = warningEmbed( - "โš”๏ธ Invalid Challenge", - `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.` + `**${challenger.username}**, you can't fight yourself! That's a draw before it even starts.`, + "โš”๏ธ Invalid Challenge" ); - return await interaction.editReply({ embeds: [embed] }); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } if (opponent.bot) { const embed = warningEmbed( - "โš”๏ธ Invalid Opponent", - "You can't fight bots! Challenge a real person instead." + "You can't fight bots! Challenge a real person instead.", + "โš”๏ธ Invalid Opponent" ); - return await interaction.editReply({ embeds: [embed] }); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } const winner = rand(0, 1) === 0 ? challenger : opponent; @@ -67,13 +69,19 @@ export default { } const outcomeText = log.join("\n"); + const winnerText = `๐Ÿ‘‘ **${winner.username}** has defeated ${loser.username} and claims the victory!`; + const fullDescription = `${outcomeText}\n\n${winnerText}`; + + const description = fullDescription.length <= EMBED_DESCRIPTION_LIMIT + ? fullDescription + : `${fullDescription.slice(0, EMBED_DESCRIPTION_LIMIT - 15)}\n\n...`; const embed = successEmbed( - "๐Ÿ† Duel Complete!", - `${outcomeText}\n\n๐Ÿ‘‘ **${winner.username}** has defeated ${loser.username} and claims the victory!` + description, + "๐Ÿ† Duel Complete!" ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Fight command error:', error); diff --git a/src/commands/Fun/filp.js b/src/commands/Fun/filp.js index 0037e32a9a..1e87ff589c 100644 --- a/src/commands/Fun/filp.js +++ b/src/commands/Fun/filp.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("flip") @@ -19,7 +20,7 @@ export default { `The coin landed on... **${result}** ${emoji}!`, ); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Flip command error:', error); diff --git a/src/commands/Fun/mock.js b/src/commands/Fun/mock.js index 2b7c85177d..4caf91f94a 100644 --- a/src/commands/Fun/mock.js +++ b/src/commands/Fun/mock.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("mock") @@ -45,7 +46,7 @@ export default { const embed = successEmbed("sPoNgEbOb cAsE", `"${mockedText}"`); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.debug(`Mock command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Mock command error:', error); diff --git a/src/commands/Fun/reverse.js b/src/commands/Fun/reverse.js index f584510e0f..5f6357c8a9 100644 --- a/src/commands/Fun/reverse.js +++ b/src/commands/Fun/reverse.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("reverse") @@ -39,7 +40,7 @@ export default { `Original: **${sanitizedText}**\nReversed: **${reversedText}**`, ); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.debug(`Reverse command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Reverse command error:', error); diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js index a7b819fcbe..122cdb4eea 100644 --- a/src/commands/Fun/roll.js +++ b/src/commands/Fun/roll.js @@ -3,6 +3,7 @@ import { successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("roll") @@ -18,7 +19,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const notation = interaction.options .getString("notation") @@ -76,7 +77,7 @@ export default { `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); } catch (error) { await handleInteractionError(interaction, error, { diff --git a/src/commands/Fun/ship.js b/src/commands/Fun/ship.js index 409f1c6751..5c85ec5de1 100644 --- a/src/commands/Fun/ship.js +++ b/src/commands/Fun/ship.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; function stringToHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -36,7 +37,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const name1Raw = interaction.options.getString("name1"); const name2Raw = interaction.options.getString("name2"); @@ -60,7 +61,7 @@ export default { "๐Ÿ’– Ship Score", `**${name1}** can't be shipped with themselves! Please choose two different people.` ); - return await interaction.editReply({ embeds: [embed] }); + return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } const sortedNames = [name1, name2].sort(); @@ -91,7 +92,7 @@ export default { `Compatibility: **${score}%**\n\n\`${progressBar}\`\n\n*${description}*`, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Ship command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Ship command error:', error); diff --git a/src/commands/Fun/wanted.js b/src/commands/Fun/wanted.js index 961e88c074..83c9f68b1a 100644 --- a/src/commands/Fun/wanted.js +++ b/src/commands/Fun/wanted.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("wanted") @@ -25,7 +26,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const targetUser = interaction.options.getUser("user"); const crimeRaw = interaction.options.getString("crime"); @@ -54,8 +55,8 @@ export default { const bounty = `$${bountyAmount.toLocaleString()} USD`; const embed = createEmbed({ - color: 0x964b00, - title: `๐Ÿ’ฅ รฐยยยรฐยยห†รฐยยโ€  รฐยยยรฐยยลฝรฐยยโ€รฐยยยรฐยยโ€œรฐยยหœ: WANTED! ๐Ÿ’ฅ`, + color: 'primary', + title: '๐Ÿ’ฅ BIG BOUNTY: WANTED! ๐Ÿ’ฅ', description: `**CRIMINAL:** ${targetUser.tag}\n**CRIME:** ${crime}`, fields: [ { @@ -72,7 +73,7 @@ export default { }, }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Wanted command executed by user ${interaction.user.id} for ${targetUser.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Wanted command error:', error); diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js index 53c7fea682..fff217ca04 100644 --- a/src/commands/Giveaway/gcreate.js +++ b/src/commands/Giveaway/gcreate.js @@ -11,6 +11,7 @@ import { createGiveawayButtons } from '../../services/giveawayService.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -174,7 +175,7 @@ export default { logger.info(`Giveaway created successfully: ${giveawayMessage.id} in ${targetChannel.name}`); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( `Giveaway Started! ๐ŸŽ‰`, diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.js index f49fc9e8f4..8a9ac5d635 100644 --- a/src/commands/Giveaway/gdelete.js +++ b/src/commands/Giveaway/gdelete.js @@ -5,11 +5,12 @@ import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/e import { getGuildGiveaways, deleteGiveaway } from '../../utils/giveaways.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("gdelete") .setDescription( - "Deletes a giveaway message and removes it from the database (no winner picked).", + "Deletes a giveaway message and removes it from the database.", ) .addStringOption((option) => option @@ -70,16 +71,40 @@ export default { let deletedMessage = false; let channelName = "Unknown Channel"; + const tryDeleteFromChannel = async (channel) => { + if (!channel || !channel.isTextBased() || !channel.messages?.fetch) { + return false; + } + + const message = await channel.messages.fetch(messageId).catch(() => null); + if (!message) { + return false; + } + + await message.delete(); + channelName = channel.name || 'unknown-channel'; + deletedMessage = true; + return true; + }; + try { const channel = await interaction.client.channels.fetch(giveaway.channelId).catch(() => null); - if (channel && channel.isTextBased()) { - channelName = channel.name; - const message = await channel.messages.fetch(messageId).catch(() => null); - if (message) { - await message.delete(); - deletedMessage = true; - logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); + if (await tryDeleteFromChannel(channel)) { + logger.debug(`Deleted giveaway message ${messageId} from channel ${channelName}`); + } + + if (!deletedMessage && interaction.guild) { + const textChannels = interaction.guild.channels.cache.filter( + ch => ch.id !== giveaway.channelId && ch.isTextBased() && ch.messages?.fetch + ); + + for (const [, guildChannel] of textChannels) { + const foundAndDeleted = await tryDeleteFromChannel(guildChannel).catch(() => false); + if (foundAndDeleted) { + logger.debug(`Deleted giveaway message ${messageId} via fallback lookup in #${channelName}`); + break; + } } } } catch (error) { @@ -87,16 +112,47 @@ export default { } - await deleteGiveaway( + const removedFromDatabase = await deleteGiveaway( interaction.client, interaction.guildId, messageId, ); + if (!removedFromDatabase) { + throw new TitanBotError( + `Failed to delete giveaway from database: ${messageId}`, + ErrorTypes.UNKNOWN, + 'The giveaway could not be removed from the database. Please try again.', + { messageId, guildId: interaction.guildId } + ); + } + + const giveawaysAfterDelete = await getGuildGiveaways(interaction.client, interaction.guildId); + const stillExistsInDatabase = giveawaysAfterDelete.some(g => g.messageId === messageId); + + if (stillExistsInDatabase) { + throw new TitanBotError( + `Giveaway still exists after deletion: ${messageId}`, + ErrorTypes.UNKNOWN, + 'Deletion did not persist in the database. Please try again.', + { messageId, guildId: interaction.guildId } + ); + } + const statusMsg = deletedMessage ? `and the message was deleted from #${channelName}` : `but the message was already deleted or the channel was inaccessible.`; + const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; + const hasWinners = winnerIds.length > 0; + const wasEnded = giveaway.ended === true || giveaway.isEnded === true || hasWinners; + + const winnerStatusMsg = hasWinners + ? `This giveaway already had ${winnerIds.length} winner(s) selected.` + : wasEnded + ? 'This giveaway was ended with no valid winners.' + : 'No winner was picked before deletion.'; + logger.info(`Giveaway deleted: ${messageId} in ${channelName}`); @@ -127,11 +183,11 @@ export default { logger.debug('Error logging giveaway deletion:', logError); } - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "Giveaway Deleted", - `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. No winner was picked.`, + `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, ), ], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js index b81d019c9f..f430da9bdd 100644 --- a/src/commands/Giveaway/gend.js +++ b/src/commands/Giveaway/gend.js @@ -9,6 +9,7 @@ import { createGiveawayButtons } from '../../services/giveawayService.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -185,7 +186,7 @@ export default { logger.info(`Giveaway successfully ended by ${interaction.user.tag}: ${messageId}`); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "Giveaway Ended โœ…", diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js index 7ff5cbadf6..da63c4a31a 100644 --- a/src/commands/Giveaway/greroll.js +++ b/src/commands/Giveaway/greroll.js @@ -9,6 +9,7 @@ import { createGiveawayButtons } from '../../services/giveawayService.js'; import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -128,7 +129,7 @@ export default { logger.warn(`Could not find channel for giveaway ${messageId}, but saved new winners to database`); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "Reroll Complete", @@ -197,7 +198,7 @@ export default { logger.debug('Error logging giveaway reroll:', logError); } - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "Reroll Complete", @@ -266,7 +267,7 @@ export default { logger.debug('Error logging giveaway reroll event:', logError); } - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "Reroll Successful โœ…", diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index 0c0cee5449..e97a543514 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -3,6 +3,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, Ac import { errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; import { initializeJoinToCreate, getChannelConfiguration, @@ -84,7 +85,7 @@ export default { } const subcommand = interaction.options.getSubcommand(); - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); let responseEmbed; @@ -111,9 +112,9 @@ export default { const errorEmbedObj = errorEmbed("โš ๏ธ Error", errorMessage); if (interaction.deferred) { - return await interaction.editReply({ embeds: [errorEmbedObj] }); + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbedObj] }); } else { - return await interaction.reply({ embeds: [errorEmbedObj], flags: MessageFlags.Ephemeral }); + return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbedObj], flags: MessageFlags.Ephemeral }); } } catch (replyError) { logger.error('Failed to send error message:', replyError); @@ -201,7 +202,7 @@ async function handleSetupSubcommand(interaction, client) { `${category ? `โ€ข Category: ${category.name}` : 'โ€ข Category: Root level'}` ); - return await interaction.editReply({ embeds: [responseEmbed] }); + return await InteractionHelper.safeEditReply(interaction, { embeds: [responseEmbed] }); } catch (error) { logger.error('Error in handleSetupSubcommand:', error); @@ -273,7 +274,7 @@ async function handleConfigSubcommand(interaction, client) { const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); - const message = await interaction.editReply({ + const message = await InteractionHelper.safeEditReply(interaction, { embeds: [configEmbed], components: [row] }); @@ -568,7 +569,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, .setStyle(ButtonStyle.Secondary) ); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('โš ๏ธ Confirm Deletion', `Are you sure you want to remove **${triggerChannel.name}** from the Join to Create system?\n\nThis action cannot be undone.`)], components: [confirmRow], flags: MessageFlags.Ephemeral diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js index 44d7b389d5..93fa30ed50 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.js @@ -10,6 +10,7 @@ import { ButtonBuilder, ButtonStyle } from 'discord.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { successEmbed, errorEmbed } from '../../../utils/embeds.js'; import { logger } from '../../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; @@ -88,7 +89,7 @@ export default { const row = new ActionRowBuilder().addComponents(selectMenu); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [row], }).catch(error => { @@ -148,7 +149,7 @@ time: 60000 selectMenu.setDisabled(true) ); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { components: [disabledRow], }).catch(() => {}); } diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js index e359c8ffde..2814fb2b4a 100644 --- a/src/commands/JoinToCreate/modules/setup.js +++ b/src/commands/JoinToCreate/modules/setup.js @@ -4,6 +4,7 @@ import { logger } from '../../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../../utils/errorHandler.js'; import { addJoinToCreateTrigger, getJoinToCreateConfig } from '../../../utils/database.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { const category = interaction.options.getChannel('category'); @@ -47,16 +48,16 @@ export default { try { if (interaction.deferred) { - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } else { - await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); } } catch (responseError) { logger.error('Error responding to interaction:', responseError); try { if (!interaction.replied) { - await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); + await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); } } catch (e) { logger.error('All response attempts failed:', e); diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index 12ec385d48..f58b12dc66 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -8,6 +8,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('leaderboard') @@ -23,7 +24,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const levelingConfig = await getLevelingConfig(client, interaction.guildId); @@ -77,7 +78,7 @@ export default { value: leaderboardText.join('\n') }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); } catch (error) { logger.error('Leaderboard command error:', error); diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js index 4cc8e543f6..a7e4f05f08 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.js @@ -10,6 +10,7 @@ import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { addLevels } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('leveladd') @@ -39,7 +40,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const hasPermission = await checkUserPermissions( @@ -65,7 +66,7 @@ export default { const userData = await addLevels(client, interaction.guildId, targetUser.id, levelsToAdd); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Levels Added', diff --git a/src/commands/Leveling/levelconfig.js b/src/commands/Leveling/levelconfig.js index 9e5a014471..19f794969e 100644 --- a/src/commands/Leveling/levelconfig.js +++ b/src/commands/Leveling/levelconfig.js @@ -10,6 +10,7 @@ import { checkUserPermissions, botHasPermission } from '../../utils/permissionGu import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('levelconfig') @@ -79,7 +80,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const hasPermission = await checkUserPermissions( @@ -99,7 +100,7 @@ export default { await saveLevelingConfig(client, interaction.guildId, levelingConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Leveling System Updated', @@ -129,7 +130,7 @@ export default { await saveLevelingConfig(client, interaction.guildId, levelingConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Level Up Channel Set', @@ -158,7 +159,7 @@ export default { levelingConfig.xpRange = { min, max }; await saveLevelingConfig(client, interaction.guildId, levelingConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… XP Range Updated', @@ -184,7 +185,7 @@ export default { await saveLevelingConfig(client, interaction.guildId, levelingConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Level Up Message Updated', diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.js index 650cf82f80..d7e4228202 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/levelremove.js @@ -10,6 +10,7 @@ import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { removeLevels, getUserLevelData } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('levelremove') @@ -39,7 +40,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const hasPermission = await checkUserPermissions( @@ -75,7 +76,7 @@ export default { const updatedData = await removeLevels(client, interaction.guildId, targetUser.id, levelsToRemove); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Levels Removed', diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.js index 909e372732..aa47112f4d 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/levelset.js @@ -10,6 +10,7 @@ import { checkUserPermissions } from '../../utils/permissionGuard.js'; import { setUserLevel } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('levelset') @@ -39,7 +40,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const hasPermission = await checkUserPermissions( @@ -65,7 +66,7 @@ export default { const userData = await setUserLevel(client, interaction.guildId, targetUser.id, newLevel); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: 'โœ… Level Set', diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js index 9d948f48c7..e4f34d3ffd 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.js @@ -8,6 +8,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('rank') @@ -29,7 +30,7 @@ export default { async execute(interaction, config, client) { try { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const targetUser = interaction.options.getUser('user') || interaction.user; const member = await interaction.guild.members @@ -94,7 +95,7 @@ export default { .setColor('#2ecc71') .setTimestamp(); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Rank command error:', error); diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js index 897110df5c..f12d956361 100644 --- a/src/commands/Moderation/dm.js +++ b/src/commands/Moderation/dm.js @@ -4,6 +4,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { sanitizeMarkdown } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("dm") @@ -38,7 +39,7 @@ export default { try { if (message.length > 2000) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Message Too Long", @@ -51,7 +52,7 @@ export default { if (targetUser.bot) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Cannot DM Bot", @@ -95,7 +96,7 @@ export default { } }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "DM Sent", @@ -107,14 +108,14 @@ export default { logger.error('DM command error:', error); if (error.code === 50007) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("Error", `Could not send a DM to ${targetUser.tag}. They may have DMs disabled.`), ], }); } - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed("Error", `Failed to send DM: ${error.message}`), ], diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 23f5b7c039..96c5f80c37 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -4,6 +4,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("lock") @@ -16,7 +17,7 @@ export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -31,7 +32,7 @@ export default { try { const currentPermissions = channel.permissionsFor(everyoneRole); if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Channel Already Locked", @@ -76,7 +77,7 @@ export default { } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `๐Ÿ”’ **Channel Locked**`, @@ -86,7 +87,7 @@ export default { }); } catch (error) { logger.error('Lock command error:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "An unexpected error occurred while trying to lock the channel. Check my permissions (I need 'Manage Channels').", diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js index f729839007..68aa0d7ced 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.js @@ -4,6 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { checkRateLimit } from '../../utils/rateLimiter.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("massban") @@ -32,7 +33,7 @@ export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -51,7 +52,7 @@ export default { const rateLimitKey = `massban_${interaction.user.id}`; const isAllowed = await checkRateLimit(rateLimitKey, 3, 60000); if (!isAllowed) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ warningEmbed( "You're performing mass bans too fast. Please wait a minute before trying again.", @@ -69,7 +70,7 @@ export default { .slice(0, 20); if (userIds.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Invalid Users", @@ -80,7 +81,7 @@ export default { } if (userIds.includes(interaction.user.id)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Cannot Ban Self", @@ -91,7 +92,7 @@ export default { } if (userIds.includes(client.user.id)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Cannot Ban Bot", @@ -193,7 +194,7 @@ export default { const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ embed( `๐Ÿ”จ Mass Ban Completed`, @@ -204,7 +205,7 @@ export default { } catch (error) { logger.error("Error in massban command:", error); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "System Error", diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js index de576f9587..3ba10acdca 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.js @@ -4,6 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { checkRateLimit } from '../../utils/rateLimiter.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("masskick") @@ -24,7 +25,7 @@ export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -42,7 +43,7 @@ export default { const rateLimitKey = `masskick_${interaction.user.id}`; const isAllowed = await checkRateLimit(rateLimitKey, 3, 60000); if (!isAllowed) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ warningEmbed( "You're performing mass kicks too fast. Please wait a minute before trying again.", @@ -60,7 +61,7 @@ export default { .slice(0, 20); if (userIds.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Invalid Users", @@ -71,7 +72,7 @@ export default { } if (userIds.includes(interaction.user.id)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Cannot Kick Self", @@ -82,7 +83,7 @@ export default { } if (userIds.includes(client.user.id)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Cannot Kick Bot", @@ -176,7 +177,7 @@ export default { const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ embed( `๐Ÿ‘ข Mass Kick Completed`, @@ -187,7 +188,7 @@ export default { } catch (error) { logger.error("Error in masskick command:", error); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "System Error", diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js index 21b4e2272e..8eccfed64b 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { checkRateLimit } from '../../utils/rateLimiter.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("purge") @@ -21,7 +22,7 @@ export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -34,7 +35,7 @@ export default { const channel = interaction.channel; if (amount < 1 || amount > 100) - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Invalid Amount", @@ -48,7 +49,7 @@ export default { const rateLimitKey = `purge_${interaction.user.id}`; const isAllowed = await checkRateLimit(rateLimitKey, 5, 60000); if (!isAllowed) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ warningEmbed( "You're purging messages too fast. Please wait a minute before trying again.", @@ -95,7 +96,7 @@ export default { } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed(`๐Ÿ—‘๏ธ Deleted ${deletedCount} messages in ${channel}.`), ], @@ -109,7 +110,7 @@ flags: MessageFlags.Ephemeral, }, 3000); } catch (error) { logger.error('Purge command error:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.", diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index 9a5034644f..c617ec4f46 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -5,6 +5,7 @@ import { logger } from '../../utils/logger.js'; import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; const durationChoices = [ { name: "5 minutes", value: 5 }, { name: "10 minutes", value: 10 }, @@ -108,7 +109,7 @@ export default { } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `โณ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, @@ -118,7 +119,7 @@ export default { }); } catch (error) { logger.error('Timeout command error:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( error.userMessage || "An unexpected error occurred during the timeout action. Please check my role permissions.", diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js index 2d1bb82ddf..149ffa7956 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.js @@ -4,6 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderationService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("unban") @@ -35,7 +36,7 @@ export default { reason }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "โœ… User Unbanned", diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index 5cbb584bae..07280112b7 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -4,6 +4,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("unlock") @@ -20,7 +21,7 @@ export default { PermissionFlagsBits.ManageChannels, ) ) - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -40,7 +41,7 @@ export default { currentPermissions.has(PermissionFlagsBits.SendMessages) === null ) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Channel Already Unlocked", @@ -91,7 +92,7 @@ export default { } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `๐Ÿ”“ **Channel Unlocked**`, @@ -101,7 +102,7 @@ export default { }); } catch (error) { logger.error('Unlock command error:', error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "An unexpected error occurred while trying to unlock the channel. Check my permissions (I need 'Manage Channels').", diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index cfbb57646a..ecc14e8e3f 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -4,6 +4,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { ModerationService } from '../../services/moderationService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("untimeout") @@ -29,7 +30,7 @@ export default { moderator: interaction.member }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `๐Ÿ”“ **Removed timeout** from ${targetUser.tag}`, diff --git a/src/commands/Moderation/usernotes.js b/src/commands/Moderation/usernotes.js index 5de20b8e0c..c1db5f7cca 100644 --- a/src/commands/Moderation/usernotes.js +++ b/src/commands/Moderation/usernotes.js @@ -9,6 +9,7 @@ import { sanitizeInput } from '../../utils/sanitization.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; function getUserNotesKey(guildId, userId) { return `moderation_user_notes_${guildId}_${userId}`; } @@ -100,7 +101,7 @@ export default { async execute(interaction, config, client) { if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -115,7 +116,7 @@ export default { const guildId = interaction.guild.id; if (subcommand !== "view" && subcommand !== "remove" && subcommand !== "clear" && subcommand !== "add") { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Invalid Subcommand", @@ -142,7 +143,7 @@ export default { case "clear": return await handleClearNotes(interaction, targetUser, notes, guildId); default: - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Invalid Subcommand", @@ -153,7 +154,7 @@ export default { } } catch (error) { logger.error(`Error in usernotes command (${subcommand}):`, error); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "System Error", @@ -171,7 +172,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { const type = interaction.options.getString("type") || "neutral"; if (note.length > 1000) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Note Too Long", @@ -182,7 +183,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { } if (note.length === 0) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Empty Note", @@ -211,7 +212,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { const typeInfo = getNoteTypeInfo(type); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( `${typeInfo.emoji} Note Added`, @@ -226,7 +227,7 @@ async function handleAddNote(interaction, targetUser, notes, guildId) { async function handleViewNotes(interaction, targetUser, notes) { if (notes.length === 0) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( "๐Ÿ“ No Notes", @@ -252,7 +253,7 @@ async function handleViewNotes(interaction, targetUser, notes) { description = description.substring(0, 3900) + "\n... *(truncated)*"; } - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( `๐Ÿ“ User Notes (${notes.length})`, @@ -266,7 +267,7 @@ async function handleRemoveNote(interaction, targetUser, notes, guildId) { const index = interaction.options.getInteger("index") - 1; if (index < 0 || index >= notes.length) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( "Invalid Index", @@ -284,7 +285,7 @@ const index = interaction.options.getInteger("index") - 1; const typeInfo = getNoteTypeInfo(removedNote.type); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( `${typeInfo.emoji} Note Removed`, @@ -300,7 +301,7 @@ async function handleClearNotes(interaction, targetUser, notes, guildId) { const noteCount = notes.length; if (noteCount === 0) { - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ infoEmbed( "No Notes to Clear", @@ -315,7 +316,7 @@ async function handleClearNotes(interaction, targetUser, notes, guildId) { const notesKey = getUserNotesKey(guildId, targetUser.id); await setInDb(notesKey, notes); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [ successEmbed( "๐Ÿ—‘๏ธ Notes Cleared", diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 3da10e5688..6cd7c1ad00 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -4,6 +4,7 @@ import { logModerationAction } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { WarningService } from '../../services/warningService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("warn") @@ -72,7 +73,7 @@ export default { } }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `โš ๏ธ **Warned** ${target.tag}`, diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js index cd93395d68..e22b1b71af 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.js @@ -5,6 +5,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { WarningService } from '../../services/warningService.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("warnings") @@ -28,7 +29,7 @@ export default { const totalWarns = validWarnings.length; if (totalWarns === 0) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: `Warnings: ${target.tag}`, @@ -73,7 +74,7 @@ export default { } }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error('Warnings command error:', error); await handleInteractionError(interaction, error, { subtype: 'warnings_view_failed' }); diff --git a/src/commands/Reaction_roles/rdelete.js b/src/commands/Reaction_roles/rdelete.js index 3f213aaaa0..29659d5ab0 100644 --- a/src/commands/Reaction_roles/rdelete.js +++ b/src/commands/Reaction_roles/rdelete.js @@ -118,7 +118,7 @@ export default { ? 'โœ… Reaction role message has been deleted from both Discord and the database.' : 'โœ… Reaction role message has been deleted from the database.\nโš ๏ธ The Discord message could not be found or deleted.'; - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed('Success', responseMessage)] }); diff --git a/src/commands/Reaction_roles/rlist.js b/src/commands/Reaction_roles/rlist.js index ea232e6db4..d556877189 100644 --- a/src/commands/Reaction_roles/rlist.js +++ b/src/commands/Reaction_roles/rlist.js @@ -25,7 +25,7 @@ export default { if (guildReactionRoles.length === 0) { logger.debug(`No reaction role messages found in guild ${interaction.guild.name}`); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [infoEmbed('No Reaction Roles', 'There are no reaction role messages in this server.')] }); } @@ -76,7 +76,7 @@ export default { } } - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`Reaction role list displayed to ${interaction.user.tag}, showing ${guildReactionRoles.length} messages`); } catch (error) { diff --git a/src/commands/Reaction_roles/rsetup.js b/src/commands/Reaction_roles/rsetup.js index 844c07a16a..056bfa4307 100644 --- a/src/commands/Reaction_roles/rsetup.js +++ b/src/commands/Reaction_roles/rsetup.js @@ -240,7 +240,7 @@ export default { }, { name: '๐Ÿ”— Message Link', - value: `[Jump to Message](${message.url})`, + value: message.url, inline: false } ] @@ -250,8 +250,8 @@ export default { logger.warn('Failed to log reaction role creation:', logError); } - await interaction.editReply({ - embeds: [successEmbed('Success', `โœ… Reaction role message created in ${channel}!\n\n[Jump to Message](${message.url})`)] + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Success', `โœ… Reaction role message created in ${channel}!\n\n${message.url}`)] }); } catch (error) { diff --git a/src/commands/Search/define.js b/src/commands/Search/define.js index 9afdc85b5b..97dd195da6 100644 --- a/src/commands/Search/define.js +++ b/src/commands/Search/define.js @@ -30,7 +30,7 @@ export default { word: word, guildId: interaction.guildId }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Error', 'Please enter a word with at least 2 characters.')], flags: MessageFlags.Ephemeral }); @@ -42,7 +42,7 @@ export default { ); if (!response.data || response.data.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not Found', `No definitions found for "${word}".`)] }); } @@ -77,7 +77,7 @@ export default { embed.setFooter({ text: 'Powered by Free Dictionary API' }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Dictionary definition retrieved', { userId: interaction.user.id, @@ -98,7 +98,7 @@ export default { if (error.response?.status === 404) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('word')}".`)] }); } else { diff --git a/src/commands/Search/google.js b/src/commands/Search/google.js index efb1b62dbb..81844faca4 100644 --- a/src/commands/Search/google.js +++ b/src/commands/Search/google.js @@ -4,6 +4,7 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('google') @@ -24,7 +25,7 @@ export default { }) .setFooter({ text: 'Google Search Results' }); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.info('Google search link generated', { userId: interaction.user.id, diff --git a/src/commands/Search/movie.js b/src/commands/Search/movie.js index 85faefbe1f..a4e72ebc0e 100644 --- a/src/commands/Search/movie.js +++ b/src/commands/Search/movie.js @@ -51,7 +51,7 @@ export default { guildId: interaction.guildId, commandName: 'movie' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Command Disabled", @@ -67,7 +67,7 @@ export default { guildId: interaction.guildId, commandName: 'movie' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Configuration Error", @@ -106,7 +106,7 @@ timeout: 8000, ); if (!searchResponse.data?.results?.length) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Not Found", @@ -186,7 +186,7 @@ timeout: 8000, { name: "Rating", value: result.vote_average - ? `รขยญย ${result.vote_average.toFixed(1)}/10 (${result.vote_count.toLocaleString()} votes)` + ? `โญ ${result.vote_average.toFixed(1)}/10 (${result.vote_count.toLocaleString()} votes)` : "N/A", inline: true, }, @@ -218,7 +218,7 @@ timeout: 8000, ); } - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Movie information retrieved', { userId: interaction.user.id, @@ -240,11 +240,11 @@ timeout: 8000, if (error.response?.status === 404) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not Found', 'The requested movie/TV show could not be found.')] }); } else if (error.response?.status === 401) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Configuration Error', 'Invalid TMDB API key. Please contact the bot administrator.')], flags: MessageFlags.Ephemeral }); diff --git a/src/commands/Search/urban.js b/src/commands/Search/urban.js index 2f9ce992b7..e2020025b8 100644 --- a/src/commands/Search/urban.js +++ b/src/commands/Search/urban.js @@ -18,12 +18,6 @@ export default { async execute(interaction) { try { - - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - const term = interaction.options.getString('term'); if (term.length < 2) { @@ -32,7 +26,7 @@ export default { term: term, guildId: interaction.guildId }); - return await interaction.editReply({ + return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Error', 'Please enter a term with at least 2 characters.')], flags: MessageFlags.Ephemeral }); @@ -45,19 +39,38 @@ export default { guildId: interaction.guildId, commandName: 'urban' }); - return await interaction.editReply({ + return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Command Disabled', 'The Urban Dictionary command is disabled in this server.')], flags: MessageFlags.Ephemeral }); } + + let deferTimer = null; + const clearDeferTimer = () => { + if (deferTimer) { + clearTimeout(deferTimer); + deferTimer = null; + } + }; + + deferTimer = setTimeout(() => { + InteractionHelper.safeDefer(interaction).catch((deferError) => { + logger.debug('Urban command defer fallback failed', { + error: deferError?.message, + interactionId: interaction.id, + commandName: 'urban' + }); + }); + }, 1500); const response = await axios.get( `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`, { timeout: 5000 } ); + clearDeferTimer(); if (!response.data?.list?.length) { - return await interaction.editReply({ + return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Not Found', `No definitions found for "${term}" on Urban Dictionary.`)] }); } @@ -102,7 +115,7 @@ export default { iconURL: 'https://i.imgur.com/8aQrX3a.png' }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.info('Urban Dictionary definition retrieved', { userId: interaction.user.id, @@ -124,11 +137,11 @@ export default { if (error.response?.status === 404 || !error.response) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not Found', `No definitions found for "${interaction.options.getString('term')}" on Urban Dictionary.`)] }); } else if (error.response?.status === 429) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Rate Limited', 'Too many requests to Urban Dictionary. Please try again in a few minutes.')] }); } else { diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.js index 3af28e4ab7..f9ce05cb82 100644 --- a/src/commands/Ticket/claim.js +++ b/src/commands/Ticket/claim.js @@ -6,12 +6,12 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; export default { data: new SlashCommandBuilder() .setName("claim") .setDescription("Claims an open ticket, assigning it to you.") - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), + .setDMPermission(false), async execute(interaction, guildConfig, client) { try { @@ -21,6 +21,29 @@ export default { return; } + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Not a Ticket Channel", + "This command can only be used in a valid ticket channel.", + ), + ], + }); + } + + if (!permissionContext.canManageTicket) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Permission Denied", + "You need the `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.", + ), + ], + }); + } + const channel = interaction.channel; const result = await claimTicket(channel, interaction.user); @@ -31,7 +54,7 @@ export default { guildId: interaction.guildId, error: result.error }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Not a Ticket Channel", @@ -41,7 +64,7 @@ export default { }); } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Ticket Claimed!", diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.js index 9f926bbcc1..0bd6d539b0 100644 --- a/src/commands/Ticket/close.js +++ b/src/commands/Ticket/close.js @@ -6,6 +6,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; export default { data: new SlashCommandBuilder() .setName("close") @@ -26,6 +27,29 @@ export default { return; } + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Not a Ticket Channel", + "This command can only be used in a valid ticket channel.", + ), + ], + }); + } + + if (!permissionContext.canCloseTicket) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Permission Denied", + "You need the `Manage Channels` permission, the configured `Ticket Staff Role`, or be the ticket creator to close this ticket.", + ), + ], + }); + } + const channel = interaction.channel; const reason = interaction.options?.getString("reason") || @@ -40,7 +64,7 @@ export default { guildId: interaction.guildId, error: result.error }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Not a Ticket Channel", @@ -50,7 +74,7 @@ export default { }); } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Ticket Closed!", diff --git a/src/commands/Ticket/modules/ticket_limits_check.js b/src/commands/Ticket/modules/ticket_limits_check.js index 712e359133..29e6bd8448 100644 --- a/src/commands/Ticket/modules/ticket_limits_check.js +++ b/src/commands/Ticket/modules/ticket_limits_check.js @@ -1,5 +1,6 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getUserTicketCount } from '../../../services/ticket.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; @@ -21,13 +22,7 @@ export default { const guildConfig = await getGuildConfig(client, guildId); const maxTickets = guildConfig.maxTicketsPerUser || 3; - const ticketChannels = interaction.guild.channels.cache.filter( - channel => channel.name.startsWith('ticket-') && - channel.topic && - channel.topic.includes(user.id) - ); - - const openTicketCount = ticketChannels.size; + const openTicketCount = await getUserTicketCount(guildId, user.id); const embed = infoEmbed( `๐ŸŽซ Ticket Limit Check: ${user.tag}`, @@ -38,7 +33,7 @@ export default { : 'โœ… This user can create more tickets.') ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Ticket limit check completed', { userId: interaction.user.id, diff --git a/src/commands/Ticket/modules/ticket_limits_set.js b/src/commands/Ticket/modules/ticket_limits_set.js index c9c4848c64..337d31d5e7 100644 --- a/src/commands/Ticket/modules/ticket_limits_set.js +++ b/src/commands/Ticket/modules/ticket_limits_set.js @@ -32,7 +32,7 @@ export default { `Users will now be limited to ${maxTickets} open ticket${maxTickets !== 1 ? 's' : ''} at a time.` ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Ticket limit updated', { userId: interaction.user.id, diff --git a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js b/src/commands/Ticket/modules/ticket_limits_toggle_dm.js index 9fa279ea4a..b6d87e11c9 100644 --- a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js +++ b/src/commands/Ticket/modules/ticket_limits_toggle_dm.js @@ -34,7 +34,7 @@ export default { : '๐Ÿ“ญ Users will NOT receive a DM when their ticket is closed.') ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Ticket DM notification setting toggled', { userId: interaction.user.id, diff --git a/src/commands/Ticket/priority.js b/src/commands/Ticket/priority.js index f26c7e6c54..fbbc3ba63f 100644 --- a/src/commands/Ticket/priority.js +++ b/src/commands/Ticket/priority.js @@ -6,6 +6,7 @@ import { logEvent } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getTicketPermissionContext } from '../../utils/ticketPermissions.js'; export default { data: new SlashCommandBuilder() @@ -23,8 +24,7 @@ export default { { name: "๐ŸŸข Low", value: "low" }, { name: "โšช None", value: "none" }, ), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers) + ) .setDMPermission(false), category: "Ticket", @@ -36,6 +36,29 @@ export default { return; } + const permissionContext = await getTicketPermissionContext({ client, interaction }); + if (!permissionContext.ticketData) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Not a Ticket Channel", + "This command can only be used in a valid ticket channel.", + ), + ], + }); + } + + if (!permissionContext.canManageTicket) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + "Permission Denied", + "You need the `Manage Channels` permission or the configured `Ticket Staff Role` to change ticket priority.", + ), + ], + }); + } + const priorityLevel = interaction.options.getString("level"); const result = await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); @@ -46,7 +69,7 @@ export default { guildId: interaction.guildId, error: result.error }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Not a Ticket Channel", @@ -56,7 +79,7 @@ export default { }); } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Priority Updated", diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 99ce37a291..750e3b3edb 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -143,7 +143,7 @@ export default { guildId: interaction.guildId, commandName: 'ticket' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Permission Denied", @@ -165,7 +165,7 @@ export default { case "toggle_dm": return ticketLimitsToggleDM.execute(interaction, config, client); default: - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Invalid Subcommand", @@ -252,7 +252,7 @@ description: panelMessage, successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Ticket Panel Set Up", @@ -342,7 +342,7 @@ description: panelMessage, commandName: 'ticket_setup' }); if (interaction.deferred || interaction.replied) { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Setup Failed", diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.js index 6d0c17ddee..53984f15a5 100644 --- a/src/commands/Tools/baseconvert.js +++ b/src/commands/Tools/baseconvert.js @@ -138,7 +138,7 @@ export default { if (!cleanNumber) { const embed = errorEmbed('โŒ Empty Input', 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`'); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } @@ -163,7 +163,7 @@ export default { ); embed.setColor(getColor('error')); logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } @@ -179,7 +179,7 @@ export default { logger.error('Base conversion parse error:', error); const embed = errorEmbed('โš ๏ธ Conversion Failed', 'The number is too large to process.\n\nTry with a smaller number.'); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } @@ -199,13 +199,13 @@ export default { ); embed.setColor(getColor('success')); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error(`Base conversion error to ${toName}:`, error); const embed = errorEmbed(`โš ๏ธ Failed to Convert to ${toName}`, 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.'); embed.setColor(getColor('error')); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } @@ -232,7 +232,7 @@ export default { ); embed.setColor(getColor('primary')); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } } catch (error) { diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js index f939d500ba..a9400aa5ad 100644 --- a/src/commands/Tools/calculate.js +++ b/src/commands/Tools/calculate.js @@ -74,7 +74,7 @@ try { if ( !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-zโˆšฯ€โˆžยฐ]+$/i.test(expression) ) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "โŒ Invalid Expression", @@ -97,7 +97,7 @@ try { for (const pattern of dangerousPatterns) { if (pattern.test(expression)) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "๐Ÿ”’ Security Alert", @@ -187,7 +187,7 @@ try { `*Use the buttons below to perform operations with the result.*`, ); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [row], }); @@ -366,7 +366,7 @@ const BUTTON_TIMEOUT = 300000; const embed = errorEmbed('Calculation Error', errorMessage); embed.setColor(getColor('error')); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js index 1fa7a8d4ab..64ffbc2f30 100644 --- a/src/commands/Tools/countdown.js +++ b/src/commands/Tools/countdown.js @@ -90,7 +90,7 @@ export default { activeCountdowns.set(countdownId, countdownData); startCountdown(countdownId, countdownData, activeCountdowns); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: "โœ… Countdown started!", flags: MessageFlags.Ephemeral, }); diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.js index 64a77d4563..07c5cd18d2 100644 --- a/src/commands/Tools/generatepassword.js +++ b/src/commands/Tools/generatepassword.js @@ -3,6 +3,7 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('generatepassword') @@ -27,6 +28,19 @@ export default { .setRequired(false)), async execute(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral + }); + + if (!deferSuccess) { + logger.warn('GeneratePassword interaction defer failed', { + userId: interaction.user?.id, + guildId: interaction.guildId, + commandName: 'generatepassword' + }); + return; + } + try { const length = interaction.options.getInteger('length') || 16; const includeUppercase = interaction.options.getBoolean('uppercase') ?? true; @@ -34,9 +48,8 @@ export default { const includeSymbols = interaction.options.getBoolean('symbols') ?? true; if (length < 8 || length > 50) { - await interaction.reply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('โŒ Invalid Length', 'Password must be 8-50 characters. You provided: ' + length)], - flags: MessageFlags.Ephemeral }); return; } @@ -128,9 +141,8 @@ strengthColor = getColor('warning'); `**Contains:** ${hasLower ? 'Lowercase' : ''}${hasUpper ? ', Uppercase' : ''}${hasNumber ? ', Numbers' : ''}${hasSymbol ? ', Symbols' : ''}` ).setColor(strengthColor); - await interaction.reply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], - flags: MessageFlags.Ephemeral }); } catch (error) { await handleInteractionError(interaction, error, { diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.js index c1f8eaaf00..c50ce19320 100644 --- a/src/commands/Tools/hexcolor.js +++ b/src/commands/Tools/hexcolor.js @@ -1,9 +1,9 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('hexcolor') @@ -14,62 +14,63 @@ export default { .setRequired(false)), async execute(interaction) { -try { - let hexColor = interaction.options.getString('color'); - let isRandom = false; - - if (!hexColor) { - isRandom = true; - hexColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'); - } else { - hexColor = hexColor.replace('#', ''); - if (!/^[0-9A-Fa-f]{3,6}$/.test(hexColor)) { - return interaction.reply({ - embeds: [errorEmbed('โŒ Invalid Hex Color', 'Please provide a valid hex code.\n\n**Valid formats:**\nโ€ข `#FF5733` (with hash)\nโ€ข `FF5733` (without hash)\nโ€ข `F57` (3-digit shorthand)\n\n**Invalid:** `#GG5733` (G is not a hex digit)')], - flags: ["Ephemeral"] - }); + await InteractionHelper.safeExecute( + interaction, + async () => { + let hexColor = interaction.options.getString('color'); + let isRandom = false; + + if (!hexColor) { + isRandom = true; + hexColor = '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'); + } else { + hexColor = hexColor.replace('#', ''); + if (!/^[0-9A-Fa-f]{3,6}$/.test(hexColor)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('โŒ Invalid Hex Color', 'Please provide a valid hex code.\n\n**Valid formats:**\nโ€ข `#FF5733` (with hash)\nโ€ข `FF5733` (without hash)\nโ€ข `F57` (3-digit shorthand)\n\n**Invalid:** `#GG5733` (G is not a hex digit)')], + }); + } + + if (hexColor.length === 3) { + hexColor = hexColor.split('').map(c => c + c).join(''); + } + + hexColor = '#' + hexColor.toUpperCase(); } - - if (hexColor.length === 3) { - hexColor = hexColor.split('').map(c => c + c).join(''); + + const r = parseInt(hexColor.slice(1, 3), 16); + const g = parseInt(hexColor.slice(3, 5), 16); + const b = parseInt(hexColor.slice(5, 7), 16); + + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + const textColor = brightness > 128 ? '#000000' : '#FFFFFF'; + + const colorPreviewUrl = `https://dummyimage.com/200x100/${hexColor.replace('#', '')}/${textColor.replace('#', '')}?text=${encodeURIComponent(hexColor)}`; + + const colorName = getColorName(hexColor); + + const embed = successEmbed( + '๐ŸŽจ Color Information', + `**Hex:** \`${hexColor}\`\n` + + `**RGB:** \`rgb(${r}, ${g}, ${b})\`\n` + + `**HSL:** \`${rgbToHsl(r, g, b)}\`\n` + + `**Name:** ${colorName || 'Custom Color'}` + ) + .setColor(hexColor) + .setImage(colorPreviewUrl); + + if (isRandom) { + embed.setFooter({ text: 'โœจ Randomly generated color' }); } - - hexColor = '#' + hexColor.toUpperCase(); - } - - const r = parseInt(hexColor.slice(1, 3), 16); - const g = parseInt(hexColor.slice(3, 5), 16); - const b = parseInt(hexColor.slice(5, 7), 16); - - const brightness = (r * 299 + g * 587 + b * 114) / 1000; - const textColor = brightness > 128 ? '#000000' : '#FFFFFF'; - - const colorPreviewUrl = `https://dummyimage.com/200x100/${hexColor.replace('#', '')}/${textColor.replace('#', '')}?text=${encodeURIComponent(hexColor)}`; - - const colorName = getColorName(hexColor); - - const embed = successEmbed( - '๐ŸŽจ Color Information', - `**Hex:** \`${hexColor}\`\n` + - `**RGB:** \`rgb(${r}, ${g}, ${b})\`\n` + - `**HSL:** \`${rgbToHsl(r, g, b)}\`\n` + - `**Name:** ${colorName || 'Custom Color'}` - ) - .setColor(hexColor) - .setImage(colorPreviewUrl); - - if (isRandom) { - embed.setFooter({ text: 'โœจ Randomly generated color' }); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + }, + 'Failed to generate color information. Please try again.', + { + autoDefer: true, + deferOptions: { flags: MessageFlags.Ephemeral } } - - await interaction.reply({ embeds: [embed] }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'hexcolor' - }); - } + ); }, }; diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.js index 520cdfdd99..080f18b585 100644 --- a/src/commands/Tools/poll.js +++ b/src/commands/Tools/poll.js @@ -107,7 +107,7 @@ export default { await new Promise(resolve => setTimeout(resolve, 500)); } - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: 'โœ… Poll created successfully!', }); } catch (error) { diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js index 7bf43ccbb8..6512bbff85 100644 --- a/src/commands/Tools/shorten.js +++ b/src/commands/Tools/shorten.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -25,7 +25,9 @@ export default { category: "Tools", async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral + }); if (!deferSuccess) { logger.warn(`Shorten interaction defer failed`, { userId: interaction.user.id, @@ -44,7 +46,7 @@ export default { } catch (e) { const embed = errorEmbed("Invalid URL", "Invalid URL format. Include http:// or https://"); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } @@ -52,7 +54,7 @@ export default { if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { const embed = errorEmbed("Invalid Custom URL", "Custom URL can only contain letters, numbers, underscores, and hyphens."); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } @@ -62,7 +64,38 @@ export default { apiUrl += `&shorturl=${encodeURIComponent(custom)}`; } - const response = await fetch(apiUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10000); + + let response; + try { + response = await fetch(apiUrl, { + signal: controller.signal, + headers: { + 'User-Agent': 'TitanBot URL Shortener/1.0' + } + }); + } catch (networkError) { + const message = networkError?.name === 'AbortError' + ? 'The URL shortener timed out. Please try again in a moment.' + : 'Unable to reach the URL shortener service right now. Please try again later.'; + const embed = errorEmbed('Network Error', message); + embed.setColor(getColor('error')); + return InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + const embed = errorEmbed('URL Shortening Failed', `Shortener service returned HTTP ${response.status}. Please try again later.`); + embed.setColor(getColor('error')); + return InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); + } + const shortUrl = await response.text(); try { @@ -71,26 +104,26 @@ export default { if (shortUrl.includes("already exists")) { const embed = errorEmbed("URL Already Taken", "That custom URL is already taken. Try a different one."); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } else if (shortUrl.includes("invalid")) { const embed = errorEmbed("Invalid URL", "Invalid URL. Include http:// or https://"); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } const embed = errorEmbed("URL Shortening Failed", `URL shortening failed: ${shortUrl}`); embed.setColor(getColor('error')); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } const embed = successEmbed("URL Shortened", `Here's your shortened URL: ${shortUrl}`); embed.setColor(getColor('success')); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], }); } catch (error) { diff --git a/src/commands/Tools/time.js b/src/commands/Tools/time.js index 05c94fc8d1..5a59ba4018 100644 --- a/src/commands/Tools/time.js +++ b/src/commands/Tools/time.js @@ -1,8 +1,8 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('time') @@ -13,50 +13,52 @@ export default { .setRequired(false)), async execute(interaction) { - try { - const timezone = interaction.options.getString('timezone') || 'UTC'; - - let timeString; - try { - timeString = new Date().toLocaleString('en-US', { - timeZone: timezone, - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - timeZoneName: 'short' - }); - } catch (error) { - logger.warn(`Invalid timezone requested: ${timezone}`); - const embed = errorEmbed('Invalid Timezone', 'Invalid timezone. Please use a valid timezone identifier (e.g., UTC, America/New_York, Europe/London)'); - embed.setColor(getColor('error')); - await interaction.reply({ - embeds: [embed], - flags: MessageFlags.Ephemeral - }); - return; - } - - const now = new Date(); - const unixTimestamp = Math.floor(now.getTime() / 1000); - - const embed = successEmbed( - '๐Ÿ•’ Current Time', - `**${timezone}:** ${timeString}\n` + - `**Unix Timestamp:** \`${unixTimestamp}\`\n` + - `**ISO String:** \`${now.toISOString()}\`` - ); - - await interaction.reply({ embeds: [embed], flags: MessageFlags.Ephemeral }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'time' - }); - } + await InteractionHelper.safeExecute( + interaction, + async () => { + const timezone = interaction.options.getString('timezone') || 'UTC'; + + let timeString; + try { + timeString = new Date().toLocaleString('en-US', { + timeZone: timezone, + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short' + }); + } catch (error) { + logger.warn(`Invalid timezone requested: ${timezone}`); + const embed = errorEmbed('Invalid Timezone', 'Invalid timezone. Please use a valid timezone identifier (e.g., UTC, America/New_York, Europe/London)'); + embed.setColor(getColor('error')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); + return; + } + + const now = new Date(); + const unixTimestamp = Math.floor(now.getTime() / 1000); + + const embed = successEmbed( + '๐Ÿ•’ Current Time', + `**${timezone}:** ${timeString}\n` + + `**Unix Timestamp:** \`${unixTimestamp}\`\n` + + `**ISO String:** \`${now.toISOString()}\`` + ); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + }, + 'Failed to get current time. Please try again.', + { + autoDefer: true, + deferOptions: { flags: MessageFlags.Ephemeral } + } + ); }, }; diff --git a/src/commands/Tools/unixtime.js b/src/commands/Tools/unixtime.js index 3aa218fe2c..23d32f0812 100644 --- a/src/commands/Tools/unixtime.js +++ b/src/commands/Tools/unixtime.js @@ -1,37 +1,39 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('unixtime') .setDescription('Get the current Unix timestamp'), async execute(interaction) { - try { - const now = new Date(); - const unixTimestamp = Math.floor(now.getTime() / 1000); - - const embed = successEmbed( - 'โฑ๏ธ Current Unix Timestamp', - `**Seconds since Unix Epoch:** \`${unixTimestamp}\`\n` + - `**Milliseconds since Unix Epoch:** \`${now.getTime()}\`\n\n` + - `**Human-readable (UTC):** ${now.toUTCString()}\n` + - `**ISO String:** ${now.toISOString()}` - ); - embed.setColor(getColor('success')); - - await interaction.reply({ - embeds: [embed], - flags: ['Ephemeral'] - }); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'unixtime' - }); - } + await InteractionHelper.safeExecute( + interaction, + async () => { + const now = new Date(); + const unixTimestamp = Math.floor(now.getTime() / 1000); + + const embed = successEmbed( + 'โฑ๏ธ Current Unix Timestamp', + `**Seconds since Unix Epoch:** \`${unixTimestamp}\`\n` + + `**Milliseconds since Unix Epoch:** \`${now.getTime()}\`\n\n` + + `**Human-readable (UTC):** ${now.toUTCString()}\n` + + `**ISO String:** ${now.toISOString()}` + ); + embed.setColor(getColor('success')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + }); + }, + 'Failed to get unix timestamp. Please try again.', + { + autoDefer: true, + deferOptions: { flags: MessageFlags.Ephemeral } + } + ); }, }; diff --git a/src/commands/Utility/avatar.js b/src/commands/Utility/avatar.js index 4c4b39d699..3916b29d7c 100644 --- a/src/commands/Utility/avatar.js +++ b/src/commands/Utility/avatar.js @@ -3,6 +3,7 @@ import { createEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName("avatar") @@ -26,7 +27,7 @@ export default { }) .setImage(avatarUrl); - await interaction.reply({ embeds: [embed] }); + await InteractionHelper.safeReply(interaction, { embeds: [embed] }); logger.info(`Avatar command executed`, { userId: interaction.user.id, targetUserId: user.id, diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.js index 28421ccf0f..6603208efd 100644 --- a/src/commands/Utility/firstmsg.js +++ b/src/commands/Utility/firstmsg.js @@ -37,18 +37,18 @@ export default { channelId: interaction.channelId, guildId: interaction.guildId }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed("First Message", "No messages found in this channel!")], }); } const messageLink = `https://discord.com/channels/${interaction.guildId}/${interaction.channelId}/${firstMessage.id}`; - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "First Message in #" + interaction.channel.name, - `[Jump to first message](${messageLink})` + `Message Link: ${messageLink}` ), ], }); diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js index 108837f1ae..4f062fe279 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.js @@ -55,7 +55,7 @@ export default { guildId: guildId, commandName: 'report' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Setup Required", @@ -74,7 +74,7 @@ export default { reportChannelId: reportChannelId, commandName: 'report' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "Channel Missing", @@ -109,7 +109,7 @@ export default { embeds: [reportEmbed], }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ createEmbed({ title: "โœ… Report Submitted", description: `Your report against **${targetUser.tag}** has been successfully filed and sent to the moderation team. Thank you!`, }), ], diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js index 7e796e753a..b471420dc8 100644 --- a/src/commands/Utility/serverinfo.js +++ b/src/commands/Utility/serverinfo.js @@ -49,7 +49,7 @@ export default { }, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`ServerInfo command executed`, { userId: interaction.user.id, guildId: guild.id, diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js index 1d2c856d4d..8ee6963992 100644 --- a/src/commands/Utility/todo.js +++ b/src/commands/Utility/todo.js @@ -195,7 +195,7 @@ export default { await setInDb(`user_shared_lists_${userId}`, sharedListsArray); } - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Shared List Created", @@ -212,13 +212,13 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Shared list not found.")] }); } if (listData.creatorId !== userId) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Only the list creator can add members.")] }); } @@ -234,7 +234,7 @@ export default { await setInDb(`user_shared_lists_${memberToAdd.id}`, memberListsArray); } - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed("Member Added", `Added ${memberToAdd.username} to the shared list "${listData.name}"` @@ -242,7 +242,7 @@ export default { ] }); } else { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "User is already a member of this list.")] }); } @@ -253,13 +253,13 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Shared list not found.")] }); } if (!listData.members.includes(userId)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "You don't have access to this list.")] }); } @@ -273,7 +273,7 @@ export default { const owner = interaction.guild.members.cache.get(listData.creatorId); const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( `๐Ÿ“‹ **${listData.name}**\n\n` + @@ -323,7 +323,7 @@ export default { `๐Ÿ‘ฅ **Members:** ${memberList}\n\n` + `**Tasks:**\n${taskList}`; - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed(fullListDisplay, `Shared List (ID: \`${listId}\`)`) ], @@ -353,13 +353,13 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Shared list not found.")] }); } if (!listData.members.includes(userId)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "You don't have access to this list.")] }); } @@ -375,7 +375,7 @@ export default { listData.tasks.push(newTask); await setInDb(`shared_todo_${listId}`, listData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed("Task Added", `Added "${taskText}" to the shared list "${listData.name}"`) ] @@ -389,20 +389,20 @@ export default { const listData = await getOrCreateSharedList(listId); if (!listData) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Shared list not found.")] }); } if (!listData.members.includes(userId)) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "You don't have access to this list.")] }); } const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); if (taskIndex === -1) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Task not found.")] }); } @@ -410,7 +410,7 @@ export default { const [removedTask] = listData.tasks.splice(taskIndex, 1); await setInDb(`shared_todo_${listId}`, listData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed("Task Removed", `Removed "${removedTask.text}" from the shared list "${listData.name}".`) ] @@ -444,7 +444,7 @@ export default { userData.tasks.push(newTask); await setInDb(dbKey, userData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( "Task Added", @@ -456,7 +456,7 @@ export default { case 'list': { if (userData.tasks.length === 0) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed("Your to-do list is empty!", "Your To-Do List")], }); } @@ -468,7 +468,7 @@ export default { ) .join('\n'); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed(taskList, "Your To-Do List") ], @@ -480,13 +480,13 @@ export default { const task = userData.tasks.find(t => t.id === taskNumber); if (!task) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Task not found.")], }); } if (task.completed) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Task Already Completed", `Task #${task.id} is already completed.`)], }); } @@ -494,7 +494,7 @@ export default { task.completed = true; await setInDb(`todo_${userId}`, userData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed("Task Completed", `Marked "${task.text}" as complete!`) ], @@ -506,7 +506,7 @@ export default { const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); if (taskIndex === -1) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Task not found.")], }); } @@ -514,7 +514,7 @@ export default { const [removedTask] = userData.tasks.splice(taskIndex, 1); await setInDb(`todo_${userId}`, userData); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed("Task Removed", `Removed "${removedTask.text}" from your to-do list.`) ], @@ -522,7 +522,7 @@ export default { } default: - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Error", "Invalid subcommand.")], }); } diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js index 54e6a98317..40dd58cd58 100644 --- a/src/commands/Utility/userinfo.js +++ b/src/commands/Utility/userinfo.js @@ -64,7 +64,7 @@ export default { }, ); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`UserInfo command executed`, { userId: interaction.user.id, targetUserId: user.id, diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js index 5005747b7b..6a47cde5db 100644 --- a/src/commands/Utility/weather.js +++ b/src/commands/Utility/weather.js @@ -42,7 +42,7 @@ export default { city: city, guildId: interaction.guildId }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "City Not Found", @@ -68,7 +68,7 @@ export default { userId: interaction.user.id, guildId: interaction.guildId }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( "API Error", @@ -109,7 +109,7 @@ export default { text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, }); - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info(`Weather command executed`, { userId: interaction.user.id, city: cityDisplay, diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.js index c6574dd825..6aad2fb872 100644 --- a/src/commands/Utility/wipedata.js +++ b/src/commands/Utility/wipedata.js @@ -4,6 +4,7 @@ import { getConfirmationButtons } from '../../utils/components.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('wipedata') @@ -27,7 +28,7 @@ export default { const confirmButtons = getConfirmationButtons('wipedata'); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [embed], components: [confirmButtons], flags: MessageFlags.Ephemeral diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index cdb59eb63d..ffead35ea9 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -5,6 +5,7 @@ import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js import { withErrorHandling, createError, ErrorTypes } from '../../../utils/errorHandler.js'; import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; import { logger } from '../../../utils/logger.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; @@ -80,7 +81,7 @@ async function handleEnable(interaction, guild, client) { const criteria = interaction.options.getString("criteria"); const accountAgeDays = interaction.options.getInteger("account_age_days") || defaultAccountAgeDays; - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); try { @@ -119,7 +120,7 @@ async function handleEnable(interaction, guild, client) { accountAgeDays: criteria === 'account_age' ? accountAgeDays : null }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "Auto-Verification Enabled", `Automatic verification has been enabled!\n\n**Criteria:** ${criteriaDescription}\n\nUsers who meet these criteria will be automatically verified when they join the server.` @@ -133,12 +134,12 @@ async function handleEnable(interaction, guild, client) { } async function handleDisable(interaction, guild, client) { - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const guildConfig = await getGuildConfig(client, guild.id); if (!guildConfig.verification?.autoVerify?.enabled) { - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [infoEmbed("Already Disabled", "Auto-verification is already disabled.")], }); } @@ -148,7 +149,7 @@ async function handleDisable(interaction, guild, client) { logger.info('Auto-verify disabled', { guildId: guild.id }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "Auto-Verification Disabled", "Automatic verification has been disabled. Users will now need to verify manually." @@ -160,7 +161,7 @@ async function handleStatus(interaction, guild, client) { const guildConfig = await getGuildConfig(client, guild.id); if (!guildConfig.verification?.autoVerify?.enabled) { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [infoEmbed( "Auto-Verification Status", "๐Ÿ”ด **Status:** Disabled\n\nAuto-verification is currently disabled. Users must verify manually.\n\nUse `/autoverify enable` to enable it." @@ -199,7 +200,7 @@ async function handleStatus(interaction, guild, client) { } ); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [statusEmbed], flags: MessageFlags.Ephemeral }); diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index d42d9acf7f..0ccf9b30b6 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -6,6 +6,7 @@ import { handleInteractionError, withErrorHandling, createError, ErrorTypes } fr import { removeVerification, verifyUser } from '../../services/verificationService.js'; import { ContextualMessages, MessageTemplates } from '../../utils/messageTemplates.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() @@ -160,7 +161,7 @@ async function handleSetup(interaction, guild, client) { ); } - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); const verifyEmbed = createEmbed({ title: "โœ… Server Verification", @@ -193,7 +194,7 @@ async function handleSetup(interaction, guild, client) { await setGuildConfig(client, guild.id, guildConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [ContextualMessages.configUpdated( "Verification System", [ @@ -213,13 +214,13 @@ async function handleVerify(interaction, guild, client) { if (!result.success) { if (result.alreadyVerified) { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [MessageTemplates.INFO.ALREADY_CONFIGURED("verified")], flags: MessageFlags.Ephemeral }); } - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [errorEmbed( "Verification Failed", "An error occurred during verification. Please try again or contact an administrator." @@ -228,7 +229,7 @@ async function handleVerify(interaction, guild, client) { }); } - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [MessageTemplates.SUCCESS.ACTION_COMPLETE( `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` )], @@ -247,7 +248,7 @@ async function handleRemove(interaction, guild, client) { if (!result.success) { if (result.notVerified) { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [MessageTemplates.INFO.NO_DATA("verified role")], flags: MessageFlags.Ephemeral }); @@ -260,7 +261,7 @@ async function handleRemove(interaction, guild, client) { moderatorId: interaction.user.id }); - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [MessageTemplates.SUCCESS.OPERATION_COMPLETE( `Verification removed from ${targetUser.tag}.` )] @@ -279,13 +280,13 @@ async function handleDisable(interaction, guild, client) { const guildConfig = await getGuildConfig(client, guild.id); if (!guildConfig.verification?.enabled) { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [MessageTemplates.INFO.ALREADY_CONFIGURED("disabled")], flags: MessageFlags.Ephemeral }); } - await interaction.deferReply(); + await InteractionHelper.safeDefer(interaction); if (guildConfig.verification.channelId && guildConfig.verification.messageId) { const channel = guild.channels.cache.get(guildConfig.verification.channelId); @@ -304,7 +305,7 @@ async function handleDisable(interaction, guild, client) { guildConfig.verification.enabled = false; await setGuildConfig(client, guild.id, guildConfig); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [MessageTemplates.SUCCESS.OPERATION_COMPLETE( "The verification system has been disabled and the verification message has been removed." )] @@ -315,7 +316,7 @@ async function handleStatus(interaction, guild, client) { const guildConfig = await getGuildConfig(client, guild.id); if (!guildConfig.verification?.enabled) { - return await interaction.reply({ + return await InteractionHelper.safeReply(interaction, { embeds: [infoEmbed( "Verification Status", "๐Ÿ”ด **Status:** Disabled\n\nThe verification system is not currently enabled on this server.\n\nUse `/verification setup` to enable it." @@ -360,7 +361,7 @@ async function handleStatus(interaction, guild, client) { } ); - await interaction.reply({ + await InteractionHelper.safeReply(interaction, { embeds: [statusEmbed], flags: MessageFlags.Ephemeral }); diff --git a/src/commands/Voice/activity.js b/src/commands/Voice/activity.js index dcbd216e69..bcc3dc1a27 100644 --- a/src/commands/Voice/activity.js +++ b/src/commands/Voice/activity.js @@ -130,13 +130,7 @@ export default { const activityName = ACTIVITY_NAMES[activity] || activity; if (!member.voice.channel) { - logger.warn('Activity command - user not in voice channel', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - activity: activity - }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not in Voice Channel', 'You need to be in a voice channel to start an activity!')] }); } @@ -157,7 +151,7 @@ export default { activity: activity, missingPermission: 'CreateInstantInvite' }); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Missing Permissions', 'I need the `Create Invite` permission to start an activity!')] }); } @@ -185,7 +179,7 @@ export default { commandName: 'activity' }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: `๐ŸŽฎ ${activityName}`, description: `Click the link below to start **${activityName}** in ${member.voice.channel.name}!\n\n[Join ${activityName} Activity](https://discord.gg/${invite.code})`, @@ -210,7 +204,7 @@ export default { source: 'discord_activity_api' }); } else { - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Failed to Create Activity', 'An error occurred while trying to create the activity. Please try again later.')] }); } diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js index 265c38dcce..5c5835ccd6 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.js @@ -50,7 +50,7 @@ const { options, guild, client } = interaction; if (role.position >= guild.members.me.roles.highest.position) { logger.warn(`[Autorole] User ${interaction.user.tag} tried to add role ${role.name} (${role.id}) higher than bot's highest role in ${guild.name}`); - return interaction.reply({ + return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Role Too High', "I can't assign roles that are higher than my highest role.")], flags: MessageFlags.Ephemeral }); @@ -63,7 +63,7 @@ const { options, guild, client } = interaction; if (existingRoles.includes(role.id)) { logger.info(`[Autorole] User ${interaction.user.tag} tried to add duplicate role ${role.name} (${role.id}) in ${guild.name}`); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Already Added', `The role ${role} is already set to be auto-assigned.`)], flags: MessageFlags.Ephemeral }); @@ -77,13 +77,13 @@ const { options, guild, client } = interaction; }); logger.info(`[Autorole] Added role ${role.name} (${role.id}) to auto-assign in ${guild.name} by ${interaction.user.tag}`); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: `โœ… Added ${role} to auto-assigned roles.`, flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Autorole] Failed to add role for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Add Failed', 'An error occurred while adding the role. Please try again.', @@ -103,7 +103,7 @@ const { options, guild, client } = interaction; if (!existingRoles.includes(role.id)) { logger.info(`[Autorole] User ${interaction.user.tag} tried to remove non-existent role ${role.name} (${role.id}) in ${guild.name}`); - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Not Found', `The role ${role} is not set to be auto-assigned.`)], flags: MessageFlags.Ephemeral }); @@ -116,13 +116,13 @@ const { options, guild, client } = interaction; }); logger.info(`[Autorole] Removed role ${role.name} (${role.id}) from auto-assign in ${guild.name} by ${interaction.user.tag}`); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: `โœ… Removed ${role} from auto-assigned roles.`, flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Autorole] Failed to remove role for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Remove Failed', 'An error occurred while removing the role. Please try again.', @@ -139,7 +139,7 @@ const { options, guild, client } = interaction; const autoRoles = Array.isArray(config.roleIds) ? config.roleIds : []; if (autoRoles.length === 0) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { content: 'โ„น๏ธ No roles are set to be auto-assigned.', flags: MessageFlags.Ephemeral }); @@ -167,7 +167,7 @@ const { options, guild, client } = interaction; } if (validRoles.length === 0) { - return interaction.editReply({ + return InteractionHelper.safeEditReply(interaction, { content: 'โ„น๏ธ No valid auto-assigned roles found. Any invalid roles have been removed.', flags: MessageFlags.Ephemeral }); @@ -179,14 +179,14 @@ const { options, guild, client } = interaction; .setDescription(validRoles.map(r => `โ€ข ${r}`).join('\n')) .setFooter({ text: `Total: ${validRoles.length} role(s)` }); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Autorole] Failed to list roles for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'List Failed', 'An error occurred while listing auto-assigned roles. Please try again.', diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 4d3fd5c696..7a0ef4dbe1 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -55,7 +55,7 @@ export default { if (!message || message.trim().length === 0) { logger.warn(`[Goodbye] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Invalid Input', 'Goodbye message cannot be empty')], flags: MessageFlags.Ephemeral }); @@ -67,7 +67,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Goodbye] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Invalid Image URL', 'Please provide a valid image URL (must start with http:// or https://')], flags: MessageFlags.Ephemeral }); @@ -109,10 +109,10 @@ export default { embed.setImage(image); } - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error(`[Goodbye] Failed to setup goodbye system for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Setup Failed', 'An error occurred while configuring the goodbye system. Please try again.', @@ -133,13 +133,13 @@ export default { }); logger.info(`[Goodbye] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: `โœ… Goodbye messages have been ${newStatus ? 'enabled' : 'disabled'}.`, flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Goodbye] Failed to toggle goodbye system for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Toggle Failed', 'An error occurred while toggling goodbye messages. Please try again.', diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index 0e057e8ff9..b787bbcfc4 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -65,7 +65,7 @@ export default { if (!message || message.trim().length === 0) { logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Invalid Input', 'Welcome message cannot be empty')], flags: MessageFlags.Ephemeral }); @@ -77,7 +77,7 @@ export default { new URL(image); } catch (e) { logger.warn(`[Welcome] Invalid image URL provided by ${interaction.user.tag}: ${image}`); - return await interaction.editReply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Invalid Image URL', 'Please provide a valid image URL (must start with http:// or https://')], flags: MessageFlags.Ephemeral }); @@ -115,10 +115,10 @@ export default { embed.setImage(image); } - await interaction.editReply({ embeds: [embed] }); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error(`[Welcome] Failed to setup welcome system for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Setup Failed', 'An error occurred while configuring the welcome system. Please try again.', @@ -139,13 +139,13 @@ export default { }); logger.info(`[Welcome] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { content: `โœ… Welcome messages have been ${newStatus ? 'enabled' : 'disabled'}.`, flags: MessageFlags.Ephemeral }); } catch (error) { logger.error(`[Welcome] Failed to toggle welcome system for guild ${guild.id}:`, error); - await interaction.editReply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( 'Toggle Failed', 'An error occurred while toggling welcome messages. Please try again.', diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js index 17708a9967..db21d9b6c5 100644 --- a/src/events/channelDelete.js +++ b/src/events/channelDelete.js @@ -3,6 +3,7 @@ import { removeJoinToCreateTrigger, unregisterTemporaryChannel } from '../utils/database.js'; +import { getServerCounters, saveServerCounters } from '../services/counterService.js'; import { logger } from '../utils/logger.js'; export default { @@ -15,6 +16,23 @@ if (channel.type !== 2 && channel.type !== 4) { const guildId = channel.guild.id; try { + // Check if this channel is a counter channel + const counters = await getServerCounters(client, guildId); + const orphanedCounter = counters.find(c => c.channelId === channel.id); + + if (orphanedCounter) { + logger.info(`Counter channel ${channel.name} (${channel.id}) was deleted, removing counter ${orphanedCounter.id} from database`); + + const updatedCounters = counters.filter(c => c.channelId !== channel.id); + const success = await saveServerCounters(client, guildId, updatedCounters); + + if (success) { + logger.info(`Successfully removed orphaned counter ${orphanedCounter.id} (type: ${orphanedCounter.type}) from guild ${guildId}`); + } else { + logger.warn(`Failed to remove orphaned counter ${orphanedCounter.id} from guild ${guildId}`); + } + } + const config = await getJoinToCreateConfig(client, guildId); if (!config.enabled) { diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 430e38185a..197cf7a813 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -5,11 +5,14 @@ import { handleApplicationModal } from '../commands/Community/apply.js'; import { handleApplicationButton, handleApplicationReviewModal } from '../commands/Community/app-admin.js'; import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; import { MessageTemplates } from '../utils/messageTemplates.js'; +import { InteractionHelper } from '../utils/interactionHelper.js'; export default { name: Events.InteractionCreate, async execute(interaction, client) { try { + InteractionHelper.patchInteractionResponses(interaction); + if (interaction.isChatInputCommand()) { try { logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`); diff --git a/src/handlers/giveawayButtonLoader.js b/src/handlers/giveawayButtonLoader.js index dfa476666d..90cadeea44 100644 --- a/src/handlers/giveawayButtonLoader.js +++ b/src/handlers/giveawayButtonLoader.js @@ -1,4 +1,4 @@ -import { giveawayJoinHandler, giveawayEndHandler, giveawayRerollHandler } from './giveawayButtons.js'; +import { giveawayJoinHandler, giveawayEndHandler, giveawayRerollHandler, giveawayViewHandler } from './giveawayButtons.js'; import { logger } from '../utils/logger.js'; @@ -10,6 +10,7 @@ export function loadGiveawayButtons(client) { client.buttons.set(giveawayJoinHandler.customId, giveawayJoinHandler); client.buttons.set(giveawayEndHandler.customId, giveawayEndHandler); client.buttons.set(giveawayRerollHandler.customId, giveawayRerollHandler); + client.buttons.set(giveawayViewHandler.customId, giveawayViewHandler); if (process.env.NODE_ENV !== 'production') { logger.debug('Giveaway button handlers loaded'); diff --git a/src/handlers/giveawayButtons.js b/src/handlers/giveawayButtons.js index 2f9cf961aa..8fe6be5bb4 100644 --- a/src/handlers/giveawayButtons.js +++ b/src/handlers/giveawayButtons.js @@ -382,5 +382,67 @@ export const giveawayRerollHandler = { } }; +export const giveawayViewHandler = { + customId: 'giveaway_view', + async execute(interaction, client) { + try { + if (!interaction.inGuild()) { + throw new TitanBotError( + 'Button used outside guild', + ErrorTypes.VALIDATION, + 'This button can only be used in a server.', + { userId: interaction.user.id } + ); + } + + const guildGiveaways = await getGuildGiveaways(client, interaction.guildId); + const giveaway = guildGiveaways.find(g => g.messageId === interaction.message.id); + + if (!giveaway) { + throw new TitanBotError( + 'Giveaway not found in database', + ErrorTypes.VALIDATION, + 'This giveaway could not be found.', + { messageId: interaction.message.id, guildId: interaction.guildId } + ); + } + + if (!giveaway.ended && !giveaway.isEnded && !isGiveawayEnded(giveaway)) { + return interaction.reply({ + embeds: [ + errorEmbed( + 'Giveaway Still Active', + 'This giveaway has not ended yet, so winners are not available.' + ) + ], + flags: MessageFlags.Ephemeral + }); + } + + const winnerIds = Array.isArray(giveaway.winnerIds) ? giveaway.winnerIds : []; + const winnerMentions = winnerIds.length > 0 + ? winnerIds.map(id => `<@${id}>`).join(', ') + : 'No valid winners were selected for this giveaway.'; + + await interaction.reply({ + embeds: [ + successEmbed( + `Winners for ${giveaway.prize || 'this giveaway'} ๐ŸŽ‰`, + winnerMentions + ) + ], + flags: MessageFlags.Ephemeral + }); + } catch (error) { + logger.error('Error in giveaway view handler:', error); + await handleInteractionError(interaction, error, { + type: 'button', + customId: 'giveaway_view', + handler: 'giveaway' + }); + } + } +}; + diff --git a/src/handlers/shopButtonLoader.js b/src/handlers/shopButtonLoader.js deleted file mode 100644 index ef2a584f3f..0000000000 --- a/src/handlers/shopButtonLoader.js +++ /dev/null @@ -1,18 +0,0 @@ -import { shopPurchaseHandler } from './shopButtons.js'; -import { logger } from '../utils/logger.js'; - - - - - -export default async function loadShopButtons(client) { - try { - client.buttons.set('shop_buy', shopPurchaseHandler); - - logger.info('Shop button handlers loaded'); - } catch (error) { - logger.error('Error loading shop button handlers:', error); - } -} - - diff --git a/src/handlers/shopButtons.js b/src/handlers/shopButtons.js deleted file mode 100644 index c94d6df83c..0000000000 --- a/src/handlers/shopButtons.js +++ /dev/null @@ -1,149 +0,0 @@ -import { shopItems } from '../config/shop/items.js'; -import { errorEmbed, successEmbed } from '../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../utils/economy.js'; -import { getGuildConfig } from '../services/guildConfig.js'; -import { InteractionHelper } from '../utils/interactionHelper.js'; -import { MessageFlags } from 'discord.js'; -import { logger } from '../utils/logger.js'; -import { checkRateLimit } from '../utils/rateLimiter.js'; - - - - - -const shopPurchaseHandler = { - name: 'shop_buy', - async execute(interaction, client, args) { - try { - if (!interaction.inGuild()) { - await interaction.reply({ - embeds: [errorEmbed('Guild Only', 'This action can only be used in a server.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const rateLimitKey = `${interaction.user.id}:shop_buy`; - const allowed = await checkRateLimit(rateLimitKey, 5, 30000); - if (!allowed) { - await interaction.reply({ - embeds: [errorEmbed('Rate Limited', 'You are purchasing too quickly. Please wait a few seconds and try again.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) return; - - const itemId = args?.[0]; - - if (!itemId) { - await interaction.editReply({ - embeds: [errorEmbed('Error', 'Invalid item ID.')], - }); - return; - } - - - const item = shopItems.find(i => i.id === itemId); - - if (!item) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Item', `The item \`${itemId}\` does not exist in the shop.`)], - }); - return; - } - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const quantity = 1; - - const totalCost = item.price * quantity; - - - const userData = await getEconomyData(client, guildId, userId); - - - if (userData.wallet < totalCost) { - await interaction.editReply({ - embeds: [errorEmbed( - 'Insufficient Funds', - `You need **$${totalCost.toLocaleString()}** to purchase **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.\n\nUse the **\`/work\`** command to earn more money.` - )], - }); - return; - } - - - if (item.type === 'role' && itemId === 'premium_role') { - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - if (!PREMIUM_ROLE_ID) { - await interaction.editReply({ - embeds: [errorEmbed('Not Configured', 'The **Premium Shop Role** has not been configured by a server administrator.')], - }); - return; - } - - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - await interaction.editReply({ - embeds: [errorEmbed('Already Own', `You already have the **${item.name}** role.`)], - }); - return; - } - - - try { - await interaction.member.roles.add(PREMIUM_ROLE_ID, `Purchased from shop`); - } catch (error) { - logger.error('Error adding premium role:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'Failed to add the role. Please contact an administrator.')], - }); - return; - } - } - - - if (!userData.inventory) { - userData.inventory = {}; - } - - if (!userData.inventory[itemId]) { - userData.inventory[itemId] = 0; - } - - userData.inventory[itemId] += quantity; - userData.wallet -= totalCost; - - - await setEconomyData(client, guildId, userId, userData); - - - await interaction.editReply({ - embeds: [successEmbed( - `โœ… **Purchase Successful!**\n\n` + - `You bought **${quantity}x ${item.name}** for **$${totalCost.toLocaleString()}**\n\n` + - `**New Balance:** $${userData.wallet.toLocaleString()}`, - '๐Ÿ›’ Shop Purchase' - )], - }); - - logger.info(`User ${interaction.user.tag} (${userId}) purchased ${quantity}x ${item.id} for $${totalCost} in guild ${guildId}`); - - } catch (error) { - logger.error('Shop purchase button handler error:', error); - - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while processing your purchase. Please try again later.')], - }); - } - } -}; - -export { shopPurchaseHandler }; - - - diff --git a/src/handlers/ticketButtonLoader.js b/src/handlers/ticketButtonLoader.js index d1722dfbe2..e256567727 100644 --- a/src/handlers/ticketButtonLoader.js +++ b/src/handlers/ticketButtonLoader.js @@ -1,5 +1,6 @@ import createTicketHandler, { createTicketModalHandler, + closeTicketModalHandler, closeTicketHandler, claimTicketHandler, priorityTicketHandler, @@ -22,6 +23,7 @@ export default async function loadTicketButtons(client) { client.buttons.set('ticket_delete', deleteTicketHandler); client.modals.set('create_ticket_modal', createTicketModalHandler); + client.modals.set('ticket_close_modal', closeTicketModalHandler); logger.info('Ticket button handlers loaded'); } catch (error) { diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index b246f53358..8af40f7b84 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -1,4 +1,4 @@ -import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, AttachmentBuilder, MessageFlags, PermissionFlagsBits } from 'discord.js'; +import { ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, AttachmentBuilder, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../utils/embeds.js'; import { createTicket, closeTicket, claimTicket, updateTicketPriority } from '../services/ticket.js'; import { getGuildConfig } from '../services/guildConfig.js'; @@ -7,6 +7,7 @@ import { logTicketEvent } from '../utils/ticketLogging.js'; import { logger } from '../utils/logger.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; +import { getTicketPermissionContext } from '../utils/ticketPermissions.js'; async function ensureGuildContext(interaction) { if (interaction.inGuild()) { @@ -23,6 +24,35 @@ async function ensureGuildContext(interaction) { return false; } +async function ensureTicketPermission(interaction, client, actionLabel, options = {}) { + const { allowTicketCreator = false } = options; + + const context = await getTicketPermissionContext({ client, interaction }); + + if (!context.ticketData) { + await interaction.reply({ + embeds: [errorEmbed('Not a Ticket Channel', 'This action can only be used in a valid ticket channel.')], + flags: MessageFlags.Ephemeral + }); + return null; + } + + const allowed = allowTicketCreator ? context.canCloseTicket : context.canManageTicket; + if (!allowed) { + const permissionMessage = allowTicketCreator + ? 'You must have **Manage Channels**, the configured **Ticket Staff Role**, or be the **ticket creator**.' + : 'You must have **Manage Channels** or the configured **Ticket Staff Role**.'; + + await interaction.reply({ + embeds: [errorEmbed('Permission Denied', `${permissionMessage}\n\nYou cannot ${actionLabel}.`)], + flags: MessageFlags.Ephemeral + }); + return null; + } + + return context; +} + const createTicketHandler = { name: 'create_ticket', async execute(interaction, client) { @@ -139,17 +169,64 @@ const closeTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; + if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + + const modal = new ModalBuilder() + .setCustomId('ticket_close_modal') + .setTitle('Close Ticket'); + + const reasonInput = new TextInputBuilder() + .setCustomId('reason') + .setLabel('Reason for closing (optional)') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Add an optional reason for closing this ticket...') + .setRequired(false) + .setMaxLength(1000); + + const actionRow = new ActionRowBuilder().addComponents(reasonInput); + modal.addComponents(actionRow); + + await interaction.showModal(modal); + } catch (error) { + logger.error('Error closing ticket:', error); + + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'Could not open ticket close form.')], + flags: MessageFlags.Ephemeral + }); + } else { + await interaction.followUp({ + embeds: [errorEmbed('Error', 'Could not open ticket close form.')], + flags: MessageFlags.Ephemeral + }); + } + } + } +}; + +const closeTicketModalHandler = { + name: 'ticket_close_modal', + async execute(interaction, client) { + try { + if (!(await ensureGuildContext(interaction))) return; + + if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - - const result = await closeTicket(interaction.channel, interaction.user); - + + const providedReason = interaction.fields.getTextInputValue('reason')?.trim(); + const reason = providedReason || 'Closed via ticket button without a specific reason.'; + + const result = await closeTicket(interaction.channel, interaction.user, reason); + if (result.success) { await interaction.editReply({ embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')], flags: MessageFlags.Ephemeral }); - + await logEvent({ client, guildId: interaction.guildId, @@ -157,7 +234,7 @@ const closeTicketHandler = { action: 'Ticket Closed', target: interaction.channel.toString(), executor: interaction.user.toString(), - reason: 'Closed via ticket button' + reason } }); } else { @@ -167,7 +244,7 @@ const closeTicketHandler = { }); } } catch (error) { - logger.error('Error closing ticket:', error); + logger.error('Error submitting close ticket modal:', error); await interaction.editReply({ embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], flags: MessageFlags.Ephemeral @@ -182,13 +259,7 @@ const claimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to claim tickets.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'claim tickets'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -232,13 +303,7 @@ const priorityTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to change ticket priority.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'change ticket priority'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -281,13 +346,7 @@ const transcriptTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to create transcripts.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'create transcripts'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -463,13 +522,7 @@ const unclaimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to unclaim tickets.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'unclaim tickets'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -514,13 +567,7 @@ const reopenTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to reopen tickets.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'reopen tickets'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -529,8 +576,13 @@ const reopenTicketHandler = { const result = await reopenTicket(interaction.channel, interaction.member); if (result.success) { + let reopenMessage = 'You have successfully reopened this ticket!'; + if (result.openCategoryMoveFailed) { + reopenMessage += '\n\nโš ๏ธ The ticket was reopened, but it could not be moved to the configured open ticket category.'; + } + await interaction.editReply({ - embeds: [successEmbed('Ticket Reopened', 'You have successfully reopened this ticket!')], + embeds: [successEmbed('Ticket Reopened', reopenMessage)], flags: MessageFlags.Ephemeral }); @@ -565,13 +617,7 @@ const deleteTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) { - await interaction.reply({ - embeds: [errorEmbed('Permission Denied', 'You need the **Manage Channels** permission to delete tickets.')], - flags: MessageFlags.Ephemeral - }); - return; - } + if (!(await ensureTicketPermission(interaction, client, 'delete tickets'))) return; const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -613,6 +659,7 @@ const deleteTicketHandler = { export default createTicketHandler; export { createTicketModalHandler, + closeTicketModalHandler, closeTicketHandler, claimTicketHandler, priorityTicketHandler, diff --git a/src/services/counterService.js b/src/services/counterService.js index d575f3b93f..a79d8db477 100644 --- a/src/services/counterService.js +++ b/src/services/counterService.js @@ -1,6 +1,81 @@ import { logger } from '../utils/logger.js'; import { logEvent, EVENT_TYPES } from './loggingService.js'; +export const COUNTER_TYPE_CONFIG = { + members: { + label: 'Members + Bots', + baseName: 'Members & Bots', + emoji: '๐Ÿ‘ฅ' + }, + members_only: { + label: 'Members Only', + baseName: 'Members', + emoji: '๐Ÿ‘ค' + }, + bots: { + label: 'Bots Only', + baseName: 'Bots', + emoji: '๐Ÿค–' + } +}; + +function getCounterConfig(type) { + return COUNTER_TYPE_CONFIG[type] || { + label: 'Unknown', + baseName: 'Counter', + emoji: 'โ“' + }; +} + +export function getCounterTypeLabel(type) { + return getCounterConfig(type).label; +} + +export function getCounterBaseName(type) { + return getCounterConfig(type).baseName; +} + +export function getCounterEmoji(type) { + return getCounterConfig(type).emoji; +} + +export async function getGuildCounterStats(guild) { + let memberCollection = guild.members.cache; + + try { + memberCollection = await guild.members.fetch(); + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + logger.debug(`Failed to fetch all guild members for ${guild.id}, using cache only`, error); + } + } + + const botCount = memberCollection.filter((member) => member.user.bot).size; + const totalCount = typeof guild.memberCount === 'number' ? guild.memberCount : memberCollection.size; + const humanCount = Math.max(totalCount - botCount, 0); + + return { + totalCount, + botCount, + humanCount + }; +} + +export async function getCounterCount(guild, type) { + const stats = await getGuildCounterStats(guild); + + switch (type) { + case 'members': + return stats.totalCount; + case 'bots': + return stats.botCount; + case 'members_only': + return stats.humanCount; + default: + return null; + } +} + function isValidCounterShape(counter) { return Boolean( @@ -62,32 +137,13 @@ export async function updateCounter(client, guild, counter) { return false; } - let count; - switch (type) { - case "members": - count = guild.memberCount; - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Member count for guild ${guild.id}: ${count}`); - } - break; - case "bots": - count = guild.members.cache.filter((m) => m.user.bot).size; - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Bot count for guild ${guild.id}: ${count}`); - } - break; - case "members_only": - count = guild.members.cache.filter((m) => !m.user.bot).size; - if (process.env.NODE_ENV !== 'production') { - logger.debug(`Human count for guild ${guild.id}: ${count}`); - } - break; - default: - logger.error('Unknown counter type:', type); - return false; + const count = await getCounterCount(guild, type); + if (count === null) { + logger.error('Unknown counter type:', type); + return false; } - const baseName = channel.name.replace(/\s*[:]\s*\d+$/, '').trim(); + const baseName = getCounterBaseName(type); if (process.env.NODE_ENV !== 'production') { logger.debug(`Base name: "${baseName}", Current name: "${channel.name}"`); } @@ -116,7 +172,7 @@ export async function updateCounter(client, guild, counter) { fields: [ { name: '๐Ÿ“Š Counter Type', - value: type.charAt(0).toUpperCase() + type.slice(1), + value: getCounterTypeLabel(type), inline: true }, { diff --git a/src/services/database.js b/src/services/database.js index 95b121a725..5daf1bdcb6 100644 --- a/src/services/database.js +++ b/src/services/database.js @@ -1,6 +1,7 @@ import 'dotenv/config'; import { pgDb } from '../utils/postgresDatabase.js'; import { logger } from '../utils/logger.js'; +import { pgConfig } from '../config/postgres.js'; let db = null; let useFallback = false; @@ -199,6 +200,45 @@ export async function getTicketData(guildId, channelId) { return await db.get(key); } +export async function getOpenTicketCountForUser(guildId, userId) { + try { + if (!db) { + await initializeServicesDatabase(); + } + + if (db?.pool && typeof db.isAvailable === 'function' && db.isAvailable()) { + const result = await db.pool.query( + `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} + WHERE guild_id = $1 + AND data->>'userId' = $2 + AND COALESCE(data->>'status', 'open') = 'open'`, + [guildId, userId] + ); + + return Number(result.rows?.[0]?.count || 0); + } + + if (typeof db?.list === 'function') { + const ticketKeys = await db.list(`guild:${guildId}:ticket:`); + let count = 0; + + for (const key of ticketKeys) { + const ticket = await getFromDb(key, null); + if (ticket && ticket.userId === userId && ticket.status === 'open') { + count += 1; + } + } + + return count; + } + + return 0; + } catch (error) { + logger.error(`Error counting open tickets for user ${userId} in guild ${guildId}:`, error); + return 0; + } +} + export async function saveTicketData(guildId, channelId, data) { if (!db) { await initializeServicesDatabase(); diff --git a/src/services/giveawayService.js b/src/services/giveawayService.js index 269f5ded8d..8062cb6724 100644 --- a/src/services/giveawayService.js +++ b/src/services/giveawayService.js @@ -197,7 +197,6 @@ export function createGiveawayEmbed(giveaway, status, winners = []) { embed.addFields({ name: 'โฐ Ends', value: ``, inline: false }); } - embed.setFooter({ text: `Giveaway ID: ${giveaway.messageId}` }); embed.setTimestamp(); return embed; diff --git a/src/services/ticket.js b/src/services/ticket.js index c621f31fee..6895b25dfc 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -7,7 +7,7 @@ import { PermissionFlagsBits } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; -import { getTicketData, saveTicketData, deleteTicketData, getFromDb, setInDb } from './database.js'; +import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from './database.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; import { logTicketEvent } from '../utils/ticketLogging.js'; @@ -50,23 +50,7 @@ const TICKET_NUMBER_RANGE = 900; export async function getUserTicketCount(guildId, userId) { try { - const ticketKeys = await getFromDb(`guild:${guildId}:ticket:*`, {}); - const allKeys = Object.keys(ticketKeys); - - let userTicketCount = 0; - - for (const key of allKeys) { - try { - const ticketData = await getFromDb(key, null); - if (ticketData && ticketData.userId === userId && ticketData.status === 'open') { - userTicketCount++; - } - } catch (error) { - continue; - } - } - - return userTicketCount; + return await getOpenTicketCountForUser(guildId, userId); } catch (error) { logger.error('Error counting user tickets:', error); return 0; @@ -75,7 +59,7 @@ export async function getUserTicketCount(guildId, userId) { export async function createTicket(guild, member, categoryId, reason = 'No reason provided', priority = 'none') { try { - const config = await getGuildConfig({}, guild.id); + const config = await getGuildConfig(guild.client, guild.id); const ticketConfig = config.tickets || {}; const maxTicketsPerUser = config.maxTicketsPerUser || 3; @@ -253,7 +237,9 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' } const config = await getGuildConfig(channel.client, channel.guild.id); -const dmOnClose = config.dmOnClose !== false; + const dmOnClose = config.dmOnClose !== false; + const closedCategoryId = config.ticketClosedCategoryId || null; + let movedToClosedCategory = false; ticketData.status = 'closed'; ticketData.closedBy = closer.id; @@ -261,6 +247,22 @@ const dmOnClose = config.dmOnClose !== false; ticketData.closeReason = reason; await saveTicketData(channel.guild.id, channel.id, ticketData); + + if (closedCategoryId && channel.parentId !== closedCategoryId) { + const closedCategory = channel.guild.channels.cache.get(closedCategoryId) + || await channel.guild.channels.fetch(closedCategoryId).catch(() => null); + + if (closedCategory?.type === ChannelType.GuildCategory) { + try { + await channel.setParent(closedCategoryId, { lockPermissions: false }); + movedToClosedCategory = true; + } catch (moveError) { + logger.warn(`Could not move ticket ${channel.id} to closed category ${closedCategoryId}: ${moveError.message}`); + } + } else { + logger.warn(`Configured closed category is invalid for guild ${channel.guild.id}: ${closedCategoryId}`); + } + } if (dmOnClose) { try { @@ -330,15 +332,6 @@ components: [] }); } - try { - const user = await channel.guild.members.fetch(ticketData.userId).catch(() => null); - if (user) { - await channel.permissionOverwrites.delete(user, 'Ticket closed'); - } - } catch (error) { - logger.warn(`Could not remove user ${ticketData.userId} from ticket channel:`, error.message); - } - const closeEmbed = createEmbed({ title: 'Ticket Closed', description: `This ticket has been closed by ${closer}.\n**Reason:** ${reason}${dmOnClose ? '\n\n๐Ÿ“ฉ A DM has been sent to the ticket creator.' : ''}`, @@ -373,7 +366,8 @@ components: [] reason: reason, metadata: { dmSent: dmOnClose, - closedAt: ticketData.closedAt + closedAt: ticketData.closedAt, + movedToClosedCategory } } }); @@ -460,8 +454,17 @@ export async function claimTicket(channel, claimer) { .setStyle(ButtonStyle.Secondary) .setEmoji('๐Ÿ”“') ); - - await channel.send({ embeds: [claimEmbed], components: [unclaimRow] }); + + const claimStatusMessage = messages.find(m => + m.embeds.length > 0 && + (m.embeds[0].title === 'Ticket Claimed' || m.embeds[0].title === 'Ticket Unclaimed') + ); + + if (claimStatusMessage) { + await claimStatusMessage.edit({ embeds: [claimEmbed], components: [unclaimRow] }); + } else { + await channel.send({ embeds: [claimEmbed], components: [unclaimRow] }); + } await logTicketEvent({ client: channel.client, @@ -502,6 +505,11 @@ export async function reopenTicket(channel, reopener) { error: 'This ticket is not currently closed' }; } + + const config = await getGuildConfig(channel.client, channel.guild.id); + const openCategoryId = config.ticketCategoryId || null; + let movedToOpenCategory = false; + let openCategoryMoveFailed = false; ticketData.status = 'open'; ticketData.closedBy = null; @@ -509,6 +517,24 @@ export async function reopenTicket(channel, reopener) { ticketData.closeReason = null; await saveTicketData(channel.guild.id, channel.id, ticketData); + + if (openCategoryId && channel.parentId !== openCategoryId) { + const openCategory = channel.guild.channels.cache.get(openCategoryId) + || await channel.guild.channels.fetch(openCategoryId).catch(() => null); + + if (openCategory?.type === ChannelType.GuildCategory) { + try { + await channel.setParent(openCategoryId, { lockPermissions: false }); + movedToOpenCategory = true; + } catch (moveError) { + openCategoryMoveFailed = true; + logger.warn(`Could not move reopened ticket ${channel.id} to open category ${openCategoryId}: ${moveError.message}`); + } + } else { + openCategoryMoveFailed = true; + logger.warn(`Configured open ticket category is invalid for guild ${channel.guild.id}: ${openCategoryId}`); + } + } try { const user = await channel.guild.members.fetch(ticketData.userId).catch(() => null); @@ -548,7 +574,7 @@ export async function reopenTicket(channel, reopener) { .setCustomId('ticket_claim') .setLabel(ticketData.claimedBy ? 'Claimed' : 'Claim') .setStyle(ticketData.claimedBy ? ButtonStyle.Secondary : ButtonStyle.Primary) - .setEmoji(ticketData.claimedBy ? '๐Ÿ™‹' : '๐Ÿ”‘') + .setEmoji('๐Ÿ™‹') .setDisabled(!!ticketData.claimedBy), new ButtonBuilder() .setCustomId('ticket_transcript') @@ -568,10 +594,26 @@ export async function reopenTicket(channel, reopener) { description: `๐Ÿ”“ ${reopener} has reopened this ticket!`, color: '#2ecc71' }); + + const closeStatusMessage = messages.find(m => + m.embeds.length > 0 && + m.embeds[0].title === 'Ticket Closed' && + m.components.length > 0 && + m.components[0].components.some(c => c.customId === 'ticket_reopen') + ); + + if (closeStatusMessage) { + await closeStatusMessage.edit({ embeds: [reopenEmbed], components: [] }); + } else { + await channel.send({ embeds: [reopenEmbed] }); + } - await channel.send({ embeds: [reopenEmbed] }); - - return { success: true, ticketData }; + return { + success: true, + ticketData, + movedToOpenCategory, + openCategoryMoveFailed + }; } catch (error) { logger.error('Error reopening ticket:', error); @@ -698,12 +740,9 @@ export async function unclaimTicket(channel, unclaimer) { }); } - const recentMessages = await channel.messages.fetch({ limit: 50 }); - const claimMessage = recentMessages.find(m => + const claimMessage = messages.find(m => m.embeds.length > 0 && - m.embeds[0].title === 'Ticket Claimed' && - m.components.length > 0 && - m.components[0].components.some(c => c.customId === 'ticket_unclaim') + (m.embeds[0].title === 'Ticket Claimed' || m.embeds[0].title === 'Ticket Unclaimed') ); if (claimMessage) { @@ -775,15 +814,16 @@ export async function updateTicketPriority(channel, priority, updater) { ticketData.priorityUpdatedAt = new Date().toISOString(); await saveTicketData(channel.guild.id, channel.id, ticketData); - - if (priority !== 'none') { - const priorityEmoji = priorityInfo.emoji; - const currentName = channel.name; - - const cleanName = currentName.replace(/[๐Ÿ”ต๐ŸŸข๐ŸŸก๐Ÿ”ดโšช]/g, '').trim(); - - const newName = `${priorityEmoji} ${cleanName}`; - + + const currentName = channel.name; + const priorityEmojis = [...new Set(Object.values(PRIORITY_MAP).map((item) => item.emoji).filter(Boolean))]; + const escapedPriorityEmojis = priorityEmojis.map((emoji) => emoji.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const cleanName = escapedPriorityEmojis.length > 0 + ? currentName.replace(new RegExp(`(?:${escapedPriorityEmojis.join('|')})`, 'g'), '').trim() + : currentName.trim(); + const newName = priority === 'none' ? cleanName : `${priorityInfo.emoji} ${cleanName}`; + + if (newName && newName !== currentName) { try { await channel.setName(newName); } catch (nameError) { diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js index 6232a60db5..ffc7eef96f 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.js @@ -201,8 +201,9 @@ export async function handleInteractionError(interaction, error, context = {}) { }; if (isUserError) { - - logger.debug(`User Error [${errorType.toUpperCase()}]: ${error.message}`, logData); + if (errorType !== ErrorTypes.RATE_LIMIT) { + logger.debug(`User Error [${errorType.toUpperCase()}]: ${error.message}`, logData); + } } else { // System errors (database, network, etc.) - unexpected failures logger.error(`System Error [${errorType.toUpperCase()}]`, { diff --git a/src/utils/interactionHelper.js b/src/utils/interactionHelper.js index ef0233dd40..037ea548c8 100644 --- a/src/utils/interactionHelper.js +++ b/src/utils/interactionHelper.js @@ -6,10 +6,47 @@ import { handleInteractionError } from './errorHandler.js'; const INTERACTION_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_DEFER_OPTIONS = { flags: MessageFlags.Ephemeral }; +function sanitizeEditReplyOptions(options = {}) { + if (!options || typeof options !== 'object') { + return options; + } + + const { flags, ephemeral, ...rest } = options; + return rest; +} + export class InteractionHelper { + static patchInteractionResponses(interaction) { + if (!interaction || interaction.__titanResponsePatched) { + return; + } + + const originalReply = interaction.reply?.bind(interaction); + const originalEditReply = interaction.editReply?.bind(interaction); + const originalFollowUp = interaction.followUp?.bind(interaction); + + if (!originalReply || !originalEditReply || !originalFollowUp) { + return; + } + + interaction.reply = async (options) => { + if (!interaction.deferred && !interaction.replied) { + return await originalReply(options); + } + + if (interaction.deferred && !interaction.replied) { + return await originalEditReply(sanitizeEditReplyOptions(options)); + } + + return await originalFollowUp(options); + }; + + interaction.__titanResponsePatched = true; + } + @@ -99,7 +136,7 @@ if (error.code === 10062) { return await this.safeReply(interaction, options); } - await interaction.editReply(options); + await interaction.editReply(sanitizeEditReplyOptions(options)); return true; } catch (error) { if (error.code === 10062) { @@ -131,7 +168,17 @@ if (error.code === 40060) { logger.warn(`Interaction ${interaction.id} has expired before reply, ignoring`); return false; } - + + if (interaction.deferred && !interaction.replied) { + await interaction.editReply(sanitizeEditReplyOptions(options)); + return true; + } + + if (interaction.replied) { + await interaction.followUp(options); + return true; + } + await interaction.reply(options); return true; } catch (error) { diff --git a/src/utils/ticketPermissions.js b/src/utils/ticketPermissions.js new file mode 100644 index 0000000000..662243c406 --- /dev/null +++ b/src/utils/ticketPermissions.js @@ -0,0 +1,28 @@ +import { PermissionFlagsBits } from 'discord.js'; +import { getGuildConfig } from '../services/guildConfig.js'; +import { getTicketData } from '../services/database.js'; + +export async function getTicketPermissionContext({ client, interaction }) { + const guildId = interaction.guildId; + const channelId = interaction.channelId; + + const [config, ticketData] = await Promise.all([ + getGuildConfig(client, guildId), + getTicketData(guildId, channelId) + ]); + + const hasManageChannels = interaction.member.permissions.has(PermissionFlagsBits.ManageChannels); + const staffRoleId = config.ticketStaffRoleId || null; + const hasTicketStaffRole = Boolean(staffRoleId && interaction.member.roles?.cache?.has(staffRoleId)); + const isTicketCreator = Boolean(ticketData?.userId && ticketData.userId === interaction.user.id); + + return { + config, + ticketData, + hasManageChannels, + hasTicketStaffRole, + isTicketCreator, + canManageTicket: hasManageChannels || hasTicketStaffRole, + canCloseTicket: hasManageChannels || hasTicketStaffRole || isTicketCreator, + }; +} From c54bacd043880d318dc6847d06056a6b4eacf138 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Tue, 24 Feb 2026 19:28:18 +1100 Subject: [PATCH 03/10] Fixed bugs across all bot modules: quality of life, UI inconsistencies, deferred commands, and permission issues, button implementation --- src/app.js | 2 +- src/commands/Leveling/leaderboard.js | 20 +- src/commands/Leveling/leveladd.js | 17 +- src/commands/Leveling/levelconfig.js | 6 +- src/commands/Leveling/levelremove.js | 17 +- src/commands/Leveling/levelset.js | 17 +- src/commands/Leveling/rank.js | 29 +-- src/commands/Moderation/cases.js | 13 +- src/commands/Moderation/dm.js | 10 + src/commands/Moderation/lock.js | 9 + src/commands/Moderation/massban.js | 10 + src/commands/Moderation/masskick.js | 10 + src/commands/Moderation/purge.js | 9 + src/commands/Moderation/timeout.js | 10 + src/commands/Moderation/unban.js | 10 + src/commands/Moderation/unlock.js | 9 + src/commands/Moderation/untimeout.js | 10 + src/commands/Moderation/warn.js | 10 + src/commands/Moderation/warnings.js | 10 + src/commands/Welcome/autorole.js | 58 ++++-- src/commands/Welcome/goodbye.js | 70 ++++++- src/commands/Welcome/modules/goodbyeConfig.js | 182 ++++++++++++++++++ src/commands/Welcome/modules/welcomeConfig.js | 172 +++++++++++++++++ src/commands/Welcome/welcome.js | 60 +++++- src/events/guildMemberAdd.js | 17 +- src/events/guildMemberRemove.js | 13 +- src/events/messageCreate.js | 27 ++- src/events/messageDelete.js | 46 ++++- src/events/ready.js | 6 + src/interactions/buttons/goodbyeConfig.js | 62 ++++++ src/interactions/buttons/welcomeConfig.js | 62 ++++++ src/interactions/modals/goodbyeConfigModal.js | 122 ++++++++++++ src/interactions/modals/welcomeConfigModal.js | 107 ++++++++++ src/services/leveling.js | 37 +++- src/services/reactionRoleService.js | 87 +++++++++ src/services/xpSystem.js | 15 +- src/utils/database.js | 4 - src/utils/interactionHelper.js | 4 +- 38 files changed, 1280 insertions(+), 99 deletions(-) create mode 100644 src/commands/Welcome/modules/goodbyeConfig.js create mode 100644 src/commands/Welcome/modules/welcomeConfig.js create mode 100644 src/interactions/buttons/goodbyeConfig.js create mode 100644 src/interactions/buttons/welcomeConfig.js create mode 100644 src/interactions/modals/goodbyeConfigModal.js create mode 100644 src/interactions/modals/welcomeConfigModal.js diff --git a/src/app.js b/src/app.js index f5c3989a29..6132ab1559 100644 --- a/src/app.js +++ b/src/app.js @@ -29,7 +29,7 @@ class TitanBot extends Client { GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, - + GatewayIntentBits.MessageContent, GatewayIntentBits.GuildVoiceStates, diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index f58b12dc66..2026419157 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -3,7 +3,7 @@ -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getLeaderboard, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; @@ -29,11 +29,15 @@ export default { const levelingConfig = await getLevelingConfig(client, interaction.guildId); if (!levelingConfig?.enabled) { - throw new TitanBotError( - 'Leveling system is disabled on this server', - ErrorTypes.CONFIGURATION, - 'The leveling system is currently disabled on this server.' - ); + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; } const leaderboard = await getLeaderboard(client, interaction.guildId, 10); @@ -66,8 +70,7 @@ export default { else rankPrefix = `**${index + 1}.**`; return `${rankPrefix} ${username} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; - } catch (error) { - logger.debug(`Error loading member ${user.userId} for leaderboard:`, error); + } catch { return `**${index + 1}.** Error loading user ${user.userId}`; } }) @@ -79,7 +82,6 @@ export default { }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Leaderboard displayed for guild ${interaction.guildId}`); } catch (error) { logger.error('Leaderboard command error:', error); await handleInteractionError(interaction, error, { diff --git a/src/commands/Leveling/leveladd.js b/src/commands/Leveling/leveladd.js index a7e4f05f08..e513b421ee 100644 --- a/src/commands/Leveling/leveladd.js +++ b/src/commands/Leveling/leveladd.js @@ -3,11 +3,11 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { addLevels } from '../../services/leveling.js'; +import { addLevels, getLevelingConfig } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -50,6 +50,19 @@ export default { ); if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } + const targetUser = interaction.options.getUser('user'); const levelsToAdd = interaction.options.getInteger('levels'); diff --git a/src/commands/Leveling/levelconfig.js b/src/commands/Leveling/levelconfig.js index 19f794969e..8e345f5ceb 100644 --- a/src/commands/Leveling/levelconfig.js +++ b/src/commands/Leveling/levelconfig.js @@ -80,7 +80,11 @@ export default { async execute(interaction, config, client) { try { - await InteractionHelper.safeDefer(interaction); + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`LevelConfig defer failed for interaction ${interaction.id}`); + return; + } const hasPermission = await checkUserPermissions( diff --git a/src/commands/Leveling/levelremove.js b/src/commands/Leveling/levelremove.js index d7e4228202..b75434be26 100644 --- a/src/commands/Leveling/levelremove.js +++ b/src/commands/Leveling/levelremove.js @@ -3,11 +3,11 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { removeLevels, getUserLevelData } from '../../services/leveling.js'; +import { removeLevels, getUserLevelData, getLevelingConfig } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -50,6 +50,19 @@ export default { ); if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } + const targetUser = interaction.options.getUser('user'); const levelsToRemove = interaction.options.getInteger('levels'); diff --git a/src/commands/Leveling/levelset.js b/src/commands/Leveling/levelset.js index aa47112f4d..e8d4c7c8f1 100644 --- a/src/commands/Leveling/levelset.js +++ b/src/commands/Leveling/levelset.js @@ -3,11 +3,11 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { checkUserPermissions } from '../../utils/permissionGuard.js'; -import { setUserLevel } from '../../services/leveling.js'; +import { setUserLevel, getLevelingConfig } from '../../services/leveling.js'; import { createEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -50,6 +50,19 @@ export default { ); if (!hasPermission) return; + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } + const targetUser = interaction.options.getUser('user'); const newLevel = interaction.options.getInteger('level'); diff --git a/src/commands/Leveling/rank.js b/src/commands/Leveling/rank.js index e4f34d3ffd..b3310e55d6 100644 --- a/src/commands/Leveling/rank.js +++ b/src/commands/Leveling/rank.js @@ -3,7 +3,7 @@ -import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { SlashCommandBuilder, EmbedBuilder, MessageFlags } from 'discord.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; import { getUserLevelData, getLevelingConfig, getXpForLevel } from '../../services/leveling.js'; @@ -32,6 +32,19 @@ export default { try { await InteractionHelper.safeDefer(interaction); + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + if (!levelingConfig?.enabled) { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setColor('#f1c40f') + .setDescription('The leveling system is currently disabled on this server.') + ], + flags: MessageFlags.Ephemeral + }); + return; + } + const targetUser = interaction.options.getUser('user') || interaction.user; const member = await interaction.guild.members .fetch(targetUser.id) @@ -45,18 +58,7 @@ export default { ); } - const [userData, levelingConfig] = await Promise.all([ - getUserLevelData(client, interaction.guildId, targetUser.id), - getLevelingConfig(client, interaction.guildId) - ]); - - if (!levelingConfig?.enabled) { - throw new TitanBotError( - 'Leveling system is disabled', - ErrorTypes.CONFIGURATION, - 'The leveling system is currently disabled on this server.' - ); - } + const userData = await getUserLevelData(client, interaction.guildId, targetUser.id); const safeUserData = { level: userData?.level ?? 0, @@ -96,7 +98,6 @@ export default { .setTimestamp(); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Rank checked for user ${targetUser.id} in guild ${interaction.guildId}`); } catch (error) { logger.error('Rank command error:', error); await handleInteractionError(interaction, error, { diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js index 40a5ea9ff8..ad16592497 100644 --- a/src/commands/Moderation/cases.js +++ b/src/commands/Moderation/cases.js @@ -2,6 +2,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuild import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getModerationCases } from '../../utils/moderation.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() .setName('cases') @@ -31,6 +32,16 @@ export default { ), async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Cases interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'cases' + }); + return; + } + try { const filterType = interaction.options.getString('filter') || 'all'; const targetUser = interaction.options.getUser('user'); @@ -157,7 +168,7 @@ time: 120000 } catch (error) { logger.error('Error in cases command:', error); - return interaction.reply({ + return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( 'System Error', diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js index f12d956361..b092f672de 100644 --- a/src/commands/Moderation/dm.js +++ b/src/commands/Moderation/dm.js @@ -32,6 +32,16 @@ export default { category: "Moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`DM interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'dm' + }); + return; + } + const targetUser = interaction.options.getUser("user"); const message = interaction.options.getString("message"); const anonymous = interaction.options.getBoolean("anonymous") || false; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js index 96c5f80c37..7705db393f 100644 --- a/src/commands/Moderation/lock.js +++ b/src/commands/Moderation/lock.js @@ -15,6 +15,15 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Lock interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'lock' + }); + return; + } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageChannels)) return await InteractionHelper.safeEditReply(interaction, { diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js index 68aa0d7ced..b3e6f6ef1d 100644 --- a/src/commands/Moderation/massban.js +++ b/src/commands/Moderation/massban.js @@ -32,6 +32,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Massban interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'massban' + }); + return; + } + if (!interaction.member.permissions.has(PermissionFlagsBits.BanMembers)) { return await InteractionHelper.safeEditReply(interaction, { embeds: [ diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js index 3ba10acdca..b6471bdcaf 100644 --- a/src/commands/Moderation/masskick.js +++ b/src/commands/Moderation/masskick.js @@ -24,6 +24,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Masskick interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'masskick' + }); + return; + } + if (!interaction.member.permissions.has(PermissionFlagsBits.KickMembers)) { return await InteractionHelper.safeEditReply(interaction, { embeds: [ diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js index 8eccfed64b..1420ea75fc 100644 --- a/src/commands/Moderation/purge.js +++ b/src/commands/Moderation/purge.js @@ -20,6 +20,15 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Purge interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'purge' + }); + return; + } if (!interaction.member.permissions.has(PermissionFlagsBits.ManageMessages)) return await InteractionHelper.safeEditReply(interaction, { diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js index c617ec4f46..04f205a3f4 100644 --- a/src/commands/Moderation/timeout.js +++ b/src/commands/Moderation/timeout.js @@ -40,6 +40,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Timeout interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'timeout' + }); + return; + } + try { if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { throw new TitanBotError( diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js index 149ffa7956..d321969451 100644 --- a/src/commands/Moderation/unban.js +++ b/src/commands/Moderation/unban.js @@ -24,6 +24,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Unban interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'unban' + }); + return; + } + try { const targetUser = interaction.options.getUser("target"); const reason = interaction.options.getString("reason") || "No reason provided"; diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js index 07280112b7..876dc3e563 100644 --- a/src/commands/Moderation/unlock.js +++ b/src/commands/Moderation/unlock.js @@ -15,6 +15,15 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Unlock interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'unlock' + }); + return; + } if ( !interaction.member.permissions.has( diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js index ecc14e8e3f..a55882953e 100644 --- a/src/commands/Moderation/untimeout.js +++ b/src/commands/Moderation/untimeout.js @@ -19,6 +19,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Untimeout interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'untimeout' + }); + return; + } + try { const targetUser = interaction.options.getUser("target"); const member = interaction.options.getMember("target"); diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js index 6cd7c1ad00..571214ece0 100644 --- a/src/commands/Moderation/warn.js +++ b/src/commands/Moderation/warn.js @@ -25,6 +25,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Warn interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'warn' + }); + return; + } + try { if (!interaction.member.permissions.has(PermissionFlagsBits.ModerateMembers)) { throw new Error("You need the `Moderate Members` permission to issue warnings."); diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js index e22b1b71af..834bdd1da4 100644 --- a/src/commands/Moderation/warnings.js +++ b/src/commands/Moderation/warnings.js @@ -20,6 +20,16 @@ export default { category: "moderation", async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) { + logger.warn(`Warnings interaction defer failed`, { + userId: interaction.user.id, + guildId: interaction.guildId, + commandName: 'warnings' + }); + return; + } + try { const target = interaction.options.getUser("target"); const guildId = interaction.guildId; diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js index 5c5835ccd6..b87055443a 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.js @@ -5,11 +5,18 @@ import { logger } from '../../utils/logger.js'; import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +function createAutoroleInfoEmbed(description) { + return new EmbedBuilder() + .setColor(getColor('primary')) + .setDescription(description) + .setFooter({ text: new Date().toLocaleString() }); +} + export default { data: new SlashCommandBuilder() .setName('autorole') .setDescription('Manage roles that are automatically assigned to new members') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('add') @@ -42,6 +49,13 @@ export default { return; } + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/autorole`.')], + flags: MessageFlags.Ephemeral + }); + } + const { options, guild, client } = interaction; const subcommand = options.getSubcommand(); @@ -59,9 +73,10 @@ const { options, guild, client } = interaction; try { const config = await getWelcomeConfig(client, guild.id); const existingRoles = config.roleIds || []; + const currentRoleId = existingRoles[0] || null; - if (existingRoles.includes(role.id)) { + if (currentRoleId === role.id) { logger.info(`[Autorole] User ${interaction.user.tag} tried to add duplicate role ${role.name} (${role.id}) in ${guild.name}`); return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('Already Added', `The role ${role} is already set to be auto-assigned.`)], @@ -69,16 +84,17 @@ const { options, guild, client } = interaction; }); } - - const updatedRoles = [...new Set([...existingRoles, role.id])]; - await updateWelcomeConfig(client, guild.id, { - roleIds: updatedRoles + roleIds: [role.id] }); - logger.info(`[Autorole] Added role ${role.name} (${role.id}) to auto-assign in ${guild.name} by ${interaction.user.tag}`); + logger.info(`[Autorole] Set single auto-role to ${role.name} (${role.id}) in ${guild.name} by ${interaction.user.tag}`); await InteractionHelper.safeEditReply(interaction, { - content: `โœ… Added ${role} to auto-assigned roles.`, + embeds: [createAutoroleInfoEmbed( + currentRoleId + ? `โœ… Auto-role updated to ${role}. Only one auto-role is allowed.` + : `โœ… Auto-role set to ${role}.` + )], flags: MessageFlags.Ephemeral }); } catch (error) { @@ -117,7 +133,7 @@ const { options, guild, client } = interaction; logger.info(`[Autorole] Removed role ${role.name} (${role.id}) from auto-assign in ${guild.name} by ${interaction.user.tag}`); await InteractionHelper.safeEditReply(interaction, { - content: `โœ… Removed ${role} from auto-assigned roles.`, + embeds: [createAutoroleInfoEmbed(`โœ… Removed ${role} from auto-assigned roles.`)], flags: MessageFlags.Ephemeral }); } catch (error) { @@ -138,9 +154,17 @@ const { options, guild, client } = interaction; const config = await getWelcomeConfig(client, guild.id); const autoRoles = Array.isArray(config.roleIds) ? config.roleIds : []; - if (autoRoles.length === 0) { + const singleRoleIds = autoRoles.length > 1 ? [autoRoles[0]] : autoRoles; + if (singleRoleIds.length !== autoRoles.length) { + await updateWelcomeConfig(client, guild.id, { + roleIds: singleRoleIds + }); + logger.info(`[Autorole] Trimmed auto-role list to one role in ${interaction.guild.name}`); + } + + if (singleRoleIds.length === 0) { return InteractionHelper.safeEditReply(interaction, { - content: 'โ„น๏ธ No roles are set to be auto-assigned.', + embeds: [createAutoroleInfoEmbed('โ„น๏ธ No role is set to be auto-assigned.')], flags: MessageFlags.Ephemeral }); } @@ -149,7 +173,7 @@ const { options, guild, client } = interaction; const validRoles = []; const invalidRoleIds = []; - for (const roleId of autoRoles) { + for (const roleId of singleRoleIds) { const role = roles.get(roleId); if (role) { validRoles.push(role); @@ -160,7 +184,7 @@ const { options, guild, client } = interaction; if (invalidRoleIds.length > 0) { logger.info(`[Autorole] Cleaning up ${invalidRoleIds.length} invalid role(s) from guild ${interaction.guild.name}`); - const updatedRoles = autoRoles.filter(id => !invalidRoleIds.includes(id)); + const updatedRoles = singleRoleIds.filter(id => !invalidRoleIds.includes(id)); await updateWelcomeConfig(client, guild.id, { roleIds: updatedRoles }); @@ -168,16 +192,16 @@ const { options, guild, client } = interaction; if (validRoles.length === 0) { return InteractionHelper.safeEditReply(interaction, { - content: 'โ„น๏ธ No valid auto-assigned roles found. Any invalid roles have been removed.', + embeds: [createAutoroleInfoEmbed('โ„น๏ธ No valid auto-role found. Any invalid role has been removed.')], flags: MessageFlags.Ephemeral }); } const embed = new EmbedBuilder() .setColor(getColor('info')) - .setTitle('Auto-Assigned Roles') - .setDescription(validRoles.map(r => `โ€ข ${r}`).join('\n')) - .setFooter({ text: `Total: ${validRoles.length} role(s)` }); + .setTitle('Auto-Assigned Role') + .setDescription(`${validRoles[0]}`) + .setFooter({ text: 'Only one auto-role can be configured.' }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed], diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 7a0ef4dbe1..579d1762f9 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -1,16 +1,17 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { buildGoodbyeConfigPayload, hasGoodbyeSetup } from './modules/goodbyeConfig.js'; export default { data: new SlashCommandBuilder() .setName('goodbye') .setDescription('Configure the goodbye message system') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('setup') @@ -27,11 +28,19 @@ export default { .addStringOption(option => option.setName('image') .setDescription('URL of the image to include in the goodbye message') + .setRequired(false)) + .addBooleanOption(option => + option.setName('ping') + .setDescription('Whether to ping the user in the goodbye message') .setRequired(false))) .addSubcommand(subcommand => subcommand .setName('toggle') - .setDescription('Enable or disable goodbye messages')), + .setDescription('Enable or disable goodbye messages')) + .addSubcommand(subcommand => + subcommand + .setName('config') + .setDescription('Customize your existing goodbye setup with buttons')), async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -45,12 +54,33 @@ export default { } const { options, guild, client } = interaction; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/goodbye`.')], + flags: MessageFlags.Ephemeral + }); + } + const subcommand = options.getSubcommand(); if (subcommand === 'setup') { const channel = options.getChannel('channel'); const message = options.getString('message'); const image = options.getString('image'); + const ping = options.getBoolean('ping') ?? false; + + const existingConfig = await getWelcomeConfig(client, guild.id); + if (hasGoodbyeSetup(existingConfig)) { + logger.info(`[Goodbye] Setup blocked because config already exists in channel ${existingConfig.goodbyeChannelId} for guild ${guild.id}`); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'Goodbye Setup Already Exists', + `Goodbye is already configured for <#${existingConfig.goodbyeChannelId}>. Use **/goodbye config** to customize channel, message, ping, or image.` + )], + flags: MessageFlags.Ephemeral + }); + } if (!message || message.trim().length === 0) { @@ -79,6 +109,7 @@ export default { goodbyeEnabled: true, goodbyeChannelId: channel.id, leaveMessage: message, + goodbyePing: ping, leaveEmbed: { title: "Goodbye {user.tag}", description: message, @@ -101,6 +132,7 @@ export default { .setDescription(`Goodbye messages will now be sent to ${channel}`) .addFields( { name: 'Message Preview', value: previewMessage }, + { name: 'Ping User', value: ping ? 'โœ… Yes' : 'โŒ No' }, { name: 'Status', value: 'โœ… Enabled' } ) .setFooter({ text: 'Tip: Use /goodbye toggle to enable/disable goodbye messages' }); @@ -121,7 +153,37 @@ export default { flags: MessageFlags.Ephemeral }); } - } + } + else if (subcommand === 'config') { + try { + const currentConfig = await getWelcomeConfig(client, guild.id); + + if (!hasGoodbyeSetup(currentConfig)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'No Goodbye Setup Found', + 'Set up goodbye first using **/goodbye setup**.' + )], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeEditReply( + interaction, + buildGoodbyeConfigPayload(guild, currentConfig, 'Use the buttons below to customize your goodbye setup.') + ); + } catch (error) { + logger.error(`[Goodbye] Failed to load goodbye config panel for guild ${guild.id}:`, error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'Config Panel Failed', + 'An error occurred while loading the goodbye config panel. Please try again.', + { showDetails: true } + )], + flags: MessageFlags.Ephemeral + }); + } + } else if (subcommand === 'toggle') { try { diff --git a/src/commands/Welcome/modules/goodbyeConfig.js b/src/commands/Welcome/modules/goodbyeConfig.js new file mode 100644 index 0000000000..79875b29e2 --- /dev/null +++ b/src/commands/Welcome/modules/goodbyeConfig.js @@ -0,0 +1,182 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle +} from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { formatWelcomeMessage } from '../../../utils/welcome.js'; + +export const GOODBYE_CONFIG_BUTTON_ID = 'goodbye_config'; +export const GOODBYE_CONFIG_MODAL_ID = 'goodbye_config_modal'; +export const GOODBYE_CONFIG_ACTIONS = ['channel', 'message', 'ping', 'image']; + +export function hasGoodbyeSetup(config) { + return Boolean(config?.goodbyeChannelId); +} + +export function parseChannelInput(rawInput) { + const value = String(rawInput || '').trim(); + if (!value) return null; + + const mentionMatch = value.match(/^<#(\d+)>$/); + if (mentionMatch) return mentionMatch[1]; + + if (/^\d{17,20}$/.test(value)) { + return value; + } + + return null; +} + +export function isValidImageUrl(rawInput) { + const value = String(rawInput || '').trim(); + if (!value) return true; + + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol); + } catch { + return false; + } +} + +function truncatePreview(value, maxLength = 512) { + if (!value) return 'Not set'; + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; +} + +export function buildGoodbyeConfigPayload(guild, config, notice = null) { + const previewMessage = formatWelcomeMessage(config.leaveMessage || '{user.tag} has left the server.', { + user: guild?.members?.me?.user || guild?.client?.user, + guild + }); + + const imageValue = + typeof config?.leaveEmbed?.image === 'string' + ? config.leaveEmbed.image + : config?.leaveEmbed?.image?.url || null; + + const embed = new EmbedBuilder() + .setColor(getColor('primary')) + .setTitle('โš™๏ธ Goodbye Configuration') + .setDescription(notice || 'Customize your goodbye setup using the buttons below.') + .addFields( + { + name: 'Channel', + value: config.goodbyeChannelId ? `<#${config.goodbyeChannelId}>` : 'Not set', + inline: true + }, + { + name: 'Ping User', + value: config.goodbyePing ? 'โœ… Yes' : 'โŒ No', + inline: true + }, + { + name: 'Status', + value: config.goodbyeEnabled ? 'โœ… Enabled' : 'โŒ Disabled', + inline: true + }, + { + name: 'Message Preview', + value: truncatePreview(previewMessage, 1024) + }, + { + name: 'Image URL', + value: imageValue ? truncatePreview(imageValue, 1024) : 'Not set' + } + ) + .setFooter({ text: 'Buttons update your saved goodbye setup immediately.' }); + + if (imageValue) { + embed.setImage(imageValue); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:channel`) + .setLabel('Channel') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:message`) + .setLabel('Message') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:ping`) + .setLabel('Toggle Ping') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`${GOODBYE_CONFIG_BUTTON_ID}:image`) + .setLabel('Image') + .setStyle(ButtonStyle.Secondary) + ); + + return { + embeds: [embed], + components: [row] + }; +} + +export function buildGoodbyeConfigModal(action, config) { + if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { + return null; + } + + const modal = new ModalBuilder().setCustomId(`${GOODBYE_CONFIG_MODAL_ID}:${action}`); + + if (action === 'channel') { + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Channel mention or ID') + .setPlaceholder('Example: #goodbye or 123456789012345678') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setValue(config.goodbyeChannelId ? `<#${config.goodbyeChannelId}>` : ''); + + modal + .setTitle('Set Goodbye Channel') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + if (action === 'message') { + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Goodbye message') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(2000) + .setValue(config.leaveMessage || '{user.tag} has left the server.'); + + modal + .setTitle('Set Goodbye Message') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + if (action === 'image') { + const imageValue = + typeof config?.leaveEmbed?.image === 'string' + ? config.leaveEmbed.image + : config?.leaveEmbed?.image?.url || ''; + + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/goodbye.png') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(imageValue); + + modal + .setTitle('Set Goodbye Image') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + return null; +} \ No newline at end of file diff --git a/src/commands/Welcome/modules/welcomeConfig.js b/src/commands/Welcome/modules/welcomeConfig.js new file mode 100644 index 0000000000..ca6774c761 --- /dev/null +++ b/src/commands/Welcome/modules/welcomeConfig.js @@ -0,0 +1,172 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle +} from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { formatWelcomeMessage } from '../../../utils/welcome.js'; + +export const WELCOME_CONFIG_BUTTON_ID = 'welcome_config'; +export const WELCOME_CONFIG_MODAL_ID = 'welcome_config_modal'; +export const WELCOME_CONFIG_ACTIONS = ['channel', 'message', 'ping', 'image']; + +export function hasWelcomeSetup(config) { + return Boolean(config?.channelId); +} + +export function parseChannelInput(rawInput) { + const value = String(rawInput || '').trim(); + if (!value) return null; + + const mentionMatch = value.match(/^<#(\d+)>$/); + if (mentionMatch) return mentionMatch[1]; + + if (/^\d{17,20}$/.test(value)) { + return value; + } + + return null; +} + +export function isValidImageUrl(rawInput) { + const value = String(rawInput || '').trim(); + if (!value) return true; + + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol); + } catch { + return false; + } +} + +function truncatePreview(value, maxLength = 512) { + if (!value) return 'Not set'; + if (value.length <= maxLength) return value; + return `${value.slice(0, maxLength - 3)}...`; +} + +export function buildWelcomeConfigPayload(guild, config, notice = null) { + const previewMessage = formatWelcomeMessage(config.welcomeMessage || 'Welcome {user} to {server}!', { + user: guild?.members?.me?.user || guild?.client?.user, + guild + }); + + const embed = new EmbedBuilder() + .setColor(getColor('primary')) + .setTitle('โš™๏ธ Welcome Configuration') + .setDescription(notice || 'Customize your welcome setup using the buttons below.') + .addFields( + { + name: 'Channel', + value: config.channelId ? `<#${config.channelId}>` : 'Not set', + inline: true + }, + { + name: 'Ping User', + value: config.welcomePing ? 'โœ… Yes' : 'โŒ No', + inline: true + }, + { + name: 'Status', + value: config.enabled ? 'โœ… Enabled' : 'โŒ Disabled', + inline: true + }, + { + name: 'Message Preview', + value: truncatePreview(previewMessage, 1024) + }, + { + name: 'Image URL', + value: config.welcomeImage ? truncatePreview(config.welcomeImage, 1024) : 'Not set' + } + ) + .setFooter({ text: 'Buttons update your saved welcome setup immediately.' }); + + if (config.welcomeImage) { + embed.setImage(config.welcomeImage); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:channel`) + .setLabel('Channel') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:message`) + .setLabel('Message') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:ping`) + .setLabel('Toggle Ping') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`${WELCOME_CONFIG_BUTTON_ID}:image`) + .setLabel('Image') + .setStyle(ButtonStyle.Secondary) + ); + + return { + embeds: [embed], + components: [row] + }; +} + +export function buildWelcomeConfigModal(action, config) { + if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { + return null; + } + + const modal = new ModalBuilder().setCustomId(`${WELCOME_CONFIG_MODAL_ID}:${action}`); + + if (action === 'channel') { + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Channel mention or ID') + .setPlaceholder('Example: #welcome or 123456789012345678') + .setStyle(TextInputStyle.Short) + .setRequired(true) + .setValue(config.channelId ? `<#${config.channelId}>` : ''); + + modal + .setTitle('Set Welcome Channel') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + if (action === 'message') { + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Welcome message') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true) + .setMaxLength(2000) + .setValue(config.welcomeMessage || 'Welcome {user} to {server}!'); + + modal + .setTitle('Set Welcome Message') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + if (action === 'image') { + const input = new TextInputBuilder() + .setCustomId('value') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/welcome.png') + .setStyle(TextInputStyle.Short) + .setRequired(false) + .setValue(config.welcomeImage || ''); + + modal + .setTitle('Set Welcome Image') + .addComponents(new ActionRowBuilder().addComponents(input)); + return modal; + } + + return null; +} \ No newline at end of file diff --git a/src/commands/Welcome/welcome.js b/src/commands/Welcome/welcome.js index b787bbcfc4..9c919e647d 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -1,16 +1,17 @@ import { getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { formatWelcomeMessage } from '../../utils/welcome.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { buildWelcomeConfigPayload, hasWelcomeSetup } from './modules/welcomeConfig.js'; export default { data: new SlashCommandBuilder() .setName('welcome') .setDescription('Configure the welcome system') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand .setName('setup') @@ -35,7 +36,11 @@ export default { .addSubcommand(subcommand => subcommand .setName('toggle') - .setDescription('Enable or disable welcome messages')), + .setDescription('Enable or disable welcome messages')) + .addSubcommand(subcommand => + subcommand + .setName('config') + .setDescription('Customize your existing welcome setup with buttons')), async execute(interaction) { try { @@ -54,6 +59,14 @@ export default { } const { options, guild, client } = interaction; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Missing Permissions', 'You need the **Manage Server** permission to use `/welcome`.')], + flags: MessageFlags.Ephemeral + }); + } + const subcommand = options.getSubcommand(); if (subcommand === 'setup') { @@ -62,6 +75,17 @@ export default { const image = options.getString('image'); const ping = options.getBoolean('ping') ?? false; + const existingConfig = await getWelcomeConfig(client, guild.id); + if (hasWelcomeSetup(existingConfig)) { + logger.info(`[Welcome] Setup blocked because config already exists in channel ${existingConfig.channelId} for guild ${guild.id}`); + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'Welcome Setup Already Exists', + `Welcome is already configured for <#${existingConfig.channelId}>. Use **/welcome config** to customize channel, message, ping, or image.` + )], + flags: MessageFlags.Ephemeral + }); + } if (!message || message.trim().length === 0) { logger.warn(`[Welcome] Empty message provided by ${interaction.user.tag} in ${guild.name}`); @@ -128,6 +152,36 @@ export default { }); } } + else if (subcommand === 'config') { + try { + const currentConfig = await getWelcomeConfig(client, guild.id); + + if (!hasWelcomeSetup(currentConfig)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'No Welcome Setup Found', + 'Set up welcome first using **/welcome setup**.' + )], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeEditReply( + interaction, + buildWelcomeConfigPayload(guild, currentConfig, 'Use the buttons below to customize your welcome setup.') + ); + } catch (error) { + logger.error(`[Welcome] Failed to load welcome config panel for guild ${guild.id}:`, error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'Config Panel Failed', + 'An error occurred while loading the welcome config panel. Please try again.', + { showDetails: true } + )], + flags: MessageFlags.Ephemeral + }); + } + } else if (subcommand === 'toggle') { try { diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index b8d66b71e6..ad7a5ab2b8 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -81,25 +81,22 @@ export default { if (welcomeConfig?.roleIds && welcomeConfig.roleIds.length > 0) { const delay = welcomeConfig.autoRoleDelay || 0; + const singleRoleId = welcomeConfig.roleIds[0]; if (delay > 0) { const timeout = setTimeout(async () => { - for (const roleId of welcomeConfig.roleIds) { - const role = guild.roles.cache.get(roleId); - if (role) { - await assignRoleSafely(member, role); - } + const role = guild.roles.cache.get(singleRoleId); + if (role) { + await assignRoleSafely(member, role); } }, delay * 1000); if (typeof timeout.unref === 'function') { timeout.unref(); } } else { - for (const roleId of welcomeConfig.roleIds) { - const role = guild.roles.cache.get(roleId); - if (role) { - await assignRoleSafely(member, role); - } + const role = guild.roles.cache.get(singleRoleId); + if (role) { + await assignRoleSafely(member, role); } } } diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index d90fb04c30..12f768b206 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -44,7 +44,10 @@ export default { const canEmbed = permissions.has(PermissionFlagsBits.EmbedLinks); if (!canEmbed) { - await channel.send({ content: goodbyeMessage }); + await channel.send({ + content: welcomeConfig?.goodbyePing ? `<@${user.id}> ${goodbyeMessage}` : goodbyeMessage, + allowedMentions: welcomeConfig?.goodbyePing ? { users: [user.id] } : { parse: [] } + }); } else { const embed = new EmbedBuilder() .setTitle(embedTitle) @@ -63,8 +66,12 @@ export default { } else if (welcomeConfig.leaveEmbed?.image?.url) { embed.setImage(welcomeConfig.leaveEmbed.image.url); } - - await channel.send({ embeds: [embed] }); + + await channel.send({ + content: welcomeConfig?.goodbyePing ? `<@${user.id}>` : undefined, + allowedMentions: welcomeConfig?.goodbyePing ? { users: [user.id] } : { parse: [] }, + embeds: [embed] + }); } } } diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js index 8e820d76f8..b7f9841dc4 100644 --- a/src/events/messageCreate.js +++ b/src/events/messageCreate.js @@ -37,19 +37,24 @@ async function handleLeveling(message, client) { try { const rateLimitKey = `xp-event:${message.guild.id}:${message.author.id}`; const canProcess = await checkRateLimit(rateLimitKey, MESSAGE_XP_RATE_LIMIT_ATTEMPTS, MESSAGE_XP_RATE_LIMIT_WINDOW_MS); - if (!canProcess) return; + if (!canProcess) { + return; + } const levelingConfig = await getLevelingConfig(client, message.guild.id); - if (!levelingConfig?.enabled) return; + if (!levelingConfig?.enabled) { + return; + } - if (levelingConfig.ignoredChannels?.includes(message.channel.id)) return; + if (levelingConfig.ignoredChannels?.includes(message.channel.id)) { + return; + } if (levelingConfig.ignoredRoles?.length > 0) { - const member = await message.guild.members.fetch(message.author.id).catch((error) => { - logger.debug(`Unable to fetch member ${message.author.id} for leveling role checks in guild ${message.guild.id}:`, error); + const member = await message.guild.members.fetch(message.author.id).catch(() => { return null; }); if (member && member.roles.cache.some(role => levelingConfig.ignoredRoles.includes(role.id))) { @@ -58,10 +63,14 @@ async function handleLeveling(message, client) { } - if (levelingConfig.blacklistedUsers?.includes(message.author.id)) return; + if (levelingConfig.blacklistedUsers?.includes(message.author.id)) { + return; + } - if (!message.content || message.content.trim().length === 0) return; + if (!message.content || message.content.trim().length === 0) { + return; + } const userData = await getUserLevelData(client, message.guild.id, message.author.id); @@ -71,7 +80,9 @@ async function handleLeveling(message, client) { const timeSinceLastMessage = now - (userData.lastMessage || 0); - if (timeSinceLastMessage < cooldownTime * 1000) return; + if (timeSinceLastMessage < cooldownTime * 1000) { + return; + } const minXP = levelingConfig.xpRange?.min || levelingConfig.xpPerMessage?.min || 15; diff --git a/src/events/messageDelete.js b/src/events/messageDelete.js index 2af67bc98b..cc737ba06c 100644 --- a/src/events/messageDelete.js +++ b/src/events/messageDelete.js @@ -1,6 +1,7 @@ import { Events } from 'discord.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { logger } from '../utils/logger.js'; +import { getReactionRoleMessage, deleteReactionRoleMessage } from '../services/reactionRoleService.js'; const MAX_LOGGED_MESSAGE_CONTENT_LENGTH = 1024; @@ -10,7 +11,50 @@ export default { async execute(message) { try { - if (!message.guild || message.author?.bot) return; + if (!message.guild) return; + + try { + const reactionRoleData = await getReactionRoleMessage(message.client, message.guild.id, message.id); + if (reactionRoleData) { + await deleteReactionRoleMessage(message.client, message.guild.id, message.id); + logger.info(`Cleaned up reaction role database entry for manually deleted message ${message.id} in guild ${message.guild.id}`); + + try { + await logEvent({ + client: message.client, + guildId: message.guild.id, + eventType: EVENT_TYPES.REACTION_ROLE_DELETE, + data: { + description: `Reaction role message was deleted manually and removed from database.`, + channelId: message.channel?.id, + fields: [ + { + name: '๐Ÿ—‘๏ธ Message ID', + value: message.id, + inline: true + }, + { + name: '๐Ÿ“ Channel', + value: message.channel ? `${message.channel.toString()} (${message.channel.id})` : 'Unknown', + inline: true + }, + { + name: '๐Ÿงน Cleanup', + value: 'Database entry removed automatically', + inline: false + } + ] + } + }); + } catch (logCleanupError) { + logger.warn('Failed to log reaction role cleanup after manual message deletion:', logCleanupError); + } + } + } catch (reactionRoleCleanupError) { + logger.warn(`Failed to clean up reaction role data for deleted message ${message.id}:`, reactionRoleCleanupError); + } + + if (message.author?.bot) return; const fields = []; diff --git a/src/events/ready.js b/src/events/ready.js index 300817d779..8f3db50966 100644 --- a/src/events/ready.js +++ b/src/events/ready.js @@ -1,6 +1,7 @@ import { Events } from "discord.js"; import { logger, startupLog } from "../utils/logger.js"; import config from "../config/application.js"; +import { reconcileReactionRoleMessages } from "../services/reactionRoleService.js"; export default { name: Events.ClientReady, @@ -13,6 +14,11 @@ export default { startupLog(`Ready! Logged in as ${client.user.tag}`); startupLog(`Serving ${client.guilds.cache.size} guild(s)`); startupLog(`Loaded ${client.commands.size} commands`); + + const reconciliationSummary = await reconcileReactionRoleMessages(client); + startupLog( + `Reaction role reconciliation: scanned ${reconciliationSummary.scannedMessages}, removed ${reconciliationSummary.removedMessages}, errors ${reconciliationSummary.errors}` + ); } catch (error) { logger.error("Error in ready event:", error); } diff --git a/src/interactions/buttons/goodbyeConfig.js b/src/interactions/buttons/goodbyeConfig.js new file mode 100644 index 0000000000..2a082b166a --- /dev/null +++ b/src/interactions/buttons/goodbyeConfig.js @@ -0,0 +1,62 @@ +import { MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + GOODBYE_CONFIG_ACTIONS, + buildGoodbyeConfigModal, + buildGoodbyeConfigPayload, + hasGoodbyeSetup +} from '../../commands/Welcome/modules/goodbyeConfig.js'; + +export default { + name: 'goodbye_config', + async execute(interaction, client, args) { + const action = args[0]; + + if (!GOODBYE_CONFIG_ACTIONS.includes(action)) { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That goodbye config action is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + const config = await getWelcomeConfig(client, interaction.guildId); + if (!hasGoodbyeSetup(config)) { + await interaction.reply({ + embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + if (action === 'ping') { + const newPingValue = !Boolean(config.goodbyePing); + await updateWelcomeConfig(client, interaction.guildId, { goodbyePing: newPingValue }); + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.update( + buildGoodbyeConfigPayload( + interaction.guild, + updatedConfig, + `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` + ) + ); + + logger.info(`[Goodbye Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); + return; + } + + const modal = buildGoodbyeConfigModal(action, config); + if (!modal) { + await interaction.reply({ + embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.showModal(modal); + } +}; \ No newline at end of file diff --git a/src/interactions/buttons/welcomeConfig.js b/src/interactions/buttons/welcomeConfig.js new file mode 100644 index 0000000000..a7ea9c96b8 --- /dev/null +++ b/src/interactions/buttons/welcomeConfig.js @@ -0,0 +1,62 @@ +import { MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + WELCOME_CONFIG_ACTIONS, + buildWelcomeConfigModal, + buildWelcomeConfigPayload, + hasWelcomeSetup +} from '../../commands/Welcome/modules/welcomeConfig.js'; + +export default { + name: 'welcome_config', + async execute(interaction, client, args) { + const action = args[0]; + + if (!WELCOME_CONFIG_ACTIONS.includes(action)) { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That welcome config action is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + const config = await getWelcomeConfig(client, interaction.guildId); + if (!hasWelcomeSetup(config)) { + await interaction.reply({ + embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + if (action === 'ping') { + const newPingValue = !Boolean(config.welcomePing); + await updateWelcomeConfig(client, interaction.guildId, { welcomePing: newPingValue }); + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.update( + buildWelcomeConfigPayload( + interaction.guild, + updatedConfig, + `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` + ) + ); + + logger.info(`[Welcome Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); + return; + } + + const modal = buildWelcomeConfigModal(action, config); + if (!modal) { + await interaction.reply({ + embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.showModal(modal); + } +}; \ No newline at end of file diff --git a/src/interactions/modals/goodbyeConfigModal.js b/src/interactions/modals/goodbyeConfigModal.js new file mode 100644 index 0000000000..136cef12b3 --- /dev/null +++ b/src/interactions/modals/goodbyeConfigModal.js @@ -0,0 +1,122 @@ +import { ChannelType, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + GOODBYE_CONFIG_ACTIONS, + buildGoodbyeConfigPayload, + hasGoodbyeSetup, + isValidImageUrl, + parseChannelInput +} from '../../commands/Welcome/modules/goodbyeConfig.js'; + +export default { + name: 'goodbye_config_modal', + async execute(interaction, client, args) { + const action = args[0]; + + if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That goodbye config form is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const currentConfig = await getWelcomeConfig(client, interaction.guildId); + if (!hasGoodbyeSetup(currentConfig)) { + await interaction.editReply({ + embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')] + }); + return; + } + + const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; + + try { + if (action === 'channel') { + const channelId = parseChannelInput(inputValue); + if (!channelId) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] + }); + return; + } + + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { goodbyeChannelId: channel.id }); + } + + if (action === 'message') { + if (!inputValue) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Message', 'Goodbye message cannot be empty.')] + }); + return; + } + + if (inputValue.length > 2000) { + await interaction.editReply({ + embeds: [errorEmbed('Message Too Long', 'Goodbye message must be 2000 characters or less.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { + leaveMessage: inputValue, + leaveEmbed: { + ...(currentConfig.leaveEmbed || {}), + description: inputValue + } + }); + } + + if (action === 'image') { + if (inputValue && !isValidImageUrl(inputValue)) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] + }); + return; + } + + const existingLeaveEmbed = currentConfig.leaveEmbed || {}; + const nextLeaveEmbed = { ...existingLeaveEmbed }; + + if (inputValue) { + nextLeaveEmbed.image = inputValue; + } else { + delete nextLeaveEmbed.image; + } + + await updateWelcomeConfig(client, interaction.guildId, { + leaveEmbed: nextLeaveEmbed + }); + } + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.editReply( + buildGoodbyeConfigPayload( + interaction.guild, + updatedConfig, + `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` + ) + ); + + logger.info(`[Goodbye Config] ${action} updated in guild ${interaction.guildId}`); + } catch (error) { + logger.error(`[Goodbye Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); + await interaction.editReply({ + embeds: [errorEmbed('Update Failed', 'An error occurred while updating your goodbye config.')] + }); + } + } +}; \ No newline at end of file diff --git a/src/interactions/modals/welcomeConfigModal.js b/src/interactions/modals/welcomeConfigModal.js new file mode 100644 index 0000000000..2c0b5c7263 --- /dev/null +++ b/src/interactions/modals/welcomeConfigModal.js @@ -0,0 +1,107 @@ +import { ChannelType, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + WELCOME_CONFIG_ACTIONS, + buildWelcomeConfigPayload, + hasWelcomeSetup, + isValidImageUrl, + parseChannelInput +} from '../../commands/Welcome/modules/welcomeConfig.js'; + +export default { + name: 'welcome_config_modal', + async execute(interaction, client, args) { + const action = args[0]; + + if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That welcome config form is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const currentConfig = await getWelcomeConfig(client, interaction.guildId); + if (!hasWelcomeSetup(currentConfig)) { + await interaction.editReply({ + embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')] + }); + return; + } + + const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; + + try { + if (action === 'channel') { + const channelId = parseChannelInput(inputValue); + if (!channelId) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] + }); + return; + } + + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { channelId: channel.id }); + } + + if (action === 'message') { + if (!inputValue) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Message', 'Welcome message cannot be empty.')] + }); + return; + } + + if (inputValue.length > 2000) { + await interaction.editReply({ + embeds: [errorEmbed('Message Too Long', 'Welcome message must be 2000 characters or less.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { welcomeMessage: inputValue }); + } + + if (action === 'image') { + if (inputValue && !isValidImageUrl(inputValue)) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { + welcomeImage: inputValue || null + }); + } + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.editReply( + buildWelcomeConfigPayload( + interaction.guild, + updatedConfig, + `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` + ) + ); + + logger.info(`[Welcome Config] ${action} updated in guild ${interaction.guildId}`); + } catch (error) { + logger.error(`[Welcome Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); + await interaction.editReply({ + embeds: [errorEmbed('Update Failed', 'An error occurred while updating your welcome config.')] + }); + } + } +}; \ No newline at end of file diff --git a/src/services/leveling.js b/src/services/leveling.js index c06738bfa3..01d38f47e1 100644 --- a/src/services/leveling.js +++ b/src/services/leveling.js @@ -178,7 +178,7 @@ export async function getLevelingConfig(client, guildId) { return guildConfig.leveling || { enabled: true, xpPerMessage: { min: 15, max: 25 }, - xpCooldown: 60, + xpCooldown: 20, levelUpMessage: '{user} has leveled up to level {level}!', levelUpChannel: null, ignoredChannels: [], @@ -193,7 +193,7 @@ export async function getLevelingConfig(client, guildId) { return { enabled: true, xpPerMessage: { min: 15, max: 25 }, - xpCooldown: 60, + xpCooldown: 20, levelUpMessage: '{user} has leveled up to level {level}!', levelUpChannel: null, ignoredChannels: [], @@ -222,7 +222,7 @@ export async function getUserLevelData(client, guildId, userId) { ); } - const key = `${guildId}:leveling:user:${userId}`; + const key = `${guildId}:leveling:users:${userId}`; const data = await client.db.get(key); if (!data) { @@ -287,10 +287,8 @@ export async function saveUserLevelData(client, guildId, userId, data) { rank: Number(data.rank) || 0 }; - const key = `${guildId}:leveling:user:${userId}`; + const key = `${guildId}:leveling:users:${userId}`; await client.db.set(key, sanitizedData); - - logger.debug(`Saved level data for user ${userId} in guild ${guildId}`); } catch (error) { logger.error(`Error saving user level data for ${userId}:`, error); if (error instanceof TitanBotError) throw error; @@ -362,6 +360,15 @@ export async function saveLevelingConfig(client, guildId, config) { export async function addLevels(client, guildId, userId, levels) { try { + const levelingConfig = await getLevelingConfig(client, guildId); + if (!levelingConfig?.enabled) { + throw new TitanBotError( + 'Leveling system is disabled on this server', + ErrorTypes.CONFIGURATION, + 'The leveling system is currently disabled on this server.' + ); + } + if (!Number.isInteger(levels) || levels <= 0) { throw new TitanBotError( @@ -414,6 +421,15 @@ export async function addLevels(client, guildId, userId, levels) { export async function removeLevels(client, guildId, userId, levels) { try { + const levelingConfig = await getLevelingConfig(client, guildId); + if (!levelingConfig?.enabled) { + throw new TitanBotError( + 'Leveling system is disabled on this server', + ErrorTypes.CONFIGURATION, + 'The leveling system is currently disabled on this server.' + ); + } + if (!Number.isInteger(levels) || levels <= 0) { throw new TitanBotError( @@ -458,6 +474,15 @@ export async function removeLevels(client, guildId, userId, levels) { export async function setUserLevel(client, guildId, userId, level) { try { + const levelingConfig = await getLevelingConfig(client, guildId); + if (!levelingConfig?.enabled) { + throw new TitanBotError( + 'Leveling system is disabled on this server', + ErrorTypes.CONFIGURATION, + 'The leveling system is currently disabled on this server.' + ); + } + if (!Number.isInteger(level) || level < MIN_LEVEL || level > MAX_LEVEL) { throw new TitanBotError( diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.js index 935818c8e7..96601a43e5 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.js @@ -502,4 +502,91 @@ export async function setReactionRoleChannel(client, guildId, messageId, channel } } +/** + * Reconcile reaction role messages against Discord state and remove stale database entries. + * Useful on startup to clean records for messages/channels deleted while the bot was offline. + * @param {Object} client - The Discord client + * @param {string} [guildId] - Optional guild ID to reconcile. If omitted, reconciles all guilds. + * @returns {Promise} Cleanup summary + */ +export async function reconcileReactionRoleMessages(client, guildId = null) { + const summary = { + scannedGuilds: 0, + scannedMessages: 0, + removedMessages: 0, + errors: 0 + }; + + try { + const targetGuildIds = guildId + ? [guildId] + : Array.from(client.guilds.cache.keys()); + + for (const targetGuildId of targetGuildIds) { + summary.scannedGuilds += 1; + + let reactionRoleMessages = []; + try { + reactionRoleMessages = await getAllReactionRoleMessages(client, targetGuildId); + } catch (error) { + summary.errors += 1; + logger.warn(`Failed to fetch reaction role messages for reconciliation in guild ${targetGuildId}:`, error); + continue; + } + + if (!reactionRoleMessages.length) { + continue; + } + + const guild = client.guilds.cache.get(targetGuildId) || await client.guilds.fetch(targetGuildId).catch(() => null); + if (!guild) { + for (const reactionRoleMessage of reactionRoleMessages) { + summary.scannedMessages += 1; + await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + summary.removedMessages += 1; + } + logger.info(`Removed ${reactionRoleMessages.length} stale reaction role message(s) for unavailable guild ${targetGuildId}`); + continue; + } + + for (const reactionRoleMessage of reactionRoleMessages) { + summary.scannedMessages += 1; + + try { + const channel = guild.channels.cache.get(reactionRoleMessage.channelId) + || await guild.channels.fetch(reactionRoleMessage.channelId).catch(() => null); + + if (!channel || !channel.isTextBased?.()) { + await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + summary.removedMessages += 1; + continue; + } + + const message = await channel.messages.fetch(reactionRoleMessage.messageId).catch(() => null); + if (!message) { + await client.db.delete(`reaction_roles:${targetGuildId}:${reactionRoleMessage.messageId}`); + summary.removedMessages += 1; + } + } catch (messageCheckError) { + summary.errors += 1; + logger.warn( + `Failed to validate reaction role message ${reactionRoleMessage.messageId} during reconciliation:`, + messageCheckError + ); + } + } + } + + logger.info( + `Reaction role reconciliation complete: scanned ${summary.scannedMessages} message(s) across ${summary.scannedGuilds} guild(s), removed ${summary.removedMessages}, errors ${summary.errors}` + ); + + return summary; + } catch (error) { + logger.error('Unexpected error during reaction role reconciliation:', error); + summary.errors += 1; + return summary; + } +} + diff --git a/src/services/xpSystem.js b/src/services/xpSystem.js index f24060d230..7f9cce5826 100644 --- a/src/services/xpSystem.js +++ b/src/services/xpSystem.js @@ -27,7 +27,7 @@ export async function addXp(client, guild, member, xpToAdd) { return { success: false, reason: 'Leveling is disabled in this server' }; } - const levelData = await getUserLevelData(client, guild.id, member.id); + const levelData = await getUserLevelData(client, guild.id, member.user.id); levelData.xp += xpToAdd; levelData.totalXp += xpToAdd; @@ -62,11 +62,11 @@ export async function addXp(client, guild, member, xpToAdd) { eventType: EVENT_TYPES.LEVELING_LEVELUP, data: { description: `${member.user.tag} reached level ${levelData.level}`, - userId: member.id, + userId: member.user.id, fields: [ { name: '๐Ÿ‘ค Member', - value: `${member.user.tag} (${member.id})`, + value: `${member.user.tag} (${member.user.id})`, inline: true }, { @@ -82,12 +82,11 @@ export async function addXp(client, guild, member, xpToAdd) { ] } }); - } catch (error) { - logger.debug('Error logging level up event:', error); + } catch { } } - await saveUserLevelData(client, guild.id, member.id, levelData); + await saveUserLevelData(client, guild.id, member.user.id, levelData); return { success: true, @@ -124,14 +123,13 @@ async function awardRoleReward(guild, member, roleId, level) { if (member.roles.cache.has(roleId)) { - logger.debug(`Member ${member.id} already has role ${roleId}`); return; } await member.roles.add(role, `Level ${level} reward`); logger.info(`โœ… Awarded role ${role.name} to ${member.user.tag} for reaching level ${level}`); } catch (error) { - logger.error(`Failed to award role reward to ${member.id}:`, error); + logger.error(`Failed to award role reward to ${member.user.id}:`, error); } } @@ -151,7 +149,6 @@ async function sendLevelUpAnnouncement(guild, member, levelData, config) { : guild.systemChannel; if (!levelUpChannel || !levelUpChannel.isTextBased()) { - logger.debug(`No valid levelup channel found for guild ${guild.id}`); return; } diff --git a/src/utils/database.js b/src/utils/database.js index 50418eed3b..b6efdead09 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -752,10 +752,6 @@ export async function saveLevelingConfig(client, guildId, config) { const key = getLevelingKey(guildId); try { await setInDb(key, config); - - if (process.env.NODE_ENV !== 'production') { - logger.debug(`๐Ÿ’พ Saved leveling config to database (guild: ${guildId})`); - } return true; } catch (error) { logger.error(`Error saving leveling config for guild ${guildId}:`, error); diff --git a/src/utils/interactionHelper.js b/src/utils/interactionHelper.js index 037ea548c8..35036b3780 100644 --- a/src/utils/interactionHelper.js +++ b/src/utils/interactionHelper.js @@ -132,7 +132,7 @@ if (error.code === 10062) { } if (!interaction.replied && !interaction.deferred) { - logger.warn(`Interaction ${interaction.id} not deferred, attempting reply instead of edit`); + logger.debug(`Interaction ${interaction.id} not deferred, using reply fallback instead of edit`); return await this.safeReply(interaction, options); } @@ -148,7 +148,7 @@ if (error.code === 40060) { return false; } if (error.name === 'InteractionNotReplied' || error.message.includes('not been sent or deferred')) { - logger.warn(`Interaction ${interaction.id} not replied, attempting reply instead of edit:`, error.message); + logger.debug(`Interaction ${interaction.id} not replied, using reply fallback instead of edit:`, error.message); return await this.safeReply(interaction, options); } logger.error('Failed to edit reply:', error); From b67ace4f3c7d144256c82d66081c0323d3f6c960 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Wed, 25 Feb 2026 20:54:38 +1100 Subject: [PATCH 04/10] Improve application, logging, and verification UIs Refactor UI/presentation and harden several command flows: - Centralize application status presentation (emoji/label/normalization) and apply across app-admin and apply commands; improve date, role and answer safety and presentation, add empty-answer handling, and fix button/customId parsing. --- PRODUCTION_READINESS_AUDIT.md | 248 +++++++++++++ src/commands/Community/app-admin.js | 101 +++-- src/commands/Community/apply.js | 67 +++- .../Config/modules/config_logging_status.js | 344 ++++++++---------- src/commands/JoinToCreate/jointocreate.js | 59 ++- .../Verification/modules/autoVerify.js | 108 +++++- src/commands/Verification/verification.js | 70 +++- src/commands/Welcome/autorole.js | 29 +- src/events/guildMemberAdd.js | 2 +- src/handlers/loggingButtons.js | 124 ++----- src/handlers/verificationButtons.js | 20 +- src/services/guildConfig.js | 2 +- src/services/joinToCreateService.js | 14 + src/services/loggingService.js | 4 +- src/services/verificationService.js | 58 ++- src/utils/constants.js | 2 +- src/utils/database.js | 123 ++++++- src/utils/errorHandler.js | 3 +- src/utils/loggingUi.js | 41 ++- src/utils/schemas.js | 7 +- 20 files changed, 1013 insertions(+), 413 deletions(-) create mode 100644 PRODUCTION_READINESS_AUDIT.md diff --git a/PRODUCTION_READINESS_AUDIT.md b/PRODUCTION_READINESS_AUDIT.md new file mode 100644 index 0000000000..5740febdfa --- /dev/null +++ b/PRODUCTION_READINESS_AUDIT.md @@ -0,0 +1,248 @@ +# TitanBot Production Readiness Audit + +**Date:** 2026-02-25 +**Scope:** Full repository scan (`src`, `config`, `events`, `handlers`, `services`, `utils`, startup/runtime scripts) +**Focus Areas:** Production standards, PostgreSQL ("PQL") security posture, logging consistency, error handling, operational security, and database architecture maturity. + +--- + +## Executive Summary + +TitanBot is **close to production-ready** and demonstrates strong architectural direction (centralized logging utility, robust PostgreSQL wrapper, graceful startup/shutdown, and structured error handling patterns). However, there are several **high-priority security and standardization gaps** that should be closed before enterprise-grade production operation. + +### Overall Rating +- **Current Readiness:** **7.1 / 10** (Good, with important hardening work remaining) +- **Recommendation:** **Conditional Go** for controlled production; complete P0 items before broad rollout. + +### Top Strengths +- PostgreSQL-first architecture with pooling, health checks, retries, indexes, and audit tables. +- Parameterized SQL usage in the majority of data operations. +- Central logger (`winston` + rotate files + exception/rejection handlers). +- Centralized interaction error system with categorized user/system error paths. +- Dependency audit currently clean (`npm audit`: 0 known vulnerabilities). + +### Top Risks +- Unsafe dynamic code execution path present (`new Function`) in calculator flows. +- Inconsistent logging standards: mixed `logger.*` and `console.*` usage. +- Weak database TLS setting (`rejectUnauthorized: false` when SSL enabled). +- Hardcoded external API key fallback exists in movie command. +- No CI workflow found enforcing tests/security/quality gates. + +--- + +## Methodology + +This report is based on: +- Static scan of source tree for security/logging/error/database patterns. +- Review of core runtime/config modules and key services. +- Query pattern inspection (parameterized vs interpolated SQL). +- Dependency risk check (`npm audit --json`). +- Operational-readiness signals (health/readiness endpoints, migration scripts, policy docs, workflow presence). + +--- + +## Scorecard by Domain + +| Domain | Score | Status | Summary | +|---|---:|---|---| +| Runtime Architecture | 8.0 | Good | Clean modular layout, service separation, and startup sequencing | +| PostgreSQL / Data Layer | 7.5 | Good | Strong schema/index setup; TLS and migration governance need hardening | +| PQL/SQL Security | 7.0 | Moderate | Parameterized queries mostly strong; dynamic SQL surfaces need guardrails | +| Logging Consistency | 6.0 | Moderate Risk | Central logger exists, but substantial `console.*` drift remains | +| Error Handling | 8.0 | Good | Centralized error categorization and interaction-safe response handling | +| Secrets & Config Security | 5.8 | Moderate Risk | Env-based model is good; hardcoded API key fallback is a security anti-pattern | +| Operations / Delivery | 5.5 | Moderate Risk | Health/readiness present, but no CI workflows detected | +| Dependency Security | 9.0 | Strong | Current advisory scan clean | + +--- + +## Findings (Pros, Cons, Improvements) + +## 1) PostgreSQL / PQL Security & Database Standards + +### Pros +- PostgreSQL wrapper includes: + - Connection pooling and retry/backoff. + - `statement_timeout` in production. + - Automatic table/index creation. + - Audit-oriented schema (`verification_audit`) and timestamp triggers. +- Most SQL operations use bind parameters (`$1`, `$2`, ...), reducing injection risk. +- Production protection exists in `src/services/database.js`: fallback storage is refused in production. + +### Cons / Risks +- SSL config uses `rejectUnauthorized: false` when `POSTGRES_SSL=true`, which weakens TLS trust verification. +- Some SQL statements interpolate identifiers (table/trigger names) dynamically; currently fed by internal constants, but still a sensitive surface. +- Two database access layers coexist (`src/utils/database.js` and `src/services/database.js`), increasing behavior divergence risk. +- No explicit migration history enforcement in standard runtime path (migration tool exists but workflow governance is unclear). + +### Improvements +- **P0:** Enforce strict TLS in production (`rejectUnauthorized: true` + CA bundle support). +- **P1:** Add identifier allowlist/assertion utility for dynamic SQL identifiers. +- **P1:** Consolidate to a single database abstraction or formally define boundaries between the two layers. +- **P1:** Add migration ledger/check gate at startup (or CI) to prevent schema drift. + +--- + +## 2) Logging Consistency & Observability + +### Pros +- Strong central logger (`winston`) with: + - Rotating files for combined/error logs. + - Exception/rejection handlers. + - Structured metadata support. +- Logging service provides event taxonomy and guild-level filtering. +- Good startup/shutdown and degraded-mode logging visibility. + +### Cons / Risks +- Logging standards are inconsistent across repo: + - Approx. **644** `logger.*` calls (good adoption) + - Approx. **60** `console.*` calls (standard drift) +- `console.*` usage bypasses central formatting, correlation, and retention behavior. +- No request/interaction correlation ID propagated consistently across services. + +### Improvements +- **P0:** Replace direct `console.*` with centralized logger wrapper. +- **P1:** Introduce correlation IDs for command interactions and propagate through service calls. +- **P1:** Define logging schema contract (`event`, `guildId`, `userId`, `command`, `errorCode`, `traceId`). +- **P2:** Add log quality checks (lint rule or CI grep guard) to block new `console.*` usage. + +--- + +## 3) Error Handling & Resilience + +### Pros +- Centralized `errorHandler` supports: + - Typed error categories. + - User-safe messaging. + - System-vs-user error separation. + - Safe interaction reply/edit fallback logic. +- `interactionCreate` catches handler-level failures and provides fallback user response. +- Startup catches fatal failures and exits in a controlled way. + +### Cons / Risks +- Error shape still varies by module (some throw typed errors, others raw errors). +- Some paths still report through console rather than central error flow. +- Limited evidence of automated negative-path testing for error contracts. + +### Improvements +- **P1:** Require typed application errors in service boundary layers. +- **P1:** Add error code registry and map to remediation hints. +- **P2:** Add tests for critical error scenarios (DB unavailable, Discord API failures, expired interactions). + +--- + +## 4) Security Posture (Application + Secrets) + +### Pros +- Security policy file exists with disclosure workflow and hardening guidance. +- Permission checks/audits are present in command helpers. +- Input sanitization helpers and schema normalization (`zod`) are in use. +- Dependency scan currently reports zero known vulnerabilities. + +### Cons / Risks +- **High-risk:** Dynamic execution present via `new Function` in calculator-related code paths. +- **High-risk:** Hardcoded TMDB API key fallback in movie search command. +- Sanitization utility is partial and may not be uniformly applied in all user-input flows. +- No CI workflow found for automated SAST/dependency/license/security policy enforcement. + +### Improvements +- **P0:** Remove `new Function`; replace with strict expression parser/evaluator (whitelist-based AST parser). +- **P0:** Remove hardcoded API key fallback and fail closed when env var is missing. +- **P1:** Expand schema-based validation (`zod`) for all command payloads and config mutations. +- **P1:** Add secret scanning and dependency policy checks in CI. +- **P2:** Add abuse protections for high-risk endpoints/commands (cooldown + anomaly logging). + +--- + +## 5) Operations, Reliability, and Production Standards + +### Pros +- Health (`/health`) and readiness (`/ready`) endpoints exist. +- Startup sequence and graceful shutdown behavior are implemented. +- Cron job registration and periodic maintenance tasks are structured. + +### Cons / Risks +- No GitHub Actions workflows detected in repository (build/test/security gates absent). +- No explicit SLO/SLI definitions or alert threshold policy found. +- Backup/restore drills and DR evidence are not represented in repo artifacts. + +### Improvements +- **P0:** Add CI workflows: lint, syntax checks, unit tests, dependency audit, secret scan. +- **P1:** Define minimum release gates (branch protection + required checks). +- **P1:** Add operational runbook docs for incident, rollback, and DB restore validation. +- **P2:** Add service-level metrics and alerting playbook (error rate, latency, DB connectivity, command failure rate). + +--- + +## Priority Remediation Plan + +### P0 (Do Before Broad Production Rollout) +1. Replace `new Function` evaluator with safe math parser. +2. Remove hardcoded TMDB API key fallback. +3. Enforce strict PostgreSQL TLS verification in production. +4. Eliminate `console.*` in runtime/service layers (route through logger). +5. Add basic CI workflow with mandatory security and quality checks. + +### P1 (Next 2โ€“4 Weeks) +1. Consolidate database access abstractions and codify single source of truth. +2. Add correlation IDs and standardized structured log fields. +3. Expand schema validation coverage for commands/config writes. +4. Add migration governance (version checks and controlled rollout). + +### P2 (Maturity / Scale) +1. Add SLO/SLI monitoring with alerts. +2. Add backup/restore automation and periodic drill evidence. +3. Add policy-as-code checks for security controls and code quality drift. + +--- + +## Evidence Snapshot + +- Dependency audit: **0 vulnerabilities** (prod deps: 149). +- Logging pattern scan: + - `logger.*`: ~644 occurrences + - `console.*`: ~60 occurrences +- Dynamic execution scan: + - `new Function`: detected in calculator-related modules. +- Security-sensitive config scan: + - PostgreSQL SSL currently allows `rejectUnauthorized: false` when enabled. +- Repo operations scan: + - No `.github/workflows/*` files detected. + +--- + +## Final Verdict + +TitanBot is **architecturally solid and operationally promising**, but it is **not yet at full industry-standard production hardening** due to specific P0 security and governance gaps. + +If P0 actions are completed, the project moves from **Conditional Production** to **Production Ready (Standard)**. If P1 actions are then completed, it reaches **Production Ready (Mature)** for larger-scale deployments. + +--- + +## Simple Improvement List (Copy/Paste) + +Use this checklist to track everything that should be improved: + +1. Turn on strict PostgreSQL TLS checks in production (require trusted cert validation). +2. Add a safe allowlist check for any dynamic SQL identifiers (like table/trigger names). +3. Merge the two database access layers into one standard approach (or clearly define strict boundaries). +4. Add migration version checks in startup/CI so schema drift is blocked. +5. Replace all `console.*` logs with the central logger. +6. Add a correlation/trace ID for each interaction and pass it through service calls. +7. Standardize log fields (`event`, `guildId`, `userId`, `command`, `errorCode`, `traceId`). +8. Add a lint/CI rule to block new `console.*` usage. +9. Enforce typed application errors at service boundaries. +10. Create an error code registry with remediation hints. +11. Add tests for major failure paths (DB down, Discord API failure, expired interaction). +12. Remove `new Function` and use a safe whitelist-based math parser. +13. Remove hardcoded TMDB key fallback; fail safely if env key is missing. +14. Expand `zod` validation to all command inputs and config writes. +15. Add CI secret scanning and dependency policy checks. +16. Add abuse protections on risky commands (cooldowns + anomaly logging). +17. Create CI workflows for lint, syntax checks, tests, dependency audit, and secret scan. +18. Add branch protection and required checks as release gates. +19. Add runbooks for incident response, rollback, and DB restore steps. +20. Add service metrics + alert playbooks (error rate, latency, DB connectivity, command failure rate). +21. Define SLO/SLI targets and alert thresholds. +22. Add backup/restore automation and run regular restore drills. +23. Add policy-as-code checks to prevent security/quality drift. diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index f3574c753c..da0d221651 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -15,6 +15,22 @@ import { } from '../../utils/database.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +function getApplicationStatusPresentation(statusValue) { + const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; + const statusLabel = + normalized === 'pending' ? 'In Progress' : + normalized === 'approved' ? 'Accepted' : + normalized === 'denied' ? 'Denied' : + 'Unknown'; + const statusEmoji = + normalized === 'pending' ? '๐ŸŸก' : + normalized === 'approved' ? '๐ŸŸข' : + normalized === 'denied' ? '๐Ÿ”ด' : + 'โšช'; + + return { normalized, statusLabel, statusEmoji }; +} + export default { data: new SlashCommandBuilder() .setName("app-admin") @@ -160,11 +176,13 @@ export default { }); } - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); - const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); + if (subcommand !== "questions") { + await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + } + logger.info(`App-admin command executed: ${subcommand}`, { userId: interaction.user.id, guildId: guild.id, @@ -292,32 +310,53 @@ async function handleView(interaction) { }); } - const statusColor = application.status === "approved" ? getColor('success') : (application.status === "denied" ? getColor('error') : getColor('warning')); - const embed = createEmbed({ title: `Application #${application.id} - ${application.roleName}`, description: `**User:** <@${application.userId}> (${application.userId})\n **Status:** ${application.status.charAt(0).toUpperCase() + application.status.slice(1)}\n` + + const { normalized: rawStatus, statusLabel, statusEmoji } = getApplicationStatusPresentation(application?.status); + const statusColor = rawStatus === "approved" ? getColor('success') : (rawStatus === "denied" ? getColor('error') : getColor('warning')); + const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; + const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) + ? submittedAt.toLocaleString() + : 'Unknown date'; + const roleName = application?.roleName || 'Unknown Role'; + const embed = createEmbed({ title: `${statusEmoji} Application #${application.id} - ${roleName}`, description: `**User:** <@${application.userId}> (${application.userId})\n **Status:** ${statusEmoji} ${statusLabel}\n` + (application.reviewer ? `**Reviewed by:** <@${application.reviewer}>\n` : "") + (application.reviewMessage ? `**Note:** ${application.reviewMessage}\n` : "") + - `**Submitted on:** ${new Date(application.createdAt).toLocaleString()}`, + `**Submitted on:** ${submittedAtDisplay}`, }).setColor(statusColor); if (application.avatar) { embed.setThumbnail(application.avatar); } - application.answers.forEach((answer, index) => { + const answers = Array.isArray(application.answers) ? application.answers : []; + answers.forEach((answer) => { + const question = typeof answer?.question === 'string' && answer.question.trim().length > 0 + ? answer.question + : 'Question'; + const response = typeof answer?.answer === 'string' && answer.answer.length > 0 + ? answer.answer + : 'No response provided.'; embed.addFields({ - name: answer.question, + name: question, value: - answer.answer.length > 1000 - ? answer.answer.substring(0, 997) + "..." - : answer.answer, + response.length > 1000 + ? response.substring(0, 997) + "..." + : response, inline: false, }); }); + if (answers.length === 0) { + embed.addFields({ + name: 'Application Answers', + value: 'No answers were stored for this application.', + inline: false, + }); + } + if (application.status === "pending") { const row = new ActionRowBuilder().addComponents( new ButtonBuilder() @@ -389,8 +428,9 @@ async function handleReview(interaction) { try { const user = await interaction.client.users.fetch(application.userId); const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const reviewStatus = getApplicationStatusPresentation(status); const dmEmbed = createEmbed( - `Application ${status.charAt(0).toUpperCase() + status.slice(1)}`, + `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, `Your application for **${application.roleName}** has been **${status}**.\n` + `**Note:** ${reason}\n\n` + `Use \`/apply status id:${appId}\` to view details.` @@ -419,13 +459,12 @@ async function handleReview(interaction) { if (logMessage) { const embed = logMessage.embeds[0]; if (embed) { + const reviewStatus = getApplicationStatusPresentation(status); const newEmbed = EmbedBuilder.from(embed) .setColor(statusColor) .spliceFields(0, 1, { name: "Status", - value: - status.charAt(0).toUpperCase() + - status.slice(1), + value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, }); await logMessage.edit({ @@ -532,20 +571,20 @@ async function handleList(interaction) { const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); applications.forEach((app) => { - const status = app.status.charAt(0).toUpperCase() + app.status.slice(1); - const statusEmoji = - { - Pending: "๐ŸŸก", - Approved: "๐ŸŸข", - Denied: "๐Ÿ”ด", - }[status] || "โšช"; + const statusView = getApplicationStatusPresentation(app?.status); + const roleName = app?.roleName || 'Unknown Role'; + const username = app?.username || 'Unknown User'; + const createdAt = app?.createdAt ? new Date(app.createdAt) : null; + const createdAtDisplay = createdAt && !Number.isNaN(createdAt.getTime()) + ? createdAt.toLocaleString() + : 'Unknown date'; embed.addFields({ - name: `${statusEmoji} ${app.roleName} - ${app.username}`, + name: `${statusView.statusEmoji} ${roleName} - ${username}`, value: `**ID:** \`${app.id}\`\n` + - `**Status:** ${status}\n` + - `**Date:** ${new Date(app.createdAt).toLocaleString()}`, + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + + `**Date:** ${createdAtDisplay}`, inline: true, }); }); @@ -697,8 +736,8 @@ export async function handleApplicationButton(interaction) { const customId = interaction.customId; if (!customId.startsWith('app_approve_') && !customId.startsWith('app_deny_')) return; - const [action, appId] = customId.split('_'); - const isApprove = action === 'app'; + const [, action, appId] = customId.split('_'); + const isApprove = action === 'approve'; try { const application = await getApplication(interaction.client, interaction.guild.id, appId); @@ -781,8 +820,9 @@ export async function handleApplicationReviewModal(interaction) { try { const user = await interaction.client.users.fetch(application.userId); + const reviewStatus = getApplicationStatusPresentation(status); const dmEmbed = createEmbed( - `Application ${status.charAt(0).toUpperCase() + status.slice(1)}`, + `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, `Your application for **${application.roleName}** has been **${status}**.\n` + `**Note:** ${reason}\n\n` + `Use \`/apply status id:${appId}\` to view details.`, @@ -802,11 +842,12 @@ export async function handleApplicationReviewModal(interaction) { if (logMessage) { const embed = logMessage.embeds[0]; if (embed) { + const reviewStatus = getApplicationStatusPresentation(status); const newEmbed = EmbedBuilder.from(embed) .setColor(isApprove ? '#00FF00' : '#FF0000') .spliceFields(0, 1, { name: 'Status', - value: status.charAt(0).toUpperCase() + status.slice(1) + value: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}` }); await logMessage.edit({ @@ -833,8 +874,8 @@ export async function handleApplicationReviewModal(interaction) { await InteractionHelper.safeEditReply(interaction, { embeds: [ successEmbed( - `Application ${status}`, - `The application has been ${status}.` + `${getApplicationStatusPresentation(status).statusEmoji} Application ${getApplicationStatusPresentation(status).statusLabel}`, + `The application has been marked as ${getApplicationStatusPresentation(status).statusLabel}.` ) ], flags: ["Ephemeral"] diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index 9b3ebcf17f..ef617c1b59 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -14,6 +14,22 @@ import { updateApplication } from '../../utils/database.js'; +function getApplicationStatusPresentation(statusValue) { + const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; + const statusLabel = + normalized === 'pending' ? 'In Progress' : + normalized === 'approved' ? 'Accepted' : + normalized === 'denied' ? 'Denied' : + 'Unknown'; + const statusEmoji = + normalized === 'pending' ? '๐ŸŸก' : + normalized === 'approved' ? '๐ŸŸข' : + normalized === 'denied' ? '๐Ÿ”ด' : + 'โšช'; + + return { normalized, statusLabel, statusEmoji }; +} + export default { data: new SlashCommandBuilder() .setName("apply") @@ -56,11 +72,13 @@ export default { }); } - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); - const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); + if (subcommand !== "submit") { + await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + } + logger.info(`Apply command executed: ${subcommand}`, { userId: interaction.user.id, guildId: guild.id, @@ -160,7 +178,7 @@ export async function handleApplicationModal(interaction) { `**Application:** ${applicationRole.name}\n` + `**Role:** ${role.name}\n` + `**Application ID:** \`${application.id}\`\n` + - `**Status:** Pending` + `**Status:** ๐ŸŸก In Progress` }).setColor(getColor('warning')); const logMessage = await logChannel.send({ embeds: [logEmbed] }); @@ -330,7 +348,18 @@ async function handleStatus(interaction) { }); } - const embed = createEmbed({ title: `Application #${application.id} - ${application.roleName}`, description: `**Status:** ${application.status.charAt(0).toUpperCase() + application.status.slice(1)}` }); + const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; + const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) + ? submittedAt.toLocaleString() + : 'Unknown date'; + const statusView = getApplicationStatusPresentation(application.status); + const embed = createEmbed({ + title: `Application #${application.id} - ${application.roleName || 'Unknown Role'}`, + description: + `**Application ID:** \`${application.id}\`\n` + + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + + `**Submitted:** ${submittedAtDisplay}` + }); return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } else { @@ -349,7 +378,35 @@ async function handleStatus(interaction) { }); } - const embed = createEmbed({ title: "Your Applications", description: "Here are your recent applications." }); + const recentApplications = applications + .sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)) + .slice(0, 10); + + const embed = createEmbed({ + title: "Your Applications", + description: `Showing ${recentApplications.length} recent application(s).` + }); + + recentApplications.forEach((application) => { + const submittedAt = application?.createdAt ? new Date(application.createdAt) : null; + const submittedAtDisplay = submittedAt && !Number.isNaN(submittedAt.getTime()) + ? submittedAt.toLocaleDateString() + : 'Unknown date'; + const statusView = getApplicationStatusPresentation(application.status); + + embed.addFields({ + name: `${statusView.statusEmoji} ${application.roleName || 'Unknown Role'} (${statusView.statusLabel})`, + value: + `**ID:** \`${application.id}\`\n` + + `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + + `**Submitted:** ${submittedAtDisplay}`, + inline: true, + }); + }); + + if (applications.length > recentApplications.length) { + embed.setFooter({ text: `Showing latest ${recentApplications.length} of ${applications.length} applications.` }); + } return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); } diff --git a/src/commands/Config/modules/config_logging_status.js b/src/commands/Config/modules/config_logging_status.js index 0d167da9ea..6bdca7e803 100644 --- a/src/commands/Config/modules/config_logging_status.js +++ b/src/commands/Config/modules/config_logging_status.js @@ -1,16 +1,151 @@ import { getColor } from '../../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; +import { PermissionsBitField, EmbedBuilder } from 'discord.js'; +import { errorEmbed } from '../../../utils/embeds.js'; import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getLoggingStatus } from '../../../services/loggingService.js'; -import { getLevelingConfig, getWelcomeConfig, getApplicationSettings, getModlogSettings } from '../../../utils/database.js'; -import { createStatusIndicatorButtons } from '../../../utils/loggingUi.js'; +import { getLoggingStatus, EVENT_TYPES } from '../../../services/loggingService.js'; +import { getWelcomeConfig, getApplicationSettings } from '../../../utils/database.js'; +import { createLoggingStatusComponents } from '../../../utils/loggingUi.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getConfiguration as getJoinToCreateConfiguration } from '../../../services/joinToCreateService.js'; +import { getLevelingConfig } from '../../../services/leveling.js'; + +const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { + const [category] = eventType.split('.'); + if (!accumulator[category]) { + accumulator[category] = []; + } + accumulator[category].push(eventType); + return accumulator; +}, {}); + +function asEnabledLabel(enabled) { + return enabled ? 'โœ… Enabled' : 'โŒ Disabled'; +} + +async function formatChannelMention(guild, id) { + if (!id) return 'โŒ Not Set'; + const channel = guild.channels.cache.get(id) || await guild.channels.fetch(id).catch(() => null); + return channel ? channel.toString() : `โš ๏ธ Missing (${id})`; +} + +function formatRoleMention(guild, id) { + if (!id) return 'โŒ Not Set'; + const role = guild.roles.cache.get(id); + return role ? role.toString() : `โš ๏ธ Missing (${id})`; +} + +function getCategoryStatus(enabledEvents, category, auditEnabled = true) { + if (!auditEnabled) { + return false; + } + + const events = enabledEvents || {}; + if (events[`${category}.*`] === false) { + return false; + } + + const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; + if (categoryEvents.length === 0) { + return true; + } + + return categoryEvents.every((eventType) => events[eventType] !== false); +} + +export async function buildLoggingStatusView(interaction, client) { + const guildConfig = await getGuildConfig(client, interaction.guildId); + const loggingStatus = await getLoggingStatus(client, interaction.guildId); + const levelingConfig = await getLevelingConfig(client, interaction.guildId); + const welcomeConfig = await getWelcomeConfig(client, interaction.guildId); + const applicationConfig = await getApplicationSettings(client, interaction.guildId); + const joinToCreateConfig = await getJoinToCreateConfiguration(client, interaction.guildId); + + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig?.roleIds) && welcomeConfig.roleIds.length > 0); + + const auditEnabled = Boolean(loggingStatus.enabled); + const auditChannelStatus = await formatChannelMention( + interaction.guild, + loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId + ); + const reportChannelStatus = await formatChannelMention(interaction.guild, guildConfig.reportChannelId); + const lifecycleChannelStatus = await formatChannelMention(interaction.guild, guildConfig.ticketLogging?.lifecycleChannelId); + const transcriptChannelStatus = await formatChannelMention(interaction.guild, guildConfig.ticketLogging?.transcriptChannelId); + + const systems = [ + { name: '๐Ÿงพ Audit Logging', value: asEnabledLabel(auditEnabled), inline: true }, + { name: '๐Ÿ“ˆ Leveling', value: asEnabledLabel(Boolean(levelingConfig?.enabled)), inline: true }, + { name: '๐Ÿ‘‹ Welcome', value: asEnabledLabel(Boolean(welcomeConfig?.enabled)), inline: true }, + { name: '๐Ÿ‘‹ Goodbye', value: asEnabledLabel(Boolean(welcomeConfig?.goodbyeEnabled)), inline: true }, + { name: '๐ŸŽ‚ Birthday', value: asEnabledLabel(Boolean(guildConfig.birthdayChannelId)), inline: true }, + { name: '๐Ÿ“‹ Applications', value: asEnabledLabel(Boolean(applicationConfig?.enabled)), inline: true }, + { name: 'โœ… Verification', value: asEnabledLabel(verificationEnabled), inline: true }, + { name: '๐Ÿค– AutoVerify', value: asEnabledLabel(autoVerifyEnabled), inline: true }, + { name: '๐ŸŽง Join to Create', value: asEnabledLabel(Boolean(joinToCreateConfig?.enabled)), inline: true }, + { name: '๐Ÿ›ก๏ธ Auto Role', value: autoRoleConfigured ? `โœ… Configured (${formatRoleMention(interaction.guild, guildConfig.autoRole)})` : 'โŒ Disabled', inline: true } + ]; + + const categoryMap = [ + ['moderation', '๐Ÿ”จ Moderation'], + ['ticket', '๐ŸŽซ Ticket Events'], + ['message', 'โŒ Message Events'], + ['role', '๐Ÿท๏ธ Role Events'], + ['member', '๐Ÿ‘ฅ Member Events'], + ['leveling', '๐Ÿ“ˆ Leveling Events'], + ['reactionrole', '๐ŸŽญ Reaction Role Events'], + ['giveaway', '๐ŸŽ Giveaway Events'], + ['counter', '๐Ÿ“Š Counter Events'] + ]; + + const eventStatusLines = categoryMap + .map(([key, label]) => `${getCategoryStatus(loggingStatus.enabledEvents, key, auditEnabled) ? 'โœ…' : 'โŒ'} ${label}`) + .join('\n'); + + const ignoredUsers = guildConfig.logIgnore?.users || []; + const ignoredChannels = guildConfig.logIgnore?.channels || []; + + const statusEmbed = new EmbedBuilder() + .setTitle('โš™๏ธ Configuration Status') + .setDescription(`Live status for **${interaction.guild.name}**. Toggle buttons update this embed instantly.`) + .setColor(getColor('info')) + .addFields( + ...systems, + { + name: '๐Ÿ“ก Log Destinations', + value: + `Audit: ${auditChannelStatus}\n` + + `Reports: ${reportChannelStatus}\n` + + `Ticket Lifecycle: ${lifecycleChannelStatus}\n` + + `Ticket Transcripts: ${transcriptChannelStatus}`, + inline: false, + }, + { + name: '๐Ÿ“‹ Event Categories', + value: eventStatusLines, + inline: false, + }, + { + name: '๐Ÿงน Ignore Filters', + value: `Users: ${ignoredUsers.length}\nChannels: ${ignoredChannels.length}`, + inline: true, + }, + { + name: '๐Ÿ•’ Last Refresh', + value: ``, + inline: true, + } + ) + .setTimestamp(); + + const components = createLoggingStatusComponents(loggingStatus.enabledEvents, auditEnabled); + return { embed: statusEmbed, components }; +} export default { async execute(interaction, config, client) { try { -if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { return InteractionHelper.safeReply(interaction, { embeds: [ errorEmbed( @@ -22,203 +157,10 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) } await InteractionHelper.safeDefer(interaction); - - const currentConfig = await getGuildConfig(client, interaction.guildId); - const loggingStatus = await getLoggingStatus(client, interaction.guildId); - - const getStatus = (id, type) => { - let status = "โŒ Not Set"; - if (id) { - const item = - type === "role" - ? interaction.guild.roles.cache.get(id) - : interaction.guild.channels.cache.get(id); - - status = item ? item.toString() : `โš ๏ธ ID: ${id} (Missing)`; - } - return status; - }; - - const logChannelStatus = getStatus( - loggingStatus.channelId || currentConfig.logChannelId, - "channel", - ); - const reportChannelStatus = getStatus( - currentConfig.reportChannelId, - "channel", - ); - const premiumRoleStatus = getStatus( - currentConfig.premiumRoleId, - "role", - ); - - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - const levelingStatus = levelingConfig?.enabled ? "โœ… **Enabled**" : "โŒ **Disabled**"; - - const welcomeConfig = await getWelcomeConfig(client, interaction.guildId); - const welcomeStatus = welcomeConfig?.enabled ? "โœ… **Enabled**" : "โŒ **Disabled**"; - const goodbyeStatus = welcomeConfig?.goodbyeEnabled ? "โœ… **Enabled**" : "โŒ **Disabled**"; - - const autoRoleStatus = getStatus(currentConfig.autoRole, "role"); - - const birthdayStatus = currentConfig.birthdayChannelId ? - "โœ… **Enabled**" : "โŒ **Disabled**"; - - const aggregateLoggingStatus = loggingStatus.enabled && (loggingStatus.channelId || currentConfig.logChannelId) - ? "โœ… **Enabled**" : "โŒ **Disabled**"; - - const applicationConfig = await getApplicationSettings(client, interaction.guildId); - const applicationStatus = applicationConfig?.enabled ? "โœ… **Enabled**" : "โŒ **Disabled**"; - - const maxTicketsPerUser = currentConfig.maxTicketsPerUser || 3; - const dmOnClose = currentConfig.dmOnClose !== false; - - const ticketLogging = currentConfig.ticketLogging || {}; - const lifecycleChannelStatus = getStatus(ticketLogging.lifecycleChannelId, "channel"); - const transcriptChannelStatus = getStatus(ticketLogging.transcriptChannelId, "channel"); - - let totalOpenTickets = 0; - try { - const { getFromDb } = await import('../../../utils/database.js'); - const ticketKeys = await getFromDb(`guild:${interaction.guildId}:ticket:*`, {}); - for (const key of Object.keys(ticketKeys)) { - const ticketData = await getFromDb(key, null); - if (ticketData && ticketData.status === 'open') totalOpenTickets++; - } - } catch (e) { - console.error('Error counting tickets:', e); - } - - const ticketLoggingStatus = ticketLogging.lifecycleChannelId || ticketLogging.transcriptChannelId - ? "โœ… **Enabled**" : "โŒ **Disabled**"; - - const ticketLimitsStatus = `๐ŸŽซ **${maxTicketsPerUser}** per user\n๐Ÿ“ฉ DM on Close: ${dmOnClose ? 'โœ…' : 'โŒ'}\n๐Ÿ“Š Open Tickets: ${totalOpenTickets}\n๐Ÿ“ Ticket Logging: ${ticketLoggingStatus}`; - - const ignoredUsers = currentConfig.logIgnore?.users || []; - const ignoredChannels = currentConfig.logIgnore?.channels || []; - - const formatIdList = (list) => { - if (list.length === 0) return "None"; - if (list.length > 5) - return `${list.length} IDs (see console for full list)`; - return list.map((id) => `\`${id}\``).join("\n"); - }; - - - let eventStatus = ''; - const categories = { - 'moderation': '๐Ÿ”จ Moderation', - 'ticket': '๐ŸŽซ Tickets', - 'message': 'โŒ Messages', - 'role': '๐Ÿท๏ธ Roles', - 'member': '๐Ÿ‘‹ Join/Leave', - 'leveling': '๐Ÿ“ˆ Leveling', - 'reactionrole': '๐ŸŽญ Reaction Roles', - 'giveaway': '๐ŸŽ Giveaway', - 'counter': '๐Ÿ“Š Counter' - }; - - for (const [category, display] of Object.entries(categories)) { - const categoryEntries = Object.entries(loggingStatus.enabledEvents) - .filter(([key]) => key.startsWith(category)); - const isEnabled = categoryEntries.length === 0 - ? true - : categoryEntries.some(([, value]) => value !== false); - - eventStatus += `${isEnabled ? 'โœ…' : 'โŒ'} ${display}\n`; - } - - const statusEmbed = new EmbedBuilder() - .setTitle("โš™๏ธ Server Configuration Status") - .setDescription( - `Current settings fetched for **${interaction.guild.name}**.`, - ) - .setColor(getColor('info')) - .setTimestamp() - .addFields( - { - name: "๐ŸŽฎ Leveling System", - value: levelingStatus, - inline: true, - }, - { - name: "๐ŸŽ‚ Birthday System", - value: birthdayStatus, - inline: true, - }, - { - name: "๐Ÿ‘‹ Welcome System", - value: welcomeStatus, - inline: true, - }, - { - name: "๐Ÿ‘‹ Goodbye System", - value: goodbyeStatus, - inline: true, - }, - { - name: "๐Ÿค– Auto Role", - value: autoRoleStatus, - inline: true, - }, - { - name: "๐Ÿ“‹ Applications", - value: applicationStatus, - inline: true, - }, - { - name: "๐Ÿ“Š Unified Logging System", - value: aggregateLoggingStatus, - inline: true, - }, - { - name: "๐Ÿ’Ž Premium Role", - value: premiumRoleStatus, - inline: true, - }, - { - name: "๐ŸŽซ Ticket Limits", - value: ticketLimitsStatus, - inline: true, - }, - { - name: "๐Ÿ“Š Configuration Channels", - value: "**Audit Logs:** " + logChannelStatus + - "\n**Report Logs:** " + reportChannelStatus + - "\n**Ticket Lifecycle:** " + lifecycleChannelStatus + - "\n**Ticket Transcripts:** " + transcriptChannelStatus, - inline: false, - }, - { - name: "๐Ÿ“‹ Event Logging Status", - value: eventStatus, - inline: false, - }, - { - name: "โŒ Log Filters", - value: "**Users:** " + formatIdList(ignoredUsers) + "\n**Channels:** " + formatIdList(ignoredChannels), - inline: false, - }, - ); - - - const statusButtons = createStatusIndicatorButtons(loggingStatus.enabledEvents); - const refreshButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('logging_toggle:all') - .setLabel('Toggle All') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('logging_refresh_status') - .setLabel('๐Ÿ”„ Refresh Status') - .setStyle(ButtonStyle.Primary) - ); - - - const components = [...statusButtons, refreshButton]; + const { embed, components } = await buildLoggingStatusView(interaction, client); await InteractionHelper.safeEditReply(interaction, { - embeds: [statusEmbed], + embeds: [embed], components }); } catch (error) { diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index e97a543514..d51401940f 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -136,24 +136,41 @@ async function handleSetupSubcommand(interaction, client) { // Check if guild already has a Join to Create channel configured const existingConfig = await getConfiguration(client, guildId); - if (existingConfig.triggerChannels && existingConfig.triggerChannels.length > 0) { - // Guild has a JTC channel in database - verify it still exists in Discord - const existingChannelId = existingConfig.triggerChannels[0]; - const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); - - if (existingChannel) { - // Channel exists, show error - const errorMessage = `This server already has a Join to Create channel set up: ${existingChannel}\n\nUse \`/jointocreate config\` to modify it, or remove it first before creating a new one.`; - + if (Array.isArray(existingConfig.triggerChannels) && existingConfig.triggerChannels.length > 0) { + const activeTriggerChannels = []; + const staleTriggerChannelIds = []; + + for (const existingChannelId of existingConfig.triggerChannels) { + const existingChannel = await interaction.guild.channels.fetch(existingChannelId).catch(() => null); + if (existingChannel) { + activeTriggerChannels.push(existingChannel); + } else { + staleTriggerChannelIds.push(existingChannelId); + } + } + + if (staleTriggerChannelIds.length > 0) { + for (const staleChannelId of staleTriggerChannelIds) { + logger.info(`Cleaning up stale JTC trigger ${staleChannelId} from guild ${guildId}`); + await removeTriggerChannel(client, guildId, staleChannelId); + } + } + + if (activeTriggerChannels.length > 0) { + const primaryTrigger = activeTriggerChannels[0]; + const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate config\` to modify it, or remove it first before creating a new one.`; + throw new TitanBotError( 'Guild already has a Join to Create channel', ErrorTypes.VALIDATION, - errorMessage + errorMessage, + { + guildId, + activeTriggerCount: activeTriggerChannels.length, + expected: true, + suppressErrorLog: true + } ); - } else { - // Channel in database but doesn't exist in Discord - clean it up - logger.info(`Cleaning up stale JTC trigger ${existingChannelId} from guild ${guildId}`); - await removeTriggerChannel(client, guildId, existingChannelId); } } @@ -248,7 +265,7 @@ async function handleConfigSubcommand(interaction, client) { inline: true } ) - .setFooter({ text: 'Use the buttons below to modify settings' }) + .setFooter({ text: 'Use the buttons below to modify settings โ€ข Only one trigger channel is supported per guild' }) .setTimestamp(); @@ -274,11 +291,21 @@ async function handleConfigSubcommand(interaction, client) { const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); - const message = await InteractionHelper.safeEditReply(interaction, { + await InteractionHelper.safeEditReply(interaction, { embeds: [configEmbed], components: [row] }); + const message = await interaction.fetchReply(); + + if (!message || typeof message.createMessageComponentCollector !== 'function') { + throw new TitanBotError( + 'Failed to fetch interaction reply for collector setup', + ErrorTypes.DISCORD_API, + 'Failed to open configuration controls. Please run `/jointocreate config` again.' + ); + } + const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index ffead35ea9..b944131c1e 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -6,6 +6,7 @@ import { withErrorHandling, createError, ErrorTypes } from '../../../utils/error import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { getWelcomeConfig } from '../../../utils/database.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; @@ -20,8 +21,14 @@ export default { .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand(subcommand => subcommand - .setName("enable") - .setDescription("Enable automatic verification") + .setName("setup") + .setDescription("Set up automatic verification") + .addRoleOption(option => + option + .setName("role") + .setDescription("Role to assign to users who meet auto-verify criteria") + .setRequired(true) + ) .addStringOption(option => option .setName("criteria") @@ -54,13 +61,13 @@ export default { ), async execute(interaction, config, client) { - return withErrorHandling(async () => { + const wrappedExecute = withErrorHandling(async () => { const subcommand = interaction.options.getSubcommand(); const guild = interaction.guild; switch (subcommand) { - case "enable": - return await handleEnable(interaction, guild, client); + case "setup": + return await handleSetup(interaction, guild, client); case "disable": return await handleDisable(interaction, guild, client); case "status": @@ -74,20 +81,78 @@ export default { ); } }, { command: 'autoverify', subcommand: interaction.options.getSubcommand() }); + + return await wrappedExecute(interaction, config, client); } }; -async function handleEnable(interaction, guild, client) { +async function handleSetup(interaction, guild, client) { const criteria = interaction.options.getString("criteria"); const accountAgeDays = interaction.options.getInteger("account_age_days") || defaultAccountAgeDays; + const targetRole = interaction.options.getRole("role"); await InteractionHelper.safeDefer(interaction); try { + const guildConfig = await getGuildConfig(client, guild.id); + const welcomeConfig = await getWelcomeConfig(client, guild.id); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const hasAutoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + if (verificationEnabled || hasAutoRoleConfigured) { + throw createError( + 'Auto-verify enable blocked by conflicting onboarding system', + ErrorTypes.CONFIGURATION, + 'You cannot enable **AutoVerify** while the verification system or AutoRole is configured. Disable those first.', + { + guildId: guild.id, + verificationEnabled, + hasAutoRoleConfigured, + expected: true, + suppressErrorLog: true + } + ); + } + + const botMember = guild.members.me; + if (!botMember) { + throw createError( + 'Bot member not found in guild cache', + ErrorTypes.CONFIGURATION, + 'I could not verify my permissions in this server. Please try again in a moment.', + { guildId: guild.id } + ); + } + + if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw createError( + 'Missing ManageRoles permission', + ErrorTypes.PERMISSION, + "I need the 'Manage Roles' permission to assign auto-verify roles.", + { guildId: guild.id } + ); + } + + if (targetRole.id === guild.id || targetRole.managed) { + throw createError( + 'Invalid auto-verify role selected', + ErrorTypes.VALIDATION, + 'Please choose a normal assignable role (not @everyone or an integration-managed role).', + { guildId: guild.id, roleId: targetRole.id, managed: targetRole.managed } + ); + } + + if (targetRole.position >= botMember.roles.highest.position) { + throw createError( + 'Role hierarchy error for auto-verify setup', + ErrorTypes.PERMISSION, + 'The selected auto-verify role must be below my highest role in the server role hierarchy.', + { guildId: guild.id, roleId: targetRole.id, rolePosition: targetRole.position, botRolePosition: botMember.roles.highest.position } + ); + } + validateAutoVerifyCriteria(criteria, criteria === 'account_age' ? accountAgeDays : 1); - - const guildConfig = await getGuildConfig(client, guild.id); if (!guildConfig.verification) { guildConfig.verification = {}; @@ -96,7 +161,9 @@ async function handleEnable(interaction, guild, client) { guildConfig.verification.autoVerify = { enabled: true, criteria: criteria, - accountAgeDays: criteria === "account_age" ? accountAgeDays : null + accountAgeDays: criteria === "account_age" ? accountAgeDays : null, + roleId: targetRole.id, + configuredVia: 'setup' }; await setGuildConfig(client, guild.id, guildConfig); @@ -117,13 +184,14 @@ async function handleEnable(interaction, guild, client) { logger.info('Auto-verify enabled', { guildId: guild.id, criteria, - accountAgeDays: criteria === 'account_age' ? accountAgeDays : null + accountAgeDays: criteria === 'account_age' ? accountAgeDays : null, + roleId: targetRole.id }); await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( - "Auto-Verification Enabled", - `Automatic verification has been enabled!\n\n**Criteria:** ${criteriaDescription}\n\nUsers who meet these criteria will be automatically verified when they join the server.` + "Auto-Verification Configured", + `Automatic verification has been configured!\n\n**Role:** ${targetRole}\n**Criteria:** ${criteriaDescription}\n\nUsers who meet these criteria will receive this role when they join the server.` )] }); @@ -159,18 +227,26 @@ async function handleDisable(interaction, guild, client) { async function handleStatus(interaction, guild, client) { const guildConfig = await getGuildConfig(client, guild.id); + const welcomeConfig = await getWelcomeConfig(client, guild.id); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + const conflictSummary = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean).join('\n'); if (!guildConfig.verification?.autoVerify?.enabled) { return await InteractionHelper.safeReply(interaction, { embeds: [infoEmbed( "Auto-Verification Status", - "๐Ÿ”ด **Status:** Disabled\n\nAuto-verification is currently disabled. Users must verify manually.\n\nUse `/autoverify enable` to enable it." + `๐Ÿ”ด **Status:** Disabled\n\nAuto-verification is currently disabled.\n\nUse \`/autoverify setup\` to configure it.${conflictSummary ? `\n\nโš ๏ธ **Setup Blockers:**\n${conflictSummary}` : ''}` )], flags: MessageFlags.Ephemeral }); } const autoVerify = guildConfig.verification.autoVerify; + const autoVerifyRole = autoVerify.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; let criteriaDescription = ""; switch (autoVerify.criteria) { @@ -192,11 +268,17 @@ async function handleStatus(interaction, guild, client) { }) .addFields( { name: "๐Ÿ“Š Status", value: "โœ… Enabled", inline: true }, + { name: "๐Ÿท๏ธ Target Role", value: autoVerifyRole ? autoVerifyRole.toString() : "Not found", inline: true }, { name: "๐ŸŽฏ Criteria", value: criteriaDescription, inline: true }, { name: "๐Ÿ“… Account Age Requirement", value: autoVerify.accountAgeDays ? `${autoVerify.accountAgeDays} days` : "N/A", inline: true + }, + { + name: "โš ๏ธ Setup Conflicts", + value: conflictSummary || "None", + inline: false } ); diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index 0ccf9b30b6..27e77604a5 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -1,12 +1,13 @@ import { botConfig, getColor } from '../../config/bot.js'; import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, infoEmbed } from '../../utils/embeds.js'; +import { createEmbed, errorEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../services/guildConfig.js'; import { handleInteractionError, withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { removeVerification, verifyUser } from '../../services/verificationService.js'; -import { ContextualMessages, MessageTemplates } from '../../utils/messageTemplates.js'; +import { ContextualMessages } from '../../utils/messageTemplates.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getWelcomeConfig } from '../../utils/database.js'; export default { data: new SlashCommandBuilder() @@ -72,7 +73,7 @@ export default { ), async execute(interaction, config, client) { - return withErrorHandling(async () => { + const wrappedExecute = withErrorHandling(async () => { const subcommand = interaction.options.getSubcommand(); const guild = interaction.guild; @@ -105,6 +106,8 @@ export default { ); } }, { command: 'verification', subcommand: interaction.options.getSubcommand() }); + + return await wrappedExecute(interaction, config, client); } }; @@ -151,6 +154,15 @@ async function handleSetup(interaction, guild, client) { ); } + if (verifiedRole.id === guild.id || verifiedRole.managed) { + throw createError( + 'Invalid verified role selected', + ErrorTypes.VALIDATION, + 'Please choose a normal assignable role (not @everyone or an integration-managed role).', + { roleId: verifiedRole.id, managed: verifiedRole.managed } + ); + } + const botRole = botMember.roles.highest; if (verifiedRole.position >= botRole.position) { throw createError( @@ -161,6 +173,26 @@ async function handleSetup(interaction, guild, client) { ); } + const guildConfig = await getGuildConfig(client, guild.id); + const welcomeConfig = await getWelcomeConfig(client, guild.id); + const hasAutoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const hasAutoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + if (hasAutoVerifyEnabled || hasAutoRoleConfigured) { + throw createError( + 'Verification setup blocked by conflicting onboarding system', + ErrorTypes.CONFIGURATION, + 'You cannot enable the verification system while **AutoVerify** or **AutoRole** is configured. Disable those first.', + { + guildId: guild.id, + hasAutoVerifyEnabled, + hasAutoRoleConfigured, + expected: true, + suppressErrorLog: true + } + ); + } + await InteractionHelper.safeDefer(interaction); const verifyEmbed = createEmbed({ @@ -182,7 +214,6 @@ async function handleSetup(interaction, guild, client) { components: [verifyButton] }); - const guildConfig = await getGuildConfig(client, guild.id); guildConfig.verification = { enabled: true, channelId: verificationChannel.id, @@ -215,7 +246,7 @@ async function handleVerify(interaction, guild, client) { if (!result.success) { if (result.alreadyVerified) { return await InteractionHelper.safeReply(interaction, { - embeds: [MessageTemplates.INFO.ALREADY_CONFIGURED("verified")], + embeds: [infoEmbed("Already Verified", "You are already verified.")], flags: MessageFlags.Ephemeral }); } @@ -230,7 +261,8 @@ async function handleVerify(interaction, guild, client) { } await InteractionHelper.safeReply(interaction, { - embeds: [MessageTemplates.SUCCESS.ACTION_COMPLETE( + embeds: [successEmbed( + "Verification Complete", `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` )], flags: MessageFlags.Ephemeral @@ -249,7 +281,7 @@ async function handleRemove(interaction, guild, client) { if (!result.success) { if (result.notVerified) { return await InteractionHelper.safeReply(interaction, { - embeds: [MessageTemplates.INFO.NO_DATA("verified role")], + embeds: [infoEmbed("Not Verified", `${targetUser.tag} does not currently have the verified role.`)], flags: MessageFlags.Ephemeral }); } @@ -262,9 +294,7 @@ async function handleRemove(interaction, guild, client) { }); return await InteractionHelper.safeReply(interaction, { - embeds: [MessageTemplates.SUCCESS.OPERATION_COMPLETE( - `Verification removed from ${targetUser.tag}.` - )] + embeds: [successEmbed("Verification Removed", `Verification removed from ${targetUser.tag}.`)] }); } catch (error) { @@ -281,7 +311,7 @@ async function handleDisable(interaction, guild, client) { if (!guildConfig.verification?.enabled) { return await InteractionHelper.safeReply(interaction, { - embeds: [MessageTemplates.INFO.ALREADY_CONFIGURED("disabled")], + embeds: [infoEmbed("Already Disabled", "The verification system is already disabled.")], flags: MessageFlags.Ephemeral }); } @@ -306,20 +336,25 @@ async function handleDisable(interaction, guild, client) { await setGuildConfig(client, guild.id, guildConfig); await InteractionHelper.safeEditReply(interaction, { - embeds: [MessageTemplates.SUCCESS.OPERATION_COMPLETE( - "The verification system has been disabled and the verification message has been removed." - )] + embeds: [successEmbed("Verification Disabled", "The verification system has been disabled and the verification message has been removed.")] }); } async function handleStatus(interaction, guild, client) { const guildConfig = await getGuildConfig(client, guild.id); + const welcomeConfig = await getWelcomeConfig(client, guild.id); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + const conflictSummary = [ + autoVerifyEnabled ? 'AutoVerify is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean).join('\n'); if (!guildConfig.verification?.enabled) { return await InteractionHelper.safeReply(interaction, { embeds: [infoEmbed( "Verification Status", - "๐Ÿ”ด **Status:** Disabled\n\nThe verification system is not currently enabled on this server.\n\nUse `/verification setup` to enable it." + `๐Ÿ”ด **Status:** Disabled\n\nThe verification system is not currently enabled on this server.\n\nUse \`/verification setup\` to enable it.${conflictSummary ? `\n\nโš ๏ธ **Setup Blockers:**\n${conflictSummary}` : ''}` )], flags: MessageFlags.Ephemeral }); @@ -358,6 +393,11 @@ async function handleStatus(interaction, guild, client) { name: "๐Ÿ‘ฅ Verified Users", value: verifiedRole ? `${verifiedRole.members.size} users` : "Unknown", inline: true + }, + { + name: "โš ๏ธ Setup Conflicts", + value: conflictSummary || "None", + inline: false } ); diff --git a/src/commands/Welcome/autorole.js b/src/commands/Welcome/autorole.js index b87055443a..e0e4c7d569 100644 --- a/src/commands/Welcome/autorole.js +++ b/src/commands/Welcome/autorole.js @@ -4,6 +4,7 @@ import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; import { logger } from '../../utils/logger.js'; import { errorEmbed } from '../../utils/embeds.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; function createAutoroleInfoEmbed(description) { return new EmbedBuilder() @@ -61,6 +62,20 @@ const { options, guild, client } = interaction; if (subcommand === 'add') { const role = options.getRole('role'); + + const guildConfig = await getGuildConfig(client, guild.id); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + + if (verificationEnabled || autoVerifyEnabled) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed( + 'Setup Conflict', + 'You cannot add AutoRole while the verification system or AutoVerify is enabled. Disable those first.' + )], + flags: MessageFlags.Ephemeral + }); + } if (role.position >= guild.members.me.roles.highest.position) { logger.warn(`[Autorole] User ${interaction.user.tag} tried to add role ${role.name} (${role.id}) higher than bot's highest role in ${guild.name}`); @@ -151,6 +166,14 @@ const { options, guild, client } = interaction; else if (subcommand === 'list') { try { + const guildConfig = await getGuildConfig(client, guild.id); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const conflictSummary = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoVerifyEnabled ? 'AutoVerify is enabled' : null + ].filter(Boolean).join('\n'); + const config = await getWelcomeConfig(client, guild.id); const autoRoles = Array.isArray(config.roleIds) ? config.roleIds : []; @@ -164,7 +187,7 @@ const { options, guild, client } = interaction; if (singleRoleIds.length === 0) { return InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed('โ„น๏ธ No role is set to be auto-assigned.')], + embeds: [createAutoroleInfoEmbed(`โ„น๏ธ No role is set to be auto-assigned.${conflictSummary ? `\n\nโš ๏ธ Setup blockers:\n${conflictSummary}` : ''}`)], flags: MessageFlags.Ephemeral }); } @@ -192,7 +215,7 @@ const { options, guild, client } = interaction; if (validRoles.length === 0) { return InteractionHelper.safeEditReply(interaction, { - embeds: [createAutoroleInfoEmbed('โ„น๏ธ No valid auto-role found. Any invalid role has been removed.')], + embeds: [createAutoroleInfoEmbed(`โ„น๏ธ No valid auto-role found. Any invalid role has been removed.${conflictSummary ? `\n\nโš ๏ธ Setup blockers:\n${conflictSummary}` : ''}`)], flags: MessageFlags.Ephemeral }); } @@ -200,7 +223,7 @@ const { options, guild, client } = interaction; const embed = new EmbedBuilder() .setColor(getColor('info')) .setTitle('Auto-Assigned Role') - .setDescription(`${validRoles[0]}`) + .setDescription(`${validRoles[0]}${conflictSummary ? `\n\nโš ๏ธ Setup blockers:\n${conflictSummary}` : ''}`) .setFooter({ text: 'Only one auto-role can be configured.' }); await InteractionHelper.safeEditReply(interaction, { diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index ad7a5ab2b8..aee86a555e 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -101,7 +101,7 @@ export default { } } - if (config?.verification?.enabled) { + if (config?.verification?.enabled || config?.verification?.autoVerify?.enabled) { await handleVerification(member, guild, config.verification, member.client); } diff --git a/src/handlers/loggingButtons.js b/src/handlers/loggingButtons.js index 651a9499e6..2eef33ab83 100644 --- a/src/handlers/loggingButtons.js +++ b/src/handlers/loggingButtons.js @@ -1,15 +1,17 @@ -import { EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { PermissionFlagsBits } from 'discord.js'; import { toggleEventLogging, getLoggingStatus, - EVENT_TYPES + EVENT_TYPES, + setLoggingEnabled } from '../services/loggingService.js'; import { - createStatusIndicatorButtons, parseEventTypeFromButton } from '../utils/loggingUi.js'; -import { getGuildConfig } from '../services/guildConfig.js'; import { logger } from '../utils/logger.js'; +import { buildLoggingStatusView } from '../commands/Config/modules/config_logging_status.js'; + +const LOGGING_CATEGORIES = [...new Set(Object.values(EVENT_TYPES).map((eventType) => eventType.split('.')[0]))]; export default { customIds: ['logging_toggle', 'logging_refresh_status'], @@ -53,38 +55,39 @@ async function handleToggle(interaction) { } const status = await getLoggingStatus(interaction.client, interaction.guildId); + + if (eventType === 'audit_enabled') { + const newState = !Boolean(status.enabled); + await setLoggingEnabled(interaction.client, interaction.guildId, newState); + + const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + return interaction.update({ + embeds: [embed], + components + }); + } if (eventType === 'all') { const newState = !Object.values(status.enabledEvents).every(v => v !== false); const allTypes = Object.values(EVENT_TYPES); + const categoryTypes = LOGGING_CATEGORIES.map((category) => `${category}.*`); - await toggleEventLogging(interaction.client, interaction.guildId, allTypes, newState); - - await interaction.reply({ - content: `โœ… All logging has been **${newState ? 'enabled' : 'disabled'}**.`, - ephemeral: true - }); + await toggleEventLogging(interaction.client, interaction.guildId, [...allTypes, ...categoryTypes], newState); } else { const currentState = status.enabledEvents[eventType] !== false; const newState = !currentState; await toggleEventLogging(interaction.client, interaction.guildId, eventType, newState); - - const displayType = eventType.endsWith('.*') - ? `${eventType.replace('.*', '').toUpperCase()} (Category)` - : eventType - .split('.') - .map((part, idx) => idx === 0 ? part.toUpperCase() : part) - .join(' '); - - await interaction.reply({ - content: `โœ… ${displayType} logging has been **${newState ? 'enabled' : 'disabled'}**.`, - ephemeral: true - }); } + const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + await interaction.update({ + embeds: [embed], + components + }); + } catch (error) { logger.error('Error toggling logging:', error); await interaction.reply({ @@ -96,13 +99,7 @@ async function handleToggle(interaction) { async function handleRefresh(interaction) { try { - const status = await getLoggingStatus(interaction.client, interaction.guildId); - const config = await getGuildConfig(interaction.client, interaction.guildId); - - const embed = createLoggingStatusEmbed(interaction.guild, status, config); - const buttons = createStatusIndicatorButtons(status.enabledEvents); - - const components = buttons ? [buttons] : []; + const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); await interaction.update({ embeds: [embed], @@ -117,72 +114,3 @@ async function handleRefresh(interaction) { }); } } - - - - -function createLoggingStatusEmbed(guild, status, config) { - const embed = new EmbedBuilder() - .setTitle('๐Ÿ“‹ Logging Configuration') - .setColor(status.enabled ? 0x2ecc71 : 0xe74c3c) - .setDescription(`**Status:** ${status.enabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**'}`) - .setTimestamp() - .setFooter({ text: guild.name, iconURL: guild.iconURL() }); - - - if (status.channelId) { - const channel = guild.channels.cache.get(status.channelId); - embed.addFields({ - name: '๐Ÿ“ค Log Channel', - value: channel ? channel.toString() : `โš ๏ธ Channel ID: ${status.channelId}`, - inline: false - }); - } else if (config.logChannelId) { - const channel = guild.channels.cache.get(config.logChannelId); - embed.addFields({ - name: '๐Ÿ“ค Log Channel (Fallback)', - value: channel ? channel.toString() : `โš ๏ธ Channel ID: ${config.logChannelId}`, - inline: false - }); - } else { - embed.addFields({ - name: 'โš ๏ธ Log Channel', - value: 'Not configured', - inline: false - }); - } - - - const categories = { - 'moderation': '๐Ÿ”จ Moderation', - 'ticket': '๐ŸŽซ Tickets', - 'message': 'โŒ Messages', - 'role': '๐Ÿท๏ธ Roles', - 'member': '๐Ÿ‘‹ Join/Leave', - 'leveling': '๐Ÿ“ˆ Leveling', - 'reactionrole': '๐ŸŽญ Reaction Roles', - 'giveaway': '๐ŸŽ Giveaway', - 'counter': '๐Ÿ“Š Counter' - }; - - let categoryStatus = ''; - for (const [category, display] of Object.entries(categories)) { - const categoryEntries = Object.entries(status.enabledEvents) - .filter(([key]) => key.startsWith(category)); - const isEnabled = categoryEntries.length === 0 - ? true - : categoryEntries.some(([, value]) => value !== false); - - categoryStatus += `${isEnabled ? 'โœ…' : 'โŒ'} ${display}\n`; - } - - if (categoryStatus) { - embed.addFields({ - name: '๐Ÿ“Š Event Categories', - value: categoryStatus, - inline: false - }); - } - - return embed; -} diff --git a/src/handlers/verificationButtons.js b/src/handlers/verificationButtons.js index 464a4d1657..e00c36b59c 100644 --- a/src/handlers/verificationButtons.js +++ b/src/handlers/verificationButtons.js @@ -1,8 +1,9 @@ -import { ButtonInteraction, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, errorEmbed } from '../utils/embeds.js'; +import { MessageFlags } from 'discord.js'; +import { successEmbed, errorEmbed } from '../utils/embeds.js'; import { verifyUser } from '../services/verificationService.js'; import { handleInteractionError } from '../utils/errorHandler.js'; import { logger } from '../utils/logger.js'; +import { InteractionHelper } from '../utils/interactionHelper.js'; @@ -13,11 +14,11 @@ import { logger } from '../utils/logger.js'; export async function handleVerificationButton(interaction, client) { try { - + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!interaction.guild) { - return await interaction.reply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("Guild Only", "This button can only be used in a server.")], - flags: MessageFlags.Ephemeral }); } @@ -38,21 +39,19 @@ export async function handleVerificationButton(interaction, client) { if (!result.success) { if (result.alreadyVerified) { - return await interaction.reply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( "Already Verified", "You are already verified and have access to all server channels." )], - flags: MessageFlags.Ephemeral }); } - return await interaction.reply({ + return await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed( "Verification Failed", "An error occurred during verification. Please try again or contact an administrator." )], - flags: MessageFlags.Ephemeral }); } @@ -63,12 +62,11 @@ export async function handleVerificationButton(interaction, client) { roleName: result.roleName }); - await interaction.reply({ + await InteractionHelper.safeEditReply(interaction, { embeds: [successEmbed( "โœ… Verification Successful!", `You have been verified and given the **${result.roleName}** role!\n\nYou now have access to all server channels and features. Welcome! ๐ŸŽ‰` )], - flags: MessageFlags.Ephemeral }); } catch (error) { diff --git a/src/services/guildConfig.js b/src/services/guildConfig.js index 9b2a85e89b..9d772f0ce9 100644 --- a/src/services/guildConfig.js +++ b/src/services/guildConfig.js @@ -13,7 +13,7 @@ const GUILD_CONFIG_DEFAULTS = { dmOnClose: true, logIgnore: { users: [], channels: [] }, logging: { - enabled: true, + enabled: false, channelId: null, enabledEvents: {} } diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index 08aade4043..d51f84454d 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -258,6 +258,20 @@ export async function initializeJoinToCreate(client, guildId, channelId, options ); } + if (Array.isArray(config.triggerChannels) && config.triggerChannels.length > 0) { + throw new TitanBotError( + 'Guild already has a Join to Create trigger configured', + ErrorTypes.VALIDATION, + 'This server already has a Join to Create channel configured. Use `/jointocreate config` to modify it, or remove it before creating a new one.', + { + guildId, + existingTriggerChannelId: config.triggerChannels[0], + expected: true, + suppressErrorLog: true + } + ); + } + config.triggerChannels.push(channelId); config.enabled = true; diff --git a/src/services/loggingService.js b/src/services/loggingService.js index d1b22316b0..5244b7c7c1 100644 --- a/src/services/loggingService.js +++ b/src/services/loggingService.js @@ -348,7 +348,7 @@ export async function toggleEventLogging(client, guildId, eventTypes, enabled) { const { updateGuildConfig } = await import('./guildConfig.js'); const config = await getGuildConfig(client, guildId); - const logging = config.logging || { enabled: true, enabledEvents: {} }; + const logging = config.logging || { enabled: false, enabledEvents: {} }; const types = Array.isArray(eventTypes) ? eventTypes : [eventTypes]; types.forEach(type => { @@ -386,7 +386,7 @@ export async function setLoggingChannel(client, guildId, channelId) { const { updateGuildConfig } = await import('./guildConfig.js'); const config = await getGuildConfig(client, guildId); - const logging = config.logging || { enabled: true, enabledEvents: {} }; + const logging = config.logging || { enabled: false, enabledEvents: {} }; logging.channelId = channelId; logging.enabled = true; diff --git a/src/services/verificationService.js b/src/services/verificationService.js index 9c7ee58784..f16e185c47 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.js @@ -101,6 +101,16 @@ export async function verifyUser(client, guildId, userId, options = {}) { const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId); + const canAssignRole = await validateBotCanAssignRole(guild, verifiedRole.id); + if (!canAssignRole) { + throw createError( + 'Bot cannot assign verified role', + ErrorTypes.PERMISSION, + "I can't assign the verified role. Please check my **Manage Roles** permission and role hierarchy.", + { guildId, roleId: verifiedRole.id } + ); + } + if (member.roles.cache.has(verifiedRole.id)) { return { success: false, @@ -213,8 +223,21 @@ export async function autoVerifyOnJoin(client, guild, member, verificationConfig }; } + const autoVerifyRoleId = verificationConfig.autoVerify?.roleId || verificationConfig.roleId; + if (!autoVerifyRoleId) { + return { + autoVerified: false, + reason: 'auto_verify_role_not_configured' + }; + } + + const effectiveVerificationConfig = { + ...verificationConfig, + roleId: autoVerifyRoleId + }; + - await validateVerificationSetup(guild, verificationConfig); + await validateVerificationSetup(guild, effectiveVerificationConfig); const shouldVerify = evaluateAutoVerifyCriteria( @@ -231,7 +254,7 @@ export async function autoVerifyOnJoin(client, guild, member, verificationConfig } - const verifiedRole = guild.roles.cache.get(verificationConfig.roleId); + const verifiedRole = guild.roles.cache.get(autoVerifyRoleId); const canAssign = await validateBotCanAssignRole(guild, verifiedRole.id); @@ -359,6 +382,16 @@ export async function removeVerification(client, guildId, userId, options = {}) ); } + const canAssignRole = await validateBotCanAssignRole(guild, verifiedRole.id); + if (!canAssignRole) { + throw createError( + 'Bot cannot manage verified role', + ErrorTypes.PERMISSION, + "I can't remove the verified role right now. Please check my **Manage Roles** permission and role hierarchy.", + { guildId, roleId: verifiedRole.id } + ); + } + if (!member.roles.cache.has(verifiedRole.id)) { return { success: false, @@ -415,6 +448,16 @@ export async function removeVerification(client, guildId, userId, options = {}) export async function validateVerificationSetup(guild, verificationConfig) { + const botMember = guild.members.me; + if (!botMember) { + throw createError( + 'Bot member not available in guild cache', + ErrorTypes.CONFIGURATION, + "I couldn't verify my server permissions. Please try again.", + { guildId: guild.id } + ); + } + const verifiedRole = guild.roles.cache.get(verificationConfig.roleId); if (!verifiedRole) { @@ -439,8 +482,8 @@ export async function validateVerificationSetup(guild, verificationConfig) { } - const botPerms = channel.permissionsFor(guild.members.me); - const requiredPerms = ['SendMessages', 'EmbedLinks']; + const botPerms = channel.permissionsFor(botMember); + const requiredPerms = ['ViewChannel', 'SendMessages', 'EmbedLinks']; const missingPerms = requiredPerms.filter(perm => !botPerms.has(perm)); if (missingPerms.length > 0) { @@ -475,6 +518,13 @@ export async function validateBotCanAssignRole(guild, roleId) { } const botMember = guild.members.me; + if (!botMember) { + logger.warn('Cannot assign role - bot member not found in guild cache', { + guildId: guild.id, + roleId + }); + return false; + } if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { diff --git a/src/utils/constants.js b/src/utils/constants.js index 4efc9350bf..65eefb58b2 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -45,7 +45,7 @@ export const DEFAULT_GUILD_CONFIG = { welcomeChannel: null, autoRole: null, logging: { - enabled: true, + enabled: false, enabledEvents: {} }, verification: { diff --git a/src/utils/database.js b/src/utils/database.js index b6efdead09..800d5d6239 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -1040,7 +1040,9 @@ minAccountAge: 0, cooldown: 7, allowMultipleApplications: false, requireVerification: false, - customWelcomeMessage: "" + customWelcomeMessage: "", + pendingApplicationRetentionDays: 30, + reviewedApplicationRetentionDays: 14 }; return { ...defaultSettings, ...unwrapped }; @@ -1068,11 +1070,120 @@ cooldown: 7, cooldown: 7, allowMultipleApplications: false, requireVerification: false, - customWelcomeMessage: "" + customWelcomeMessage: "", + pendingApplicationRetentionDays: 30, + reviewedApplicationRetentionDays: 14 }; } } +function getApplicationRetentionDays(settings = {}) { + const pendingRaw = Number(settings.pendingApplicationRetentionDays); + const reviewedRaw = Number(settings.reviewedApplicationRetentionDays); + + const pendingDays = Number.isFinite(pendingRaw) ? Math.min(Math.max(pendingRaw, 1), 3650) : 30; + const reviewedDays = Number.isFinite(reviewedRaw) ? Math.min(Math.max(reviewedRaw, 1), 3650) : 14; + + return { pendingDays, reviewedDays }; +} + +function isApplicationExpired(application, retentionDays, now = Date.now()) { + if (!application || typeof application !== 'object') { + return false; + } + + const createdAt = Number(application.createdAt) || now; + const updatedAt = Number(application.updatedAt) || createdAt; + const reviewedAt = application.reviewedAt ? Number(new Date(application.reviewedAt)) : null; + const status = typeof application.status === 'string' ? application.status.toLowerCase() : 'pending'; + + const ageMsFromCreated = now - createdAt; + const ageMsFromReviewed = now - (reviewedAt || updatedAt || createdAt); + const pendingRetentionMs = retentionDays.pendingDays * 24 * 60 * 60 * 1000; + const reviewedRetentionMs = retentionDays.reviewedDays * 24 * 60 * 60 * 1000; + + if (status === 'pending') { + return ageMsFromCreated > pendingRetentionMs; + } + + if (status === 'approved' || status === 'denied') { + return ageMsFromReviewed > reviewedRetentionMs; + } + + return ageMsFromCreated > pendingRetentionMs; +} + +export async function deleteApplication(client, guildId, applicationId, userIdHint = null) { + const key = getApplicationKey(guildId, applicationId); + + try { + const existing = unwrapReplitData(await client.db.get(key, null)); + const userId = userIdHint || existing?.userId || null; + + await client.db.delete(key); + + if (userId) { + const userKey = getUserApplicationsKey(guildId, userId); + const userApplications = await client.db.get(userKey, []); + const unwrapped = unwrapReplitData(userApplications); + const ids = Array.isArray(unwrapped) ? unwrapped : []; + const filtered = ids.filter(id => id !== applicationId); + await client.db.set(userKey, filtered); + } + + return true; + } catch (error) { + console.error(`Error deleting application ${applicationId} in guild ${guildId}:`, error); + return false; + } +} + +export async function cleanupExpiredApplications(client, guildId) { + try { + if (!client.db || typeof client.db.list !== 'function') { + return { removed: 0, scanned: 0 }; + } + + const settings = await getApplicationSettings(client, guildId); + const retentionDays = getApplicationRetentionDays(settings); + const prefix = `guild:${guildId}:applications:`; + let keys = await client.db.list(prefix); + + if (!Array.isArray(keys)) { + if (typeof keys === 'object' && keys !== null) { + keys = Object.keys(keys).filter(key => key.startsWith(prefix)); + } else { + return { removed: 0, scanned: 0 }; + } + } + + const applicationKeyPattern = new RegExp(`^guild:${guildId}:applications:[^:]+$`); + const applicationKeys = keys.filter(key => applicationKeyPattern.test(key)); + + const now = Date.now(); + let removed = 0; + + for (const key of applicationKeys) { + const app = unwrapReplitData(await client.db.get(key, null)); + if (!app) { + continue; + } + + if (isApplicationExpired(app, retentionDays, now)) { + const deleted = await deleteApplication(client, guildId, app.id, app.userId); + if (deleted) { + removed += 1; + } + } + } + + return { removed, scanned: applicationKeys.length }; + } catch (error) { + console.error(`Error cleaning expired applications for guild ${guildId}:`, error); + return { removed: 0, scanned: 0 }; + } +} + @@ -1153,6 +1264,7 @@ status: 'pending', export async function getApplication(client, guildId, applicationId) { const key = getApplicationKey(guildId, applicationId); try { + await cleanupExpiredApplications(client, guildId); const application = await client.db.get(key, null); return unwrapReplitData(application); } catch (error) { @@ -1206,6 +1318,8 @@ export async function getUserApplications(client, guildId, userId) { return []; } + await cleanupExpiredApplications(client, guildId); + const applicationIds = await client.db.get(userKey, []); const unwrappedIds = unwrapReplitData(applicationIds); @@ -1248,6 +1362,8 @@ export async function getApplications(client, guildId, filters = {}) { return []; } + await cleanupExpiredApplications(client, guildId); + const prefix = `guild:${guildId}:applications:`; let keys = await client.db.list(prefix); @@ -1260,7 +1376,8 @@ export async function getApplications(client, guildId, filters = {}) { } } - const applicationKeys = keys.filter(key => !key.endsWith('settings') && key.includes('applications:')); + const applicationKeyPattern = new RegExp(`^guild:${guildId}:applications:[^:]+$`); + const applicationKeys = keys.filter(key => applicationKeyPattern.test(key)); const applicationPromises = applicationKeys.map(key => client.db.get(key)); let applications = (await Promise.all(applicationPromises)) diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js index ffc7eef96f..c80d7b9492 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.js @@ -185,6 +185,7 @@ export async function handleInteractionError(interaction, error, context = {}) { ErrorTypes.USER_INPUT, ErrorTypes.PERMISSION ].includes(errorType); + const isExpectedError = Boolean(error?.context?.expected === true || error?.context?.suppressErrorLog === true); const logData = { error: error.message, @@ -200,7 +201,7 @@ export async function handleInteractionError(interaction, error, context = {}) { context }; - if (isUserError) { + if (isUserError || isExpectedError) { if (errorType !== ErrorTypes.RATE_LIMIT) { logger.debug(`User Error [${errorType.toUpperCase()}]: ${error.message}`, logData); } diff --git a/src/utils/loggingUi.js b/src/utils/loggingUi.js index 02aa9e022c..d460194071 100644 --- a/src/utils/loggingUi.js +++ b/src/utils/loggingUi.js @@ -1,6 +1,15 @@ import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; import { EVENT_TYPES } from '../services/loggingService.js'; +const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { + const [category] = eventType.split('.'); + if (!accumulator[category]) { + accumulator[category] = []; + } + accumulator[category].push(eventType); + return accumulator; +}, {}); + @@ -81,16 +90,17 @@ export function getButtonStatusStyle(isEnabled) { -export function createStatusIndicatorButtons(enabledEvents) { +export function createStatusIndicatorButtons(enabledEvents = {}, loggingEnabled = false) { const eventCategories = ['moderation', 'ticket', 'message', 'role', 'member', 'leveling', 'reactionrole', 'giveaway', 'counter']; const buttons = []; for (const category of eventCategories) { - const categoryEntries = Object.entries(enabledEvents) - .filter(([key]) => key.startsWith(category)); - const isEnabled = categoryEntries.length === 0 + const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; + const categoryWildcardDisabled = enabledEvents[`${category}.*`] === false; + const categoryEventsEnabled = categoryEvents.length === 0 ? true - : categoryEntries.some(([, value]) => value !== false); + : categoryEvents.every((eventType) => enabledEvents[eventType] !== false); + const isEnabled = loggingEnabled && !categoryWildcardDisabled && categoryEventsEnabled; const emoji = { 'moderation': '๐Ÿ”จ', @@ -126,6 +136,27 @@ export function createStatusIndicatorButtons(enabledEvents) { return rows; } +export function createLoggingStatusComponents(enabledEvents, loggingEnabled = false) { + const categoryRows = createStatusIndicatorButtons(enabledEvents, loggingEnabled); + + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('logging_toggle:audit_enabled') + .setLabel(loggingEnabled ? '๐Ÿงพ Audit: ON' : '๐Ÿงพ Audit: OFF') + .setStyle(loggingEnabled ? ButtonStyle.Success : ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('logging_toggle:all') + .setLabel('Toggle Categories') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('logging_refresh_status') + .setLabel('๐Ÿ”„ Refresh') + .setStyle(ButtonStyle.Primary) + ); + + return [...categoryRows, actionRow]; +} + diff --git a/src/utils/schemas.js b/src/utils/schemas.js index fc69cf5021..3acb11f553 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.js @@ -9,11 +9,11 @@ const LogIgnoreSchema = z const LoggingConfigSchema = z .object({ - enabled: z.boolean().default(true), + enabled: z.boolean().default(false), channelId: z.string().nullable().optional(), enabledEvents: z.record(z.boolean()).default({}) }) - .default({ enabled: true, enabledEvents: {} }); + .default({ enabled: false, enabledEvents: {} }); const TicketLoggingSchema = z .object({ @@ -26,7 +26,8 @@ const AutoVerifyConfigSchema = z .object({ enabled: z.boolean().default(false), criteria: z.enum(['account_age', 'server_size', 'none']).default('none'), - accountAgeDays: z.number().int().min(1).max(365).nullable().optional() + accountAgeDays: z.number().int().min(1).max(365).nullable().optional(), + roleId: z.string().nullable().optional() }) .optional(); From f7dbc7cde506a597f31344b2e92f1a6b855df9fb Mon Sep 17 00:00:00 2001 From: codebymitch Date: Wed, 4 Mar 2026 22:54:54 +1100 Subject: [PATCH 05/10] Add DB backup/restore, migration guards, and CI workflows --- .env.example | 63 ++- .github/workflows/migration-version-check.yml | 58 +++ .github/workflows/restore-drill.yml | 59 +++ .gitignore | 4 + README.md | 11 + package.json | 8 +- scripts/backup.js | 150 +++++++ scripts/migrate.js | 144 ++++++- scripts/restore-drill.js | 208 +++++++++ scripts/restore.js | 137 ++++++ src/app.js | 13 +- src/commands/Community/app-admin.js | 10 +- src/commands/Config/config.js | 3 +- .../Config/modules/config_birthday_toggle.js | 3 +- .../Config/modules/config_logging_filter.js | 3 +- .../modules/config_logging_setchannel.js | 3 +- .../Config/modules/config_logging_status.js | 3 +- .../Config/modules/config_premium_setrole.js | 3 +- .../modules/config_reports_setchannel.js | 3 +- src/commands/Core/ping.js | 4 +- src/commands/Core/stats.js | 3 +- src/commands/Core/support.js | 5 +- src/commands/Core/uptime.js | 5 +- src/commands/Economy/pay.js | 2 +- src/commands/Economy/shop.js | 3 +- src/commands/Tools/calculate.js | 29 +- src/config/bot.js | 68 +-- src/config/postgres.js | 85 ++-- src/config/schemaVersion.js | 7 + src/events/interactionCreate.js | 397 ++++++++++-------- src/handlers/calculateButtonLoader.js | 11 - src/handlers/calculateButtons.js | 27 +- src/handlers/calculateModalLoader.js | 11 - src/handlers/calculateModals.js | 27 +- src/handlers/countdownButtonLoader.js | 13 - src/handlers/counterButtonLoader.js | 11 - src/handlers/giveawayButtonLoader.js | 23 - src/handlers/helpButtonLoader.js | 25 -- src/handlers/helpButtons.js | 89 ++-- src/handlers/helpSelectMenuLoader.js | 20 - src/handlers/helpSelectMenus.js | 22 +- .../goodbyeConfigButton.js | 59 +++ .../interactionHandlers/goodbyeConfigModal.js | 119 ++++++ .../reactionRolesSelectMenu.js | 205 +++++++++ .../welcomeConfigButton.js | 59 +++ .../interactionHandlers/welcomeConfigModal.js | 104 +++++ src/handlers/interactions.js | 26 +- src/handlers/loggingButtonLoader.js | 12 - src/handlers/ticketButtonLoader.js | 34 -- src/handlers/todoButtonLoader.js | 21 - src/handlers/verificationButtonLoader.js | 20 - src/handlers/wipedataButtonLoader.js | 19 - src/interactions/buttons/countdown.js | 12 + src/interactions/buttons/counterDelete.js | 3 + src/interactions/buttons/giveaway.js | 20 + src/interactions/buttons/goodbyeConfig.js | 58 +-- src/interactions/buttons/help.js | 19 + src/interactions/buttons/logging.js | 12 + src/interactions/buttons/ticket.js | 20 + src/interactions/buttons/todoShared.js | 10 + src/interactions/buttons/verification.js | 6 + src/interactions/buttons/welcomeConfig.js | 58 +-- src/interactions/buttons/wipedata.js | 6 + src/interactions/modals/calculate.js | 10 + src/interactions/modals/goodbyeConfigModal.js | 118 +----- src/interactions/modals/ticket.js | 6 + src/interactions/modals/todoShared.js | 11 + src/interactions/modals/welcomeConfigModal.js | 103 +---- src/interactions/selectMenus/helpCategory.js | 3 + .../selectMenus/reaction_roles.js | 226 +--------- src/services/birthdayService.js | 4 +- src/services/configService.js | 46 ++ src/services/database.js | 345 +-------------- src/services/economy.js | 2 +- src/services/economyService.js | 8 + src/services/guildConfig.js | 62 ++- src/services/ticket.js | 148 ++++++- src/services/verificationService.js | 41 +- src/utils/abuseProtection.js | 197 +++++++++ src/utils/commandInputValidation.js | 50 +++ src/utils/database.js | 255 ++++++++--- src/utils/errorHandler.js | 59 ++- src/utils/errorRegistry.js | 131 ++++++ src/utils/logger.js | 86 +++- src/utils/postgresDatabase.js | 141 ++++++- src/utils/safeMathParser.js | 293 +++++++++++++ src/utils/schemas.js | 28 +- src/utils/serviceErrorBoundary.js | 121 ++++++ src/utils/sqlIdentifiers.js | 21 + src/utils/ticketPermissions.js | 2 +- src/utils/traceContext.js | 45 ++ tests/failure-paths/abuseProtection.test.js | 100 +++++ tests/failure-paths/database.failure.test.js | 45 ++ .../errorHandler.failure.test.js | 101 +++++ tests/failure-paths/safeMathParser.test.js | 33 ++ .../zodValidationCoverage.test.js | 72 ++++ 96 files changed, 3886 insertions(+), 1702 deletions(-) create mode 100644 .github/workflows/migration-version-check.yml create mode 100644 .github/workflows/restore-drill.yml create mode 100644 scripts/backup.js create mode 100644 scripts/restore-drill.js create mode 100644 scripts/restore.js create mode 100644 src/config/schemaVersion.js delete mode 100644 src/handlers/calculateButtonLoader.js delete mode 100644 src/handlers/calculateModalLoader.js delete mode 100644 src/handlers/countdownButtonLoader.js delete mode 100644 src/handlers/counterButtonLoader.js delete mode 100644 src/handlers/giveawayButtonLoader.js delete mode 100644 src/handlers/helpButtonLoader.js delete mode 100644 src/handlers/helpSelectMenuLoader.js create mode 100644 src/handlers/interactionHandlers/goodbyeConfigButton.js create mode 100644 src/handlers/interactionHandlers/goodbyeConfigModal.js create mode 100644 src/handlers/interactionHandlers/reactionRolesSelectMenu.js create mode 100644 src/handlers/interactionHandlers/welcomeConfigButton.js create mode 100644 src/handlers/interactionHandlers/welcomeConfigModal.js delete mode 100644 src/handlers/loggingButtonLoader.js delete mode 100644 src/handlers/ticketButtonLoader.js delete mode 100644 src/handlers/todoButtonLoader.js delete mode 100644 src/handlers/verificationButtonLoader.js delete mode 100644 src/handlers/wipedataButtonLoader.js create mode 100644 src/interactions/buttons/countdown.js create mode 100644 src/interactions/buttons/counterDelete.js create mode 100644 src/interactions/buttons/giveaway.js create mode 100644 src/interactions/buttons/help.js create mode 100644 src/interactions/buttons/logging.js create mode 100644 src/interactions/buttons/ticket.js create mode 100644 src/interactions/buttons/todoShared.js create mode 100644 src/interactions/buttons/verification.js create mode 100644 src/interactions/buttons/wipedata.js create mode 100644 src/interactions/modals/calculate.js create mode 100644 src/interactions/modals/ticket.js create mode 100644 src/interactions/modals/todoShared.js create mode 100644 src/interactions/selectMenus/helpCategory.js create mode 100644 src/utils/abuseProtection.js create mode 100644 src/utils/commandInputValidation.js create mode 100644 src/utils/errorRegistry.js create mode 100644 src/utils/safeMathParser.js create mode 100644 src/utils/serviceErrorBoundary.js create mode 100644 src/utils/sqlIdentifiers.js create mode 100644 src/utils/traceContext.js create mode 100644 tests/failure-paths/abuseProtection.test.js create mode 100644 tests/failure-paths/database.failure.test.js create mode 100644 tests/failure-paths/errorHandler.failure.test.js create mode 100644 tests/failure-paths/safeMathParser.test.js create mode 100644 tests/failure-paths/zodValidationCoverage.test.js diff --git a/.env.example b/.env.example index 700de52f41..a2f04d1b2e 100644 --- a/.env.example +++ b/.env.example @@ -1,53 +1,50 @@ -# Copy this file to .env and update with your actual values +# Copy this file to .env and replace placeholder values. # Discord Bot Configuration DISCORD_TOKEN=your_discord_bot_token_here CLIENT_ID=your_discord_client_id_here GUILD_ID=your_discord_guild_id_here +OWNER_IDS= + +# Bot Runtime Configuration +NODE_ENV=production +LOG_LEVEL=warn +LOG_TO_FILE=false +SENTRY_DSN= + +# Web/API Configuration +PORT=3000 +WEB_HOST=0.0.0.0 +PORT_RETRY_ATTEMPTS=5 +CORS_ORIGIN=* # PostgreSQL Configuration (Primary Database) -POSTGRES_URL=postgresql://titanbot:yourpassword@localhost:5432/titanbot +POSTGRES_URL=postgresql://postgres:yourpassword@localhost:5432/titanbot POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_DB=titanbot -POSTGRES_USER=titanbot +POSTGRES_USER=postgres POSTGRES_PASSWORD=yourpassword -POSTGRES_SSL=false - -# Migration Settings -AUTO_MIGRATE=false - -# Bot Configuration - -# NODE_ENV options: -# - development: verbose/dev-friendly behavior -# - production: optimized/safe production behavior -# - test: optional for automated tests -# Note: this bot treats any value other than "production" as non-production behavior. -NODE_ENV=production - -# LOG_LEVEL options (Winston levels): -# error, warn, info, http, verbose, debug, silly -# Accepted aliases in this bot: warns, warning, warnings -> warn -# In production with LOG_LEVEL=warn, startup/shutdown status is still shown for non-technical operators. -LOG_LEVEL=warn - -# Production Configuration -# Set NODE_ENV=production for production deployment -# Set LOG_LEVEL=warn for production (only warnings and errors in console) -# Optional: Additional PostgreSQL Settings (Production-Ready) +# PostgreSQL Pool/Timeout Settings POSTGRES_MAX_CONNECTIONS=20 POSTGRES_MIN_CONNECTIONS=2 POSTGRES_CONNECTION_TIMEOUT=10000 POSTGRES_IDLE_TIMEOUT=30000 - -# Retry and backoff configuration POSTGRES_RETRIES=3 POSTGRES_BACKOFF_BASE=100 POSTGRES_BACKOFF_MULTIPLIER=2 -# Production settings (applied automatically with NODE_ENV=production) -# statement_timeout: Prevent long-running queries from blocking the bot (30s in production, unlimited in development) -# keepalives: Enable TCP keepalives for cloud-hosted databases -# keepalives_idle: Seconds before sending keepalive probe to server (30s recommended) +# Migration & Schema Settings +AUTO_MIGRATE=true +POSTGRES_MIGRATION_TABLE=schema_migrations +SCHEMA_VERSION=1 +SCHEMA_VERSION_LABEL=baseline-v1 + +# Backup/Restore Script Settings +BACKUP_DIR=./backups +BACKUP_RETENTION_DAYS=14 +POSTGRES_RESTORE_URL= + +# Optional Feature/API Keys +TMDB_API_KEY= diff --git a/.github/workflows/migration-version-check.yml b/.github/workflows/migration-version-check.yml new file mode 100644 index 0000000000..971b9750b7 --- /dev/null +++ b/.github/workflows/migration-version-check.yml @@ -0,0 +1,58 @@ +name: Migration Version Check + +on: + pull_request: + push: + branches: + - main + - master + - mitchwork + +jobs: + migration-check: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: titanbot + POSTGRES_PASSWORD: titanbot + POSTGRES_DB: titanbot + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U titanbot -d titanbot" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_URL: postgresql://titanbot:titanbot@localhost:5432/titanbot + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_DB: titanbot + POSTGRES_USER: titanbot + POSTGRES_PASSWORD: titanbot + POSTGRES_SSL: "false" + SCHEMA_VERSION: "1" + SCHEMA_VERSION_LABEL: "baseline-v1" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Apply migrations + run: npm run migrate + + - name: Validate migration version + run: npm run migrate:check diff --git a/.github/workflows/restore-drill.yml b/.github/workflows/restore-drill.yml new file mode 100644 index 0000000000..325045f7b3 --- /dev/null +++ b/.github/workflows/restore-drill.yml @@ -0,0 +1,59 @@ +name: Restore Drill + +on: + schedule: + - cron: '0 4 * * 1' + workflow_dispatch: + +jobs: + restore-drill: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: titanbot + POSTGRES_PASSWORD: titanbot + POSTGRES_DB: titanbot + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U titanbot -d titanbot" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + POSTGRES_URL: postgresql://titanbot:titanbot@localhost:5432/titanbot + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + POSTGRES_DB: titanbot + POSTGRES_USER: titanbot + POSTGRES_PASSWORD: titanbot + POSTGRES_SSL: "false" + SCHEMA_VERSION: "1" + SCHEMA_VERSION_LABEL: "baseline-v1" + BACKUP_RETENTION_DAYS: "7" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install PostgreSQL client tools + run: sudo apt-get update ; sudo apt-get install -y postgresql-client + + - name: Install dependencies + run: npm ci + + - name: Apply migrations + run: npm run migrate + + - name: Run restore drill + run: npm run backup:drill diff --git a/.gitignore b/.gitignore index 55ba236f60..23b71db818 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,7 @@ dist # Replit debugger .breakpoints .git.backup.* + +# Database backup artifacts +backups/ +*.dump diff --git a/README.md b/README.md index 75f8621c7e..b6c6fffdf0 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,14 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: POSTGRES_USER=titanbot POSTGRES_PASSWORD=yourpassword POSTGRES_SSL=false + # Optional trusted CA for TLS validation (recommended in production) + # POSTGRES_CA_CERT="-----BEGIN CERTIFICATE-----...-----END CERTIFICATE-----" + # POSTGRES_CA_CERT_PATH=/path/to/postgres-ca.pem # Migration Settings AUTO_MIGRATE=false + SCHEMA_VERSION=1 + SCHEMA_VERSION_LABEL=baseline-v1 # Bot Configuration NODE_ENV=development @@ -140,6 +145,7 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: Production note: - `NODE_ENV=production` + - `POSTGRES_SSL=true` (required in production; startup fails closed if disabled) - `LOG_LEVEL=warn` for a clean production console (critical issues + startup status) - `LOG_LEVEL=info` if you want more detailed operational logs - If your chosen `PORT` is already used, TitanBot automatically tries the next port(s) @@ -179,6 +185,11 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: npm start ``` +### Migration Version Guard +- `npm run migrate` applies schema setup and records the expected schema version. +- `npm run migrate:check` fails if the database schema version does not match the code's expected version. +- `npm run migrate:status` prints current vs expected schema version metadata. + ## ๐Ÿ—„๏ธ Database System TitanBot uses **PostgreSQL** as its primary database with intelligent fallback to memory storage: diff --git a/package.json b/package.json index 6ea53831a7..720a37903b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,13 @@ "type": "module", "scripts": { "start": "node src/app.js", - "migrate": "node scripts/migrate.js" + "test": "node --test tests/**/*.test.js", + "migrate": "node scripts/migrate.js apply", + "migrate:check": "node scripts/migrate.js check", + "migrate:status": "node scripts/migrate.js status", + "backup:db": "node scripts/backup.js", + "restore:db": "node scripts/restore.js", + "backup:drill": "node scripts/restore-drill.js" }, "dependencies": { "axios": "^1.13.2", diff --git a/scripts/backup.js b/scripts/backup.js new file mode 100644 index 0000000000..584b40a13d --- /dev/null +++ b/scripts/backup.js @@ -0,0 +1,150 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, readdir, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import { logger } from '../src/utils/logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith('--')) { + continue; + } + + const [rawKey, inlineValue] = token.slice(2).split('='); + if (typeof inlineValue !== 'undefined') { + args[rawKey] = inlineValue; + continue; + } + + const nextToken = argv[index + 1]; + if (!nextToken || nextToken.startsWith('--')) { + args[rawKey] = true; + continue; + } + + args[rawKey] = nextToken; + index += 1; + } + + return args; +} + +function assertEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function ensureCommand(command) { + const result = spawnSync(command, ['--version'], { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`${command} is required but was not found in PATH.`); + } +} + +function createTimestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}-${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`; +} + +async function pruneBackups(backupDir, retentionDays) { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) { + return; + } + + const entries = await readdir(backupDir, { withFileTypes: true }); + const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); + let removed = 0; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.dump')) { + continue; + } + + const fullPath = path.join(backupDir, entry.name); + const fileStats = await stat(fullPath); + if (fileStats.mtimeMs < cutoff) { + await unlink(fullPath); + removed += 1; + } + } + + if (removed > 0) { + logger.info(`Pruned ${removed} old backup file(s)`, { + event: 'backup.prune.completed', + removed, + retentionDays + }); + } +} + +async function run() { + const args = parseArgs(process.argv.slice(2)); + const databaseUrl = assertEnv('POSTGRES_URL'); + const retentionDays = Number.parseInt(args['retention-days'] || process.env.BACKUP_RETENTION_DAYS || '14', 10); + const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); + + ensureCommand('pg_dump'); + await mkdir(backupDir, { recursive: true }); + + const outputPath = args.output + ? path.resolve(args.output) + : path.join(backupDir, `titanbot-backup-${createTimestamp()}.dump`); + + const dumpArgs = [ + '--format=custom', + '--no-owner', + '--no-privileges', + '--file', + outputPath, + databaseUrl + ]; + + logger.info('Starting PostgreSQL backup', { + event: 'backup.start', + outputPath + }); + + const result = spawnSync('pg_dump', dumpArgs, { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`pg_dump failed: ${result.stderr || result.stdout || 'Unknown error'}`); + } + + await pruneBackups(backupDir, retentionDays); + + logger.info('PostgreSQL backup completed', { + event: 'backup.completed', + outputPath, + retentionDays + }); + + process.stdout.write(`${outputPath}\n`); +} + +run().catch((error) => { + logger.error('Backup command failed', { + event: 'backup.failed', + error: error.message + }); + process.exit(1); +}); diff --git a/scripts/migrate.js b/scripts/migrate.js index d0ef80020e..86742a9ebf 100644 --- a/scripts/migrate.js +++ b/scripts/migrate.js @@ -2,6 +2,8 @@ import pg from 'pg'; import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; +import { logger } from '../src/utils/logger.js'; +import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from '../src/config/schemaVersion.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); dotenv.config({ path: path.join(__dirname, '..', '.env') }); @@ -10,11 +12,52 @@ const { Pool } = pg; const pool = new Pool({ connectionString: process.env.POSTGRES_URL, - ssl: process.env.POSTGRES_SSL === 'true' ? { rejectUnauthorized: false } : false, + ssl: false, }); +const migrationTable = process.env.POSTGRES_MIGRATION_TABLE || 'schema_migrations'; +const migrationTablePattern = /^[a-z_][a-z0-9_]*$/; + +if (!migrationTablePattern.test(migrationTable)) { + throw new Error(`Invalid migration table name: ${migrationTable}`); +} + +const ensureMigrationLedger = async (client) => { + await client.query(` + CREATE TABLE IF NOT EXISTS ${migrationTable} ( + version INTEGER PRIMARY KEY, + label VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); +}; + +const recordSchemaVersion = async (client) => { + await ensureMigrationLedger(client); + await client.query( + `INSERT INTO ${migrationTable} (version, label) + VALUES ($1, $2) + ON CONFLICT (version) + DO UPDATE SET label = EXCLUDED.label, applied_at = CURRENT_TIMESTAMP`, + [EXPECTED_SCHEMA_VERSION, EXPECTED_SCHEMA_LABEL] + ); +}; + +const getCurrentSchemaVersion = async (client) => { + await ensureMigrationLedger(client); + const result = await client.query( + `SELECT version, label, applied_at FROM ${migrationTable} ORDER BY version DESC LIMIT 1` + ); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0]; +}; + const createTables = async (client) => { - console.log('๐Ÿ“Š Creating database tables...'); + logger.info('๐Ÿ“Š Creating database tables...'); const tables = [ @@ -99,11 +142,11 @@ const createTables = async (client) => { `CREATE TABLE IF NOT EXISTS giveaway_entries ( id SERIAL PRIMARY KEY, - giveaway_id VARCHAR(255) NOT NULL, + giveaway_id INTEGER NOT NULL, user_id VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(giveaway_id, user_id), - FOREIGN KEY(giveaway_id) REFERENCES giveaways(giveaway_id) ON DELETE CASCADE + FOREIGN KEY(giveaway_id) REFERENCES giveaways(id) ON DELETE CASCADE )`, @@ -157,16 +200,16 @@ const createTables = async (client) => { try { await client.query(table); } catch (error) { - console.error('โŒ Error creating table:', error.message); + logger.error(`โŒ Error creating table: ${error.message}`); throw error; } } - console.log('โœ… All tables created successfully'); + logger.info('โœ… All tables created successfully'); }; const createIndexes = async (client) => { - console.log('๐Ÿ“ˆ Creating indexes...'); + logger.info('๐Ÿ“ˆ Creating indexes...'); const indexes = [ 'CREATE INDEX IF NOT EXISTS idx_user_levels_guild ON user_levels(guild_id)', @@ -180,16 +223,16 @@ const createIndexes = async (client) => { try { await client.query(index); } catch (error) { - console.error('โŒ Error creating index:', error.message); + logger.error(`โŒ Error creating index: ${error.message}`); throw error; } } - console.log('โœ… All indexes created successfully'); + logger.info('โœ… All indexes created successfully'); }; const createTriggers = async (client) => { - console.log('โฐ Setting up automatic timestamps...'); + logger.info('โฐ Setting up automatic timestamps...'); const triggers = [ { @@ -252,28 +295,30 @@ const createTriggers = async (client) => { EXECUTE FUNCTION update_timestamp_${table}(); `); } catch (error) { - console.error(`โŒ Error creating trigger for ${table}:`, error.message); + logger.error(`โŒ Error creating trigger for ${table}: ${error.message}`); throw error; } } - console.log('โœ… All triggers created successfully'); + logger.info('โœ… All triggers created successfully'); }; const migrate = async () => { const client = await pool.connect(); try { - console.log('๐Ÿš€ Starting database migration...\n'); + logger.info('๐Ÿš€ Starting database migration...'); await createTables(client); await createIndexes(client); await createTriggers(client); + await recordSchemaVersion(client); - console.log('\nโœจ Migration completed successfully!'); - console.log('๐Ÿ“š Your database is now ready for TitanBot.\n'); + logger.info('โœจ Migration completed successfully!'); + logger.info(`๐Ÿ“Œ Schema version recorded: v${EXPECTED_SCHEMA_VERSION} (${EXPECTED_SCHEMA_LABEL})`); + logger.info('๐Ÿ“š Your database is now ready for TitanBot.'); } catch (error) { - console.error('\nโŒ Migration failed:', error); + logger.error('โŒ Migration failed:', error); process.exit(1); } finally { client.release(); @@ -281,4 +326,69 @@ const migrate = async () => { } }; -migrate(); +const checkMigrationVersion = async () => { + const client = await pool.connect(); + + try { + const current = await getCurrentSchemaVersion(client); + + if (!current) { + logger.error(`โŒ No schema version found in ${migrationTable}. Expected v${EXPECTED_SCHEMA_VERSION}.`); + process.exit(1); + } + + const currentVersion = Number(current.version); + if (currentVersion !== EXPECTED_SCHEMA_VERSION) { + logger.error( + `โŒ Schema drift detected. Expected v${EXPECTED_SCHEMA_VERSION}, found v${currentVersion}.` + ); + process.exit(1); + } + + logger.info( + `โœ… Schema version check passed (v${currentVersion}, label: ${current.label}).` + ); + } catch (error) { + logger.error('โŒ Migration check failed:', error); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +}; + +const printMigrationStatus = async () => { + const client = await pool.connect(); + + try { + const current = await getCurrentSchemaVersion(client); + if (!current) { + logger.info(`โ„น๏ธ No schema version recorded yet. Expected v${EXPECTED_SCHEMA_VERSION}.`); + return; + } + + logger.info(`๐Ÿ“Œ Current schema version: v${current.version}`); + logger.info(`๐Ÿท๏ธ Label: ${current.label}`); + logger.info(`๐Ÿ•’ Applied at: ${current.applied_at}`); + logger.info(`๐ŸŽฏ Expected: v${EXPECTED_SCHEMA_VERSION} (${EXPECTED_SCHEMA_LABEL})`); + } catch (error) { + logger.error('โŒ Migration status failed:', error); + process.exit(1); + } finally { + client.release(); + await pool.end(); + } +}; + +const command = process.argv[2] || 'apply'; + +if (command === 'apply') { + migrate(); +} else if (command === 'check') { + checkMigrationVersion(); +} else if (command === 'status') { + printMigrationStatus(); +} else { + logger.error(`Unknown command: ${command}. Use one of: apply, check, status`); + process.exit(1); +} diff --git a/scripts/restore-drill.js b/scripts/restore-drill.js new file mode 100644 index 0000000000..a2d7085c58 --- /dev/null +++ b/scripts/restore-drill.js @@ -0,0 +1,208 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, readdir, stat, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import pg from 'pg'; +import { logger } from '../src/utils/logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +const { Pool } = pg; + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith('--')) { + continue; + } + + const [rawKey, inlineValue] = token.slice(2).split('='); + if (typeof inlineValue !== 'undefined') { + args[rawKey] = inlineValue; + continue; + } + + const nextToken = argv[index + 1]; + if (!nextToken || nextToken.startsWith('--')) { + args[rawKey] = true; + continue; + } + + args[rawKey] = nextToken; + index += 1; + } + + return args; +} + +function ensureCommand(command) { + const result = spawnSync(command, ['--version'], { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`${command} is required but was not found in PATH.`); + } +} + +function createTimestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return `${now.getUTCFullYear()}${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())}${pad(now.getUTCHours())}${pad(now.getUTCMinutes())}${pad(now.getUTCSeconds())}`; +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`${command} failed: ${result.stderr || result.stdout || 'Unknown error'}`); + } + + return result.stdout; +} + +async function pruneBackups(backupDir, retentionDays) { + if (!Number.isFinite(retentionDays) || retentionDays <= 0) { + return; + } + + const entries = await readdir(backupDir, { withFileTypes: true }); + const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.dump')) { + continue; + } + + const fullPath = path.join(backupDir, entry.name); + const fileStats = await stat(fullPath); + if (fileStats.mtimeMs < cutoff) { + await unlink(fullPath); + } + } +} + +function buildDatabaseUrlWithName(databaseUrl, databaseName) { + const parsed = new URL(databaseUrl); + parsed.pathname = `/${databaseName}`; + return parsed.toString(); +} + +async function run() { + const args = parseArgs(process.argv.slice(2)); + const sourceDatabaseUrl = process.env.POSTGRES_URL; + if (!sourceDatabaseUrl) { + throw new Error('Missing required environment variable: POSTGRES_URL'); + } + + const keepDrillDatabase = args['keep-db'] === true || args['keep-db'] === 'true'; + const retentionDays = Number.parseInt(args['retention-days'] || process.env.BACKUP_RETENTION_DAYS || '14', 10); + const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); + + ensureCommand('pg_dump'); + ensureCommand('pg_restore'); + + await mkdir(backupDir, { recursive: true }); + + const stamp = createTimestamp(); + const backupPath = path.join(backupDir, `restore-drill-${stamp}.dump`); + const drillDatabaseName = `titanbot_restore_drill_${stamp}`; + const maintenanceUrl = buildDatabaseUrlWithName(sourceDatabaseUrl, 'postgres'); + const drillDatabaseUrl = buildDatabaseUrlWithName(sourceDatabaseUrl, drillDatabaseName); + + const maintenancePool = new Pool({ connectionString: maintenanceUrl }); + + logger.info('Starting restore drill', { + event: 'restore_drill.start', + drillDatabaseName + }); + + try { + runCommand('pg_dump', [ + '--format=custom', + '--no-owner', + '--no-privileges', + '--file', + backupPath, + sourceDatabaseUrl + ]); + + const createClient = await maintenancePool.connect(); + try { + await createClient.query(`CREATE DATABASE "${drillDatabaseName}" TEMPLATE template0`); + } finally { + createClient.release(); + } + + runCommand('pg_restore', [ + '--clean', + '--if-exists', + '--no-owner', + '--no-privileges', + '--dbname', + drillDatabaseUrl, + backupPath + ]); + + const verifyPool = new Pool({ connectionString: drillDatabaseUrl }); + try { + const tableCount = await verifyPool.query( + `SELECT COUNT(*)::int AS value FROM information_schema.tables WHERE table_schema = 'public'` + ); + + const migrationTableCount = await verifyPool.query( + `SELECT COUNT(*)::int AS value FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'schema_migrations'` + ); + + if (tableCount.rows[0]?.value <= 0) { + throw new Error('Restore drill verification failed: no public tables restored.'); + } + + if (migrationTableCount.rows[0]?.value <= 0) { + throw new Error('Restore drill verification failed: schema_migrations table missing.'); + } + } finally { + await verifyPool.end(); + } + + logger.info('Restore drill completed successfully', { + event: 'restore_drill.completed', + drillDatabaseName, + backupPath + }); + } finally { + if (!keepDrillDatabase) { + const dropClient = await maintenancePool.connect(); + try { + await dropClient.query( + `SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()`, + [drillDatabaseName] + ); + await dropClient.query(`DROP DATABASE IF EXISTS "${drillDatabaseName}"`); + } finally { + dropClient.release(); + } + } + + await maintenancePool.end(); + await pruneBackups(backupDir, retentionDays); + } +} + +run().catch((error) => { + logger.error('Restore drill failed', { + event: 'restore_drill.failed', + error: error.message + }); + process.exit(1); +}); diff --git a/scripts/restore.js b/scripts/restore.js new file mode 100644 index 0000000000..5551947d93 --- /dev/null +++ b/scripts/restore.js @@ -0,0 +1,137 @@ +import { spawnSync } from 'node:child_process'; +import { readdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import dotenv from 'dotenv'; +import { logger } from '../src/utils/logger.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, '..', '.env') }); + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith('--')) { + continue; + } + + const [rawKey, inlineValue] = token.slice(2).split('='); + if (typeof inlineValue !== 'undefined') { + args[rawKey] = inlineValue; + continue; + } + + const nextToken = argv[index + 1]; + if (!nextToken || nextToken.startsWith('--')) { + args[rawKey] = true; + continue; + } + + args[rawKey] = nextToken; + index += 1; + } + + return args; +} + +function ensureCommand(command) { + const result = spawnSync(command, ['--version'], { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`${command} is required but was not found in PATH.`); + } +} + +async function resolveLatestBackup(backupDir) { + const entries = await readdir(backupDir, { withFileTypes: true }); + const dumpFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.dump')) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)); + + if (dumpFiles.length === 0) { + throw new Error(`No .dump backup files found in ${backupDir}`); + } + + return path.join(backupDir, dumpFiles[0]); +} + +function runCommand(command, args) { + const result = spawnSync(command, args, { + encoding: 'utf8', + stdio: 'pipe', + shell: process.platform === 'win32' + }); + + if (result.status !== 0) { + throw new Error(`${command} failed: ${result.stderr || result.stdout || 'Unknown error'}`); + } +} + +async function run() { + const args = parseArgs(process.argv.slice(2)); + const backupDir = path.resolve(args['backup-dir'] || process.env.BACKUP_DIR || path.join(process.cwd(), 'backups')); + const targetUrl = args['target-url'] || process.env.POSTGRES_RESTORE_URL || process.env.POSTGRES_URL; + + if (!targetUrl) { + throw new Error('Missing target database URL. Set POSTGRES_RESTORE_URL or POSTGRES_URL.'); + } + + if (!args.confirm) { + throw new Error('Restore requires explicit confirmation. Re-run with --confirm.'); + } + + ensureCommand('pg_restore'); + ensureCommand('psql'); + + const inputPath = args.input ? path.resolve(args.input) : await resolveLatestBackup(backupDir); + const dropSchema = args['drop-schema'] === true || args['drop-schema'] === 'true'; + + logger.warn('Starting database restore', { + event: 'restore.start', + inputPath, + targetUrl, + dropSchema + }); + + if (dropSchema) { + runCommand('psql', [ + '--dbname', + targetUrl, + '-v', + 'ON_ERROR_STOP=1', + '-c', + 'DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;' + ]); + } + + runCommand('pg_restore', [ + '--clean', + '--if-exists', + '--no-owner', + '--no-privileges', + '--dbname', + targetUrl, + inputPath + ]); + + logger.info('Database restore completed', { + event: 'restore.completed', + inputPath, + targetUrl + }); +} + +run().catch((error) => { + logger.error('Restore command failed', { + event: 'restore.failed', + error: error.message + }); + process.exit(1); +}); diff --git a/src/app.js b/src/app.js index 6132ab1559..8f0e2b4dc7 100644 --- a/src/app.js +++ b/src/app.js @@ -277,18 +277,7 @@ class TitanBot extends Client { async loadHandlers() { const handlers = [ { path: 'events', type: 'default', required: true }, - { path: 'interactions', type: 'default', required: true }, - { path: 'todoButtonLoader', type: 'default', required: false }, - { path: 'counterButtonLoader', type: 'default', required: false }, - { path: 'ticketButtonLoader', type: 'default', required: false }, - { path: 'giveawayButtonLoader', type: 'named:loadGiveawayButtons', required: false }, - { path: 'helpButtonLoader', type: 'default', required: false }, - { path: 'helpSelectMenuLoader', type: 'default', required: false }, - { path: 'loggingButtonLoader', type: 'default', required: false }, - { path: 'verificationButtonLoader', type: 'named:loadVerificationButtons', required: false }, - { path: 'wipedataButtonLoader', type: 'default', required: false }, - { path: 'countdownButtonLoader', type: 'default', required: false }, - { path: 'calculateButtonLoader', type: 'default', required: false } + { path: 'interactions', type: 'default', required: true } ]; for (const handler of handlers) { diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index da0d221651..898c1ec4f8 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -783,7 +783,7 @@ export async function handleApplicationButton(interaction) { await interaction.showModal(modal); } catch (error) { - console.error('Error handling application button:', error); + logger.error('Error handling application button:', error); await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('An error occurred while processing the application.')], flags: ["Ephemeral"] @@ -831,7 +831,7 @@ export async function handleApplicationReviewModal(interaction) { await user.send({ embeds: [dmEmbed] }); } catch (error) { - console.error('Error sending DM to user:', error); + logger.error('Error sending DM to user:', error); } if (application.logMessageId && application.logChannelId) { @@ -858,7 +858,7 @@ export async function handleApplicationReviewModal(interaction) { } } } catch (error) { - console.error('Error updating log message:', error); + logger.error('Error updating log message:', error); } } @@ -867,7 +867,7 @@ export async function handleApplicationReviewModal(interaction) { const member = await interaction.guild.members.fetch(application.userId); await member.roles.add(application.role); } catch (error) { - console.error('Error assigning role:', error); + logger.error('Error assigning role:', error); } } @@ -882,7 +882,7 @@ export async function handleApplicationReviewModal(interaction) { }); } catch (error) { - console.error('Error processing application review:', error); + logger.error('Error processing application review:', error); await InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed('An error occurred while processing the application.')], flags: ["Ephemeral"] diff --git a/src/commands/Config/config.js b/src/commands/Config/config.js index e44e205bf1..81f45f7c85 100644 --- a/src/commands/Config/config.js +++ b/src/commands/Config/config.js @@ -1,5 +1,6 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } from 'discord.js'; import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; import birthdayToggle from './modules/config_birthday_toggle.js'; import loggingStatus from './modules/config_logging_status.js'; @@ -200,7 +201,7 @@ export default { flags: MessageFlags.Ephemeral, }); } catch (error) { - console.error("Config command error:", error); + logger.error("Config command error:", error); const errorMessage = { embeds: [ diff --git a/src/commands/Config/modules/config_birthday_toggle.js b/src/commands/Config/modules/config_birthday_toggle.js index 3af157b325..b0b6e185c5 100644 --- a/src/commands/Config/modules/config_birthday_toggle.js +++ b/src/commands/Config/modules/config_birthday_toggle.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { @@ -39,7 +40,7 @@ try { }); } } catch (error) { - console.error("config_birthday_toggle error:", error); + logger.error("config_birthday_toggle error:", error); return InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Config/modules/config_logging_filter.js b/src/commands/Config/modules/config_logging_filter.js index 320a6fd529..8970a20d2d 100644 --- a/src/commands/Config/modules/config_logging_filter.js +++ b/src/commands/Config/modules/config_logging_filter.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { logEvent } from '../../../utils/moderation.js'; +import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { @@ -128,7 +129,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) embeds: [successEmbed("Success!", successMessage)], }); } catch (error) { - console.error("Error saving log filter:", error); + logger.error("Error saving log filter:", error); await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Config/modules/config_logging_setchannel.js b/src/commands/Config/modules/config_logging_setchannel.js index 58814a95fa..428428a55f 100644 --- a/src/commands/Config/modules/config_logging_setchannel.js +++ b/src/commands/Config/modules/config_logging_setchannel.js @@ -3,6 +3,7 @@ import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from ' import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; import { logEvent } from '../../../utils/moderation.js'; import { validateLogChannel } from '../../../utils/ticketLogging.js'; +import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { @@ -171,7 +172,7 @@ if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator) }); } catch (error) { - console.error("Error setting log channel:", error); + logger.error("Error setting log channel:", error); await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Config/modules/config_logging_status.js b/src/commands/Config/modules/config_logging_status.js index 6bdca7e803..095e665381 100644 --- a/src/commands/Config/modules/config_logging_status.js +++ b/src/commands/Config/modules/config_logging_status.js @@ -8,6 +8,7 @@ import { createLoggingStatusComponents } from '../../../utils/loggingUi.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { getConfiguration as getJoinToCreateConfiguration } from '../../../services/joinToCreateService.js'; import { getLevelingConfig } from '../../../services/leveling.js'; +import { logger } from '../../../utils/logger.js'; const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, eventType) => { const [category] = eventType.split('.'); @@ -164,7 +165,7 @@ export default { components }); } catch (error) { - console.error("config_logging_status error:", error); + logger.error("config_logging_status error:", error); await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Config/modules/config_premium_setrole.js b/src/commands/Config/modules/config_premium_setrole.js index d092b85aa8..562859faee 100644 --- a/src/commands/Config/modules/config_premium_setrole.js +++ b/src/commands/Config/modules/config_premium_setrole.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { @@ -35,7 +36,7 @@ export default { ], }); } catch (error) { - console.error("SetPremiumRole command error:", error); + logger.error("SetPremiumRole command error:", error); await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Config/modules/config_reports_setchannel.js b/src/commands/Config/modules/config_reports_setchannel.js index e9a42ac377..625850322a 100644 --- a/src/commands/Config/modules/config_reports_setchannel.js +++ b/src/commands/Config/modules/config_reports_setchannel.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; export default { async execute(interaction, config, client) { @@ -23,7 +24,7 @@ export default { ], }); } catch (error) { - console.error("Error setting report channel:", error); + logger.error("Error setting report channel:", error); await InteractionHelper.safeEditReply(interaction, { embeds: [ errorEmbed( diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js index 45737397c9..c20d1ad496 100644 --- a/src/commands/Core/ping.js +++ b/src/commands/Core/ping.js @@ -37,14 +37,14 @@ export default { embeds: [embed], }); } catch (error) { - console.error('Ping command error:', error); + logger.error('Ping command error:', error); try { return await InteractionHelper.safeReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], flags: MessageFlags.Ephemeral, }); } catch (replyError) { - console.error('Failed to send error reply:', replyError); + logger.error('Failed to send error reply:', replyError); } } }, diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.js index 6b46d7351f..b2e634a6af 100644 --- a/src/commands/Core/stats.js +++ b/src/commands/Core/stats.js @@ -1,5 +1,6 @@ import { SlashCommandBuilder, version, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -32,7 +33,7 @@ export default { await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { - console.error('Stats command error:', error); + logger.error('Stats command error:', error); return InteractionHelper.safeEditReply(interaction, { embeds: [createEmbed({ title: 'System Error', description: 'Could not fetch system statistics.', color: 'error' })], flags: MessageFlags.Ephemeral, diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js index f5210e298e..aa392e3a66 100644 --- a/src/commands/Core/support.js +++ b/src/commands/Core/support.js @@ -1,5 +1,6 @@ import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; const SUPPORT_SERVER_URL = "https://discord.gg/QnWNz2dKCE"; @@ -25,7 +26,7 @@ export default { flags: MessageFlags.Ephemeral, }); } catch (error) { - console.error('Support command error:', error); + logger.error('Support command error:', error); try { return await InteractionHelper.safeReply(interaction, { @@ -33,7 +34,7 @@ export default { flags: MessageFlags.Ephemeral, }); } catch (replyError) { - console.error('Failed to send error reply:', replyError); + logger.error('Failed to send error reply:', replyError); } } }, diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.js index f25f3eb1f2..7c33d49b82 100644 --- a/src/commands/Core/uptime.js +++ b/src/commands/Core/uptime.js @@ -1,5 +1,6 @@ import { SlashCommandBuilder, MessageFlags } from 'discord.js'; import { createEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -28,7 +29,7 @@ export default { })], }); } catch (error) { - console.error('Uptime command error:', error); + logger.error('Uptime command error:', error); try { return await InteractionHelper.safeEditReply(interaction, { @@ -36,7 +37,7 @@ export default { flags: MessageFlags.Ephemeral, }); } catch (replyError) { - console.error('Failed to send error reply:', replyError); + logger.error('Failed to send error reply:', replyError); } } }, diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js index 609f91e2fc..40f6424beb 100644 --- a/src/commands/Economy/pay.js +++ b/src/commands/Economy/pay.js @@ -147,7 +147,7 @@ export default { }); await receiver.send({ embeds: [receiverEmbed] }); } catch (e) { - console.log(`Could not DM user ${receiver.id}:`, e.message); + logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); } }, { command: 'pay' }) }; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index 298e535474..9fd4272d16 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -1,6 +1,7 @@ import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, EmbedBuilder, MessageFlags } from 'discord.js'; import { shopItems } from '../../config/shop/items.js'; import { getColor } from '../../config/bot.js'; +import { logger } from '../../utils/logger.js'; export default { data: new SlashCommandBuilder() @@ -108,7 +109,7 @@ export default { } }); } catch (error) { - console.error('Shop command error:', error); + logger.error('Shop command error:', error); await interaction.reply({ content: 'โŒ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js index a9400aa5ad..82b3c402ca 100644 --- a/src/commands/Tools/calculate.js +++ b/src/commands/Tools/calculate.js @@ -4,38 +4,13 @@ import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { getColor } from '../../config/bot.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { evaluateMathExpression } from '../../utils/safeMathParser.js'; // Store calculation context for modal handlers const calculationContexts = new Map(); function evaluate(expression) { - let expr = expression.replace(/\s/g, '').toLowerCase(); - - const math = { - sin: Math.sin, - cos: Math.cos, - tan: Math.tan, - sqrt: Math.sqrt, - abs: Math.abs, - log: Math.log, - log10: Math.log10, - exp: Math.exp, - pi: Math.PI, - e: Math.E - }; - - expr = expr.replace(/sin|cos|tan|sqrt|abs|log|log10|exp|pi|e/g, (match) => `math.${match}`); - - expr = expr.replace(/(\d+)\s*deg/g, (match, num) => `(${num} * Math.PI / 180)`); - - expr = expr.replace(/\^/g, '**'); - - try { - const func = new Function('math', `return ${expr}`); - return func(math); - } catch (error) { - throw new Error(`Invalid expression: ${error.message}`); - } + return evaluateMathExpression(expression); } const calculationHistory = new Map(); diff --git a/src/config/bot.js b/src/config/bot.js index 444ee9ae45..b416b8df94 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -1,8 +1,3 @@ - - - - - import { logger } from '../utils/logger.js'; @@ -51,20 +46,6 @@ export const botConfig = { // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors - - - - - - - - - - - - - - embeds: { colors: { @@ -252,7 +233,7 @@ export const botConfig = { logAllVerifications: true, - keepAuditTrail: true // Store audit trail in database + keepAuditTrail: true }, @@ -341,17 +322,10 @@ export const botConfig = { utility: true, community: true, fun: true, - - - music: false, }, }; - - - - export function validateConfig(config) { const errors = []; @@ -393,7 +367,7 @@ export function validateConfig(config) { const configErrors = validateConfig(botConfig); if (configErrors.length > 0) { - console.error("Bot configuration errors:", configErrors.join("\n")); + logger.error("Bot configuration errors:", configErrors.join("\n")); if (process.env.NODE_ENV === "production") { process.exit(1); } @@ -402,44 +376,6 @@ if (configErrors.length > 0) { export const BotConfig = botConfig; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - export function getColor(path, fallback = "#99AAB5") { if (typeof path === "number") return path; diff --git a/src/config/postgres.js b/src/config/postgres.js index c45f6dbea4..b70d1875a6 100644 --- a/src/config/postgres.js +++ b/src/config/postgres.js @@ -1,3 +1,50 @@ +import { assertAllowlistedIdentifier } from '../utils/sqlIdentifiers.js'; +import { EXPECTED_SCHEMA_LABEL, EXPECTED_SCHEMA_VERSION } from './schemaVersion.js'; + +const configuredTables = { + guilds: 'guilds', + users: 'users', + guild_users: 'guild_users', + birthdays: 'birthdays', + giveaways: 'giveaways', + tickets: 'ticket_data', + afk_status: 'afk_status', + welcome_configs: 'welcome_configs', + leveling_configs: 'leveling_configs', + user_levels: 'user_levels', + economy: 'economy', + invite_tracking: 'invite_tracking', + application_roles: 'application_roles', + verification_audit: 'verification_audit', + temp_data: 'temp_data', + cache_data: 'cache_data', +}; + +const allowedTableIdentifiers = new Set([ + 'guilds', + 'users', + 'guild_users', + 'birthdays', + 'giveaways', + 'ticket_data', + 'afk_status', + 'welcome_configs', + 'leveling_configs', + 'user_levels', + 'economy', + 'invite_tracking', + 'application_roles', + 'verification_audit', + 'temp_data', + 'cache_data', +]); + +const validatedTables = Object.fromEntries( + Object.entries(configuredTables).map(([key, value]) => [ + key, + assertAllowlistedIdentifier(value, allowedTableIdentifiers, `PostgreSQL table identifier (${key})`), + ]) +); @@ -11,11 +58,7 @@ export const pgConfig = { database: process.env.POSTGRES_DB || 'titanbot', user: process.env.POSTGRES_USER || 'postgres', password: (process.env.POSTGRES_PASSWORD || '').toString(), - - - ssl: process.env.POSTGRES_SSL === 'true' ? { - rejectUnauthorized: false - } : false, + ssl: false, max: parseInt(process.env.POSTGRES_MAX_CONNECTIONS) || 20, @@ -35,24 +78,7 @@ export const pgConfig = { backoffMultiplier: parseInt(process.env.POSTGRES_BACKOFF_MULTIPLIER) || 2, }, - tables: { - guilds: 'guilds', - users: 'users', - guild_users: 'guild_users', - birthdays: 'birthdays', - giveaways: 'giveaways', - tickets: 'ticket_data', - afk_status: 'afk_status', - welcome_configs: 'welcome_configs', - leveling_configs: 'leveling_configs', - user_levels: 'user_levels', - economy: 'economy', - invite_tracking: 'invite_tracking', - application_roles: 'application_roles', - verification_audit: 'verification_audit', - temp_data: 'temp_data', - cache_data: 'cache_data', - }, + tables: validatedTables, defaultTTL: { userSession: 86400, @@ -80,8 +106,7 @@ export const pgConfig = { features: { pooling: true, - - ssl: process.env.POSTGRES_SSL === 'true', + ssl: false, metrics: true, @@ -89,7 +114,7 @@ export const pgConfig = { autoCreateTables: true, - autoMigrate: false, + autoMigrate: process.env.AUTO_MIGRATE !== 'false', }, healthCheck: { @@ -103,13 +128,17 @@ export const pgConfig = { }, migration: { - enabled: false, + enabled: true, - table: 'migrations', + table: 'schema_migrations', directory: 'database/migrations', rollbackOnFailure: false, + + expectedVersion: EXPECTED_SCHEMA_VERSION, + + expectedLabel: EXPECTED_SCHEMA_LABEL, } }; diff --git a/src/config/schemaVersion.js b/src/config/schemaVersion.js new file mode 100644 index 0000000000..de311deaf5 --- /dev/null +++ b/src/config/schemaVersion.js @@ -0,0 +1,7 @@ +const rawSchemaVersion = Number.parseInt(process.env.SCHEMA_VERSION || '1', 10); + +export const EXPECTED_SCHEMA_VERSION = Number.isInteger(rawSchemaVersion) && rawSchemaVersion > 0 + ? rawSchemaVersion + : 1; + +export const EXPECTED_SCHEMA_LABEL = process.env.SCHEMA_VERSION_LABEL || `baseline-v${EXPECTED_SCHEMA_VERSION}`; diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index 197cf7a813..bfd3e9d2eb 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -6,218 +6,275 @@ import { handleApplicationButton, handleApplicationReviewModal } from '../comman import { handleInteractionError, createError, ErrorTypes } from '../utils/errorHandler.js'; import { MessageTemplates } from '../utils/messageTemplates.js'; import { InteractionHelper } from '../utils/interactionHelper.js'; +import { createInteractionTraceContext, runWithTraceContext } from '../utils/traceContext.js'; +import { validateChatInputPayloadOrThrow } from '../utils/commandInputValidation.js'; +import { enforceAbuseProtection, formatCooldownDuration } from '../utils/abuseProtection.js'; + +function withTraceContext(context = {}, traceContext = {}) { + return { + traceId: traceContext.traceId, + guildId: context.guildId || traceContext.guildId, + userId: context.userId || traceContext.userId, + command: context.commandName || traceContext.command, + ...context + }; +} export default { name: Events.InteractionCreate, async execute(interaction, client) { - try { - InteractionHelper.patchInteractionResponses(interaction); + const interactionTraceContext = createInteractionTraceContext(interaction); + interaction.traceContext = interactionTraceContext; + interaction.traceId = interactionTraceContext.traceId; - if (interaction.isChatInputCommand()) { - try { - logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`); - const command = client.commands.get(interaction.commandName); + return runWithTraceContext(interactionTraceContext, async () => { + try { + InteractionHelper.patchInteractionResponses(interaction); - if (!command) { - throw createError( - `No command matching ${interaction.commandName} was found.`, - ErrorTypes.CONFIGURATION, - 'Sorry, that command does not exist.', - { commandName: interaction.commandName } - ); - } - + if (interaction.isChatInputCommand()) { + try { + logger.info(`Command executed: /${interaction.commandName} by ${interaction.user.tag}`, { + event: 'interaction.command.received', + traceId: interactionTraceContext.traceId, + guildId: interaction.guildId, + userId: interaction.user?.id, + command: interaction.commandName + }); + + validateChatInputPayloadOrThrow(interaction, withTraceContext({ + type: 'command_input_validation', + commandName: interaction.commandName + }, interactionTraceContext)); - let guildConfig = null; - if (interaction.guild) { - guildConfig = await getGuildConfig(client, interaction.guild.id); - if (guildConfig?.disabledCommands?.[interaction.commandName]) { + const command = client.commands.get(interaction.commandName); + + if (!command) { throw createError( - `Command ${interaction.commandName} is disabled in this guild`, + `No command matching ${interaction.commandName} was found.`, ErrorTypes.CONFIGURATION, - 'This command has been disabled for this server.', - { commandName: interaction.commandName, guildId: interaction.guild.id } + 'Sorry, that command does not exist.', + withTraceContext({ commandName: interaction.commandName }, interactionTraceContext) ); } - } - await command.execute(interaction, guildConfig, client); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: interaction.commandName - }); - } - } - else if (interaction.isButton()) { - if (interaction.customId.startsWith('app_approve_') || interaction.customId.startsWith('app_deny_')) { - try { - await handleApplicationButton(interaction); + const abuseProtection = await enforceAbuseProtection(interaction, command, interaction.commandName); + if (!abuseProtection.allowed) { + const formattedCooldown = formatCooldownDuration(abuseProtection.remainingMs); + throw createError( + `Risky command cooldown active for ${interaction.commandName}`, + ErrorTypes.RATE_LIMIT, + `This command is on cooldown. Please wait ${formattedCooldown} before trying again.`, + withTraceContext({ + commandName: interaction.commandName, + subtype: 'command_cooldown', + expected: true, + cooldownMs: abuseProtection.remainingMs, + cooldownWindowMs: abuseProtection.policy?.windowMs, + cooldownMaxAttempts: abuseProtection.policy?.maxAttempts + }, interactionTraceContext) + ); + } + + let guildConfig = null; + if (interaction.guild) { + guildConfig = await getGuildConfig(client, interaction.guild.id, interactionTraceContext); + if (guildConfig?.disabledCommands?.[interaction.commandName]) { + throw createError( + `Command ${interaction.commandName} is disabled in this guild`, + ErrorTypes.CONFIGURATION, + 'This command has been disabled for this server.', + withTraceContext({ commandName: interaction.commandName, guildId: interaction.guild.id }, interactionTraceContext) + ); + } + } + + await command.execute(interaction, guildConfig, client); } catch (error) { - await handleInteractionError(interaction, error, { - type: 'button', - customId: interaction.customId, - handler: 'application' - }); + await handleInteractionError(interaction, error, withTraceContext({ + type: 'command', + commandName: interaction.commandName + }, interactionTraceContext)); } - return; - } - - if (interaction.customId.startsWith('shared_todo_')) { - const parts = interaction.customId.split('_'); -const buttonType = parts.slice(0, 3).join('_'); - const listId = parts[3]; - const button = client.buttons.get(buttonType); - - if (button) { + } else if (interaction.isButton()) { + if (interaction.customId.startsWith('app_approve_') || interaction.customId.startsWith('app_deny_')) { try { - await button.execute(interaction, client, [listId]); + await handleApplicationButton(interaction); } catch (error) { - await handleInteractionError(interaction, error, { + await handleInteractionError(interaction, error, withTraceContext({ type: 'button', customId: interaction.customId, - handler: 'todo' - }); + handler: 'application' + }, interactionTraceContext)); } - } else { + return; + } + + if (interaction.customId.startsWith('shared_todo_')) { + const parts = interaction.customId.split('_'); + const buttonType = parts.slice(0, 3).join('_'); + const listId = parts[3]; + const button = client.buttons.get(buttonType); + + if (button) { + try { + await button.execute(interaction, client, [listId]); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'todo' + }, interactionTraceContext)); + } + } else { + throw createError( + `No button handler found for ${buttonType}`, + ErrorTypes.CONFIGURATION, + 'This button is not available.', + withTraceContext({ buttonType }, interactionTraceContext) + ); + } + return; + } + + const [customId, ...args] = interaction.customId.split(':'); + const button = client.buttons.get(customId); + + if (!button) { + if (!interaction.customId.includes(':')) { + return; + } + throw createError( - `No button handler found for ${buttonType}`, + `No button handler found for ${customId}`, ErrorTypes.CONFIGURATION, 'This button is not available.', - { buttonType } + withTraceContext({ customId }, interactionTraceContext) ); } - return; - } - - const [customId, ...args] = interaction.customId.split(':'); - const button = client.buttons.get(customId); - if (!button) { - if (!interaction.customId.includes(':')) { - return; + try { + await button.execute(interaction, client, args); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'button', + customId: interaction.customId, + handler: 'general' + }, interactionTraceContext)); } + } else if (interaction.isStringSelectMenu()) { + const [customId, ...args] = interaction.customId.split(':'); + const selectMenu = client.selectMenus.get(customId); - throw createError( - `No button handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This button is not available.', - { customId } - ); - } - - try { - await button.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'button', - customId: interaction.customId, - handler: 'general' - }); - } - } - else if (interaction.isStringSelectMenu()) { - const [customId, ...args] = interaction.customId.split(':'); - const selectMenu = client.selectMenus.get(customId); - - if (!selectMenu) { - throw createError( - `No select menu handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This select menu is not available.', - { customId } - ); - } + if (!selectMenu) { + throw createError( + `No select menu handler found for ${customId}`, + ErrorTypes.CONFIGURATION, + 'This select menu is not available.', + withTraceContext({ customId }, interactionTraceContext) + ); + } - try { - await selectMenu.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'select_menu', - customId: interaction.customId - }); - } - } - else if (interaction.isModalSubmit()) { - if (interaction.customId.startsWith('app_modal_')) { try { - await handleApplicationModal(interaction); + await selectMenu.execute(interaction, client, args); } catch (error) { - await handleInteractionError(interaction, error, { - type: 'modal', - customId: interaction.customId, - handler: 'application' + await handleInteractionError(interaction, error, withTraceContext({ + type: 'select_menu', + customId: interaction.customId + }, interactionTraceContext)); + } + } else if (interaction.isModalSubmit()) { + if (interaction.customId.startsWith('app_modal_')) { + try { + await handleApplicationModal(interaction); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'modal', + customId: interaction.customId, + handler: 'application' + }, interactionTraceContext)); + } + return; + } + + if (interaction.customId.startsWith('app_review_')) { + try { + await handleApplicationReviewModal(interaction); + } catch (error) { + await handleInteractionError(interaction, error, withTraceContext({ + type: 'modal', + customId: interaction.customId, + handler: 'application_review' + }, interactionTraceContext)); + } + return; + } + + if (interaction.customId.startsWith('jtc_')) { + logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`, { + event: 'interaction.modal.inline_skipped', + traceId: interactionTraceContext.traceId }); + return; } - return; - } - - if (interaction.customId.startsWith('app_review_')) { + + const [customId, ...args] = interaction.customId.split(':'); + const modal = client.modals.get(customId); + + if (!modal) { + throw createError( + `No modal handler found for ${customId}`, + ErrorTypes.CONFIGURATION, + 'This form is not available.', + withTraceContext({ customId }, interactionTraceContext) + ); + } + try { - await handleApplicationReviewModal(interaction); + await modal.execute(interaction, client, args); } catch (error) { - await handleInteractionError(interaction, error, { + await handleInteractionError(interaction, error, withTraceContext({ type: 'modal', customId: interaction.customId, - handler: 'application_review' - }); + handler: 'general' + }, interactionTraceContext)); } - return; - } - - // Skip modals that are awaited inline by commands (Join to Create modals) - if (interaction.customId.startsWith('jtc_')) { - // These modals are handled by awaitModalSubmit() in the command - logger.debug(`Skipping modal handler lookup for inline-awaited modal: ${interaction.customId}`); - return; - } - - const [customId, ...args] = interaction.customId.split(':'); - const modal = client.modals.get(customId); - - if (!modal) { - throw createError( - `No modal handler found for ${customId}`, - ErrorTypes.CONFIGURATION, - 'This form is not available.', - { customId } - ); } + } catch (error) { + logger.error('Unhandled error in interactionCreate:', { + event: 'interaction.unhandled_error', + errorCode: 'INTERACTION_UNHANDLED_ERROR', + error, + traceId: interactionTraceContext.traceId, + interactionId: interaction.id, + guildId: interaction.guildId, + userId: interaction.user?.id + }); try { - await modal.execute(interaction, client, args); - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'modal', - customId: interaction.customId, - handler: 'general' + const ephemeralErrorMessage = { + embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')], + flags: MessageFlags.Ephemeral + }; + const editErrorMessage = { + embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')] + }; + + if (interaction.deferred) { + await interaction.editReply(editErrorMessage); + } else if (interaction.replied) { + await interaction.followUp(ephemeralErrorMessage); + } else { + await interaction.reply(ephemeralErrorMessage); + } + } catch (replyError) { + logger.error('Failed to send fallback error response:', { + event: 'interaction.error_response_failed', + errorCode: 'INTERACTION_ERROR_RESPONSE_FAILED', + error: replyError, + traceId: interactionTraceContext.traceId }); } } - } catch (error) { - logger.error('Unhandled error in interactionCreate:', error); - - try { - const ephemeralErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')], - flags: MessageFlags.Ephemeral - }; - const editErrorMessage = { - embeds: [MessageTemplates.ERRORS.DATABASE_ERROR('processing your interaction')] - }; - - if (interaction.deferred) { - await interaction.editReply(editErrorMessage); - } else if (interaction.replied) { - await interaction.followUp(ephemeralErrorMessage); - } else { - await interaction.reply(ephemeralErrorMessage); - } - } catch (replyError) { - logger.error('Failed to send fallback error response:', replyError); - } - } + }); } }; - - diff --git a/src/handlers/calculateButtonLoader.js b/src/handlers/calculateButtonLoader.js deleted file mode 100644 index 840d3cfdc9..0000000000 --- a/src/handlers/calculateButtonLoader.js +++ /dev/null @@ -1,11 +0,0 @@ -import calculateModalHandler from './calculateModals.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadCalculateButtons(client) { - try { - client.modals.set('calc_modal', calculateModalHandler); - logger.info('Calculate modal handlers loaded'); - } catch (error) { - logger.error('Error loading calculate button handlers:', error); - } -} diff --git a/src/handlers/calculateButtons.js b/src/handlers/calculateButtons.js index cef9ef86b4..10aa7b29c5 100644 --- a/src/handlers/calculateButtons.js +++ b/src/handlers/calculateButtons.js @@ -1,32 +1,9 @@ import { errorEmbed, successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; +import { evaluateMathExpression } from '../utils/safeMathParser.js'; function evaluate(expression) { - let expr = expression.replace(/\s/g, '').toLowerCase(); - - const math = { - sin: Math.sin, - cos: Math.cos, - tan: Math.tan, - sqrt: Math.sqrt, - abs: Math.abs, - log: Math.log, - log10: Math.log10, - exp: Math.exp, - pi: Math.PI, - e: Math.E - }; - - expr = expr.replace(/sin|cos|tan|sqrt|abs|log|log10|exp|pi|e/g, (match) => `math.${match}`); - expr = expr.replace(/(\d+)\s*deg/g, (match, num) => `(${num} * Math.PI / 180)`); - expr = expr.replace(/\^/g, '**'); - - try { - const func = new Function('math', `return ${expr}`); - return func(math); - } catch (error) { - throw new Error(`Invalid expression: ${error.message}`); - } + return evaluateMathExpression(expression); } async function calculateModalHandler(interaction, client, args) { diff --git a/src/handlers/calculateModalLoader.js b/src/handlers/calculateModalLoader.js deleted file mode 100644 index 8df646202a..0000000000 --- a/src/handlers/calculateModalLoader.js +++ /dev/null @@ -1,11 +0,0 @@ -import calculateModalHandler from './calculateModals.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadCalculateModals(client) { - try { - client.modals.set('calc_modal', calculateModalHandler); - logger.info('Calculate modal handlers loaded'); - } catch (error) { - logger.error('Error loading calculate modal handlers:', error); - } -} diff --git a/src/handlers/calculateModals.js b/src/handlers/calculateModals.js index b104161e1f..bebdd572e1 100644 --- a/src/handlers/calculateModals.js +++ b/src/handlers/calculateModals.js @@ -1,32 +1,9 @@ import { errorEmbed, successEmbed } from '../utils/embeds.js'; import { logger } from '../utils/logger.js'; +import { evaluateMathExpression } from '../utils/safeMathParser.js'; function evaluate(expression) { - let expr = expression.replace(/\s/g, '').toLowerCase(); - - const math = { - sin: Math.sin, - cos: Math.cos, - tan: Math.tan, - sqrt: Math.sqrt, - abs: Math.abs, - log: Math.log, - log10: Math.log10, - exp: Math.exp, - pi: Math.PI, - e: Math.E - }; - - expr = expr.replace(/sin|cos|tan|sqrt|abs|log|log10|exp|pi|e/g, (match) => `math.${match}`); - expr = expr.replace(/(\d+)\s*deg/g, (match, num) => `(${num} * Math.PI / 180)`); - expr = expr.replace(/\^/g, '**'); - - try { - const func = new Function('math', `return ${expr}`); - return func(math); - } catch (error) { - throw new Error(`Invalid expression: ${error.message}`); - } + return evaluateMathExpression(expression); } async function calculateModalHandler(interaction, client, args) { diff --git a/src/handlers/countdownButtonLoader.js b/src/handlers/countdownButtonLoader.js deleted file mode 100644 index 44b91b6aaa..0000000000 --- a/src/handlers/countdownButtonLoader.js +++ /dev/null @@ -1,13 +0,0 @@ -import countdownButtonHandler from './countdownButtons.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadCountdownButtons(client) { - try { - // Register both pause and cancel button handlers under countdown_pause and countdown_cancel - client.buttons.set('countdown_pause', countdownButtonHandler); - client.buttons.set('countdown_cancel', countdownButtonHandler); - logger.info('Countdown button handlers loaded'); - } catch (error) { - logger.error('Error loading countdown button handlers:', error); - } -} diff --git a/src/handlers/counterButtonLoader.js b/src/handlers/counterButtonLoader.js deleted file mode 100644 index c755f4a2c6..0000000000 --- a/src/handlers/counterButtonLoader.js +++ /dev/null @@ -1,11 +0,0 @@ -import counterDeleteActionHandler from './counterButtons.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadCounterButtons(client) { - try { - client.buttons.set(counterDeleteActionHandler.name, counterDeleteActionHandler); - logger.info('Counter button handlers loaded'); - } catch (error) { - logger.error('Error loading counter button handlers:', error); - } -} diff --git a/src/handlers/giveawayButtonLoader.js b/src/handlers/giveawayButtonLoader.js deleted file mode 100644 index 90cadeea44..0000000000 --- a/src/handlers/giveawayButtonLoader.js +++ /dev/null @@ -1,23 +0,0 @@ -import { giveawayJoinHandler, giveawayEndHandler, giveawayRerollHandler, giveawayViewHandler } from './giveawayButtons.js'; -import { logger } from '../utils/logger.js'; - - - - - -export function loadGiveawayButtons(client) { - try { - client.buttons.set(giveawayJoinHandler.customId, giveawayJoinHandler); - client.buttons.set(giveawayEndHandler.customId, giveawayEndHandler); - client.buttons.set(giveawayRerollHandler.customId, giveawayRerollHandler); - client.buttons.set(giveawayViewHandler.customId, giveawayViewHandler); - - if (process.env.NODE_ENV !== 'production') { - logger.debug('Giveaway button handlers loaded'); - } - } catch (error) { - logger.error('Error loading giveaway button handlers:', error); - } -} - - diff --git a/src/handlers/helpButtonLoader.js b/src/handlers/helpButtonLoader.js deleted file mode 100644 index 5b5899322f..0000000000 --- a/src/handlers/helpButtonLoader.js +++ /dev/null @@ -1,25 +0,0 @@ -import { helpBackButton, helpBugReportButton, helpPaginationButton } from './helpButtons.js'; -import { logger } from '../utils/logger.js'; - - - - - -export default function loadHelpButtons(client) { - try { - client.buttons.set(helpBackButton.name, helpBackButton); - client.buttons.set(helpBugReportButton.name, helpBugReportButton); - client.buttons.set('help-page_first', helpPaginationButton); - client.buttons.set('help-page_prev', helpPaginationButton); - client.buttons.set('help-page_next', helpPaginationButton); - client.buttons.set('help-page_last', helpPaginationButton); - - if (process.env.NODE_ENV !== 'production') { - logger.debug('Help button handlers loaded'); - } - } catch (error) { - logger.error('Error loading help button handlers:', error); - } -} - - diff --git a/src/handlers/helpButtons.js b/src/handlers/helpButtons.js index b8cc0963e4..57dea4ada7 100644 --- a/src/handlers/helpButtons.js +++ b/src/handlers/helpButtons.js @@ -5,6 +5,7 @@ import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; import { Collection, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; +import { logger } from '../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -158,11 +159,29 @@ async function createCategorySelectMenu() { export const helpBackButton = { name: BACK_BUTTON_ID, async execute(interaction, client) { - const { embeds, components } = await createCategorySelectMenu(); - await interaction.update({ - embeds, - components, - }); + try { + if (!interaction.deferred && !interaction.replied) { + await interaction.deferUpdate(); + } + + const { embeds, components } = await createCategorySelectMenu(); + await interaction.editReply({ + embeds, + components, + }); + } catch (error) { + if (error?.code === 40060 || error?.code === 10062) { + logger.warn('Help back button interaction already acknowledged or expired.', { + event: 'interaction.help.button.unavailable', + errorCode: String(error.code), + customId: interaction.customId, + interactionId: interaction.id, + }); + return; + } + + throw error; + } }, }; @@ -231,29 +250,47 @@ function getPaginationInfo(components) { export const helpPaginationButton = { name: `${PAGINATION_PREFIX}_next`, async execute(interaction, client) { - const { currentPage, totalPages } = getPaginationInfo(interaction.message?.components); + try { + if (!interaction.deferred && !interaction.replied) { + await interaction.deferUpdate(); + } - let nextPage = currentPage; - switch (interaction.customId) { - case `${PAGINATION_PREFIX}_first`: - nextPage = 1; - break; - case `${PAGINATION_PREFIX}_prev`: - nextPage = Math.max(1, currentPage - 1); - break; - case `${PAGINATION_PREFIX}_next`: - nextPage = Math.min(totalPages, currentPage + 1); - break; - case `${PAGINATION_PREFIX}_last`: - nextPage = totalPages; - break; - default: - nextPage = currentPage; - break; - } + const { currentPage, totalPages } = getPaginationInfo(interaction.message?.components); + + let nextPage = currentPage; + switch (interaction.customId) { + case `${PAGINATION_PREFIX}_first`: + nextPage = 1; + break; + case `${PAGINATION_PREFIX}_prev`: + nextPage = Math.max(1, currentPage - 1); + break; + case `${PAGINATION_PREFIX}_next`: + nextPage = Math.min(totalPages, currentPage + 1); + break; + case `${PAGINATION_PREFIX}_last`: + nextPage = totalPages; + break; + default: + nextPage = currentPage; + break; + } - const { embeds, components } = await createAllCommandsMenu(nextPage, client); - await interaction.update({ embeds, components }); + const { embeds, components } = await createAllCommandsMenu(nextPage, client); + await interaction.editReply({ embeds, components }); + } catch (error) { + if (error?.code === 40060 || error?.code === 10062) { + logger.warn('Help pagination interaction already acknowledged or expired.', { + event: 'interaction.help.pagination.unavailable', + errorCode: String(error.code), + customId: interaction.customId, + interactionId: interaction.id, + }); + return; + } + + throw error; + } }, }; diff --git a/src/handlers/helpSelectMenuLoader.js b/src/handlers/helpSelectMenuLoader.js deleted file mode 100644 index 0b2323e626..0000000000 --- a/src/handlers/helpSelectMenuLoader.js +++ /dev/null @@ -1,20 +0,0 @@ -import { helpCategorySelectMenu } from './helpSelectMenus.js'; -import { logger } from '../utils/logger.js'; - - - - - -export default function loadHelpSelectMenus(client) { - try { - client.selectMenus.set(helpCategorySelectMenu.name, helpCategorySelectMenu); - - if (process.env.NODE_ENV !== 'production') { - logger.debug('Help select menu handlers loaded'); - } - } catch (error) { - logger.error('Error loading help select menu handlers:', error); - } -} - - diff --git a/src/handlers/helpSelectMenus.js b/src/handlers/helpSelectMenus.js index 2bb6555191..b71a26b3d2 100644 --- a/src/handlers/helpSelectMenus.js +++ b/src/handlers/helpSelectMenus.js @@ -3,7 +3,7 @@ import { createButton, getPaginationRow } from '../utils/components.js'; import fs from 'fs/promises'; import path from 'path'; import { fileURLToPath } from 'url'; -import { Collection, ActionRowBuilder } from 'discord.js'; +import { Collection, ActionRowBuilder, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; const __filename = fileURLToPath(import.meta.url); @@ -367,27 +367,41 @@ export const helpCategorySelectMenu = { name: CATEGORY_SELECT_ID, async execute(interaction, client) { try { + if (!interaction.deferred && !interaction.replied) { + await interaction.deferUpdate(); + } + const selectedCategory = interaction.values[0]; if (selectedCategory === ALL_COMMANDS_ID) { const { embeds, components } = await createAllCommandsMenu(1, client); - await interaction.update({ + await interaction.editReply({ embeds, components, }); } else { const { embeds, components } = await createCategoryCommandsMenu(selectedCategory, client); - await interaction.update({ + await interaction.editReply({ embeds, components, }); } } catch (error) { + if (error?.code === 40060 || error?.code === 10062) { + logger.warn('Help category select interaction already acknowledged or expired.', { + event: 'interaction.help.select.unavailable', + errorCode: String(error.code), + customId: interaction.customId, + interactionId: interaction.id, + }); + return; + } + logger.error('Error in help category select menu handler:', error); if (!interaction.replied && !interaction.deferred) { await interaction.reply({ content: 'An error occurred while loading help categories.', - ephemeral: true, + flags: MessageFlags.Ephemeral, }); } } diff --git a/src/handlers/interactionHandlers/goodbyeConfigButton.js b/src/handlers/interactionHandlers/goodbyeConfigButton.js new file mode 100644 index 0000000000..93b7ea9c3a --- /dev/null +++ b/src/handlers/interactionHandlers/goodbyeConfigButton.js @@ -0,0 +1,59 @@ +import { MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + GOODBYE_CONFIG_ACTIONS, + buildGoodbyeConfigModal, + buildGoodbyeConfigPayload, + hasGoodbyeSetup +} from '../../commands/Welcome/modules/goodbyeConfig.js'; + +export async function handleGoodbyeConfigButton(interaction, client, args) { + const action = args[0]; + + if (!GOODBYE_CONFIG_ACTIONS.includes(action)) { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That goodbye config action is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + const config = await getWelcomeConfig(client, interaction.guildId); + if (!hasGoodbyeSetup(config)) { + await interaction.reply({ + embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + if (action === 'ping') { + const newPingValue = !Boolean(config.goodbyePing); + await updateWelcomeConfig(client, interaction.guildId, { goodbyePing: newPingValue }); + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.update( + buildGoodbyeConfigPayload( + interaction.guild, + updatedConfig, + `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` + ) + ); + + logger.info(`[Goodbye Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); + return; + } + + const modal = buildGoodbyeConfigModal(action, config); + if (!modal) { + await interaction.reply({ + embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.showModal(modal); +} diff --git a/src/handlers/interactionHandlers/goodbyeConfigModal.js b/src/handlers/interactionHandlers/goodbyeConfigModal.js new file mode 100644 index 0000000000..19dc944c8d --- /dev/null +++ b/src/handlers/interactionHandlers/goodbyeConfigModal.js @@ -0,0 +1,119 @@ +import { ChannelType, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + GOODBYE_CONFIG_ACTIONS, + buildGoodbyeConfigPayload, + hasGoodbyeSetup, + isValidImageUrl, + parseChannelInput +} from '../../commands/Welcome/modules/goodbyeConfig.js'; + +export async function handleGoodbyeConfigModal(interaction, client, args) { + const action = args[0]; + + if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That goodbye config form is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const currentConfig = await getWelcomeConfig(client, interaction.guildId); + if (!hasGoodbyeSetup(currentConfig)) { + await interaction.editReply({ + embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')] + }); + return; + } + + const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; + + try { + if (action === 'channel') { + const channelId = parseChannelInput(inputValue); + if (!channelId) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] + }); + return; + } + + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { goodbyeChannelId: channel.id }); + } + + if (action === 'message') { + if (!inputValue) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Message', 'Goodbye message cannot be empty.')] + }); + return; + } + + if (inputValue.length > 2000) { + await interaction.editReply({ + embeds: [errorEmbed('Message Too Long', 'Goodbye message must be 2000 characters or less.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { + leaveMessage: inputValue, + leaveEmbed: { + ...(currentConfig.leaveEmbed || {}), + description: inputValue + } + }); + } + + if (action === 'image') { + if (inputValue && !isValidImageUrl(inputValue)) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] + }); + return; + } + + const existingLeaveEmbed = currentConfig.leaveEmbed || {}; + const nextLeaveEmbed = { ...existingLeaveEmbed }; + + if (inputValue) { + nextLeaveEmbed.image = inputValue; + } else { + delete nextLeaveEmbed.image; + } + + await updateWelcomeConfig(client, interaction.guildId, { + leaveEmbed: nextLeaveEmbed + }); + } + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.editReply( + buildGoodbyeConfigPayload( + interaction.guild, + updatedConfig, + `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` + ) + ); + + logger.info(`[Goodbye Config] ${action} updated in guild ${interaction.guildId}`); + } catch (error) { + logger.error(`[Goodbye Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); + await interaction.editReply({ + embeds: [errorEmbed('Update Failed', 'An error occurred while updating your goodbye config.')] + }); + } +} diff --git a/src/handlers/interactionHandlers/reactionRolesSelectMenu.js b/src/handlers/interactionHandlers/reactionRolesSelectMenu.js new file mode 100644 index 0000000000..969e797428 --- /dev/null +++ b/src/handlers/interactionHandlers/reactionRolesSelectMenu.js @@ -0,0 +1,205 @@ +import { EmbedBuilder, MessageFlags } from 'discord.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getColor } from '../../config/bot.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; +import { getReactionRoleMessage } from '../../services/reactionRoleService.js'; + +export async function handleReactionRolesSelectMenu(interaction, client) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + if (!deferSuccess) return; + + if (!interaction.inGuild() || !interaction.guild || !interaction.member) { + throw createError( + 'Reaction role interaction used outside a guild context', + ErrorTypes.VALIDATION, + 'This reaction role menu can only be used inside a server.', + { userId: interaction.user.id } + ); + } + + logger.debug(`Reaction role select menu interaction by ${interaction.user.tag} on message ${interaction.message.id}`); + + const reactionRoleData = await getReactionRoleMessage(client, interaction.guildId, interaction.message.id); + + if (!reactionRoleData) { + logger.warn(`Reaction role data not found for message ${interaction.message.id} in guild ${interaction.guildId}`); + return interaction.editReply({ + embeds: [ + new EmbedBuilder() + .setDescription('โŒ This reaction role message is no longer active.') + .setColor(getColor('error')) + ] + }); + } + + const member = interaction.member; + const selectedRoleIds = interaction.values; + + const me = interaction.guild.members.me ?? await interaction.guild.members.fetchMe().catch(() => null); + + if (!me) { + throw createError( + 'Unable to fetch bot member for permission validation', + ErrorTypes.PERMISSION, + 'I could not verify my server permissions. Please try again.', + { guildId: interaction.guildId } + ); + } + + if (!me.permissions.has('ManageRoles')) { + throw createError( + 'Bot missing ManageRoles permission', + ErrorTypes.PERMISSION, + 'I do not have permission to manage roles in this server.', + { guildId: interaction.guildId } + ); + } + + const botRolePosition = me.roles.highest.position; + + const availableRoleIds = Array.isArray(reactionRoleData.roles) + ? reactionRoleData.roles + : (typeof reactionRoleData.roles === 'object' ? Object.values(reactionRoleData.roles) : []); + + const addedRoles = []; + const removedRoles = []; + const skippedRoles = []; + + for (const roleId of selectedRoleIds) { + if (!availableRoleIds.includes(roleId)) { + logger.warn(`Role ${roleId} not in available roles for message ${interaction.message.id}`); + continue; + } + + const role = interaction.guild.roles.cache.get(roleId); + if (!role) { + logger.warn(`Role ${roleId} not found in guild ${interaction.guildId}`); + skippedRoles.push(roleId); + continue; + } + + const roleHasDangerousPermissions = role.permissions.has([ + 'Administrator', + 'ManageGuild', + 'ManageRoles', + 'ManageChannels', + 'ManageWebhooks', + 'BanMembers', + 'KickMembers', + 'MentionEveryone' + ]); + + if (role.managed || roleHasDangerousPermissions) { + logger.warn(`Blocked self-assignment for protected role ${role.name} (${roleId})`); + skippedRoles.push(role.name); + continue; + } + + if (role.position >= botRolePosition) { + logger.warn(`Cannot assign role ${role.name} (${roleId}), hierarchy issue`); + skippedRoles.push(role.name); + continue; + } + + if (!member.roles.cache.has(roleId)) { + try { + await member.roles.add(role); + addedRoles.push(role.name); + logger.debug(`Added role ${role.name} to ${member.user.tag}`); + } catch (roleError) { + logger.error(`Failed to add role ${role.name} to ${member.user.tag}:`, roleError); + skippedRoles.push(role.name); + } + } + } + + for (const roleId of availableRoleIds) { + if (selectedRoleIds.includes(roleId)) continue; + + const role = interaction.guild.roles.cache.get(roleId); + if (!role) continue; + + if (role.position >= botRolePosition) continue; + + if (member.roles.cache.has(roleId)) { + try { + await member.roles.remove(role); + removedRoles.push(role.name); + logger.debug(`Removed role ${role.name} from ${member.user.tag}`); + } catch (roleError) { + logger.error(`Failed to remove role ${role.name} from ${member.user.tag}:`, roleError); + } + } + } + + let description = '๐ŸŽญ **Roles updated successfully!**\n\n'; + + if (addedRoles.length > 0) { + description += `โœ… **Added:** ${addedRoles.map(name => `**${name}**`).join(', ')}\n`; + } + + if (removedRoles.length > 0) { + description += `โŒ **Removed:** ${removedRoles.map(name => `**${name}**`).join(', ')}\n`; + } + + if (addedRoles.length === 0 && removedRoles.length === 0) { + description += 'No changes were made to your roles.'; + } + + if (skippedRoles.length > 0) { + description += `\nโš ๏ธ **Skipped:** ${skippedRoles.length} role${skippedRoles.length !== 1 ? 's' : ''} (permission issues)`; + } + + const responseEmbed = new EmbedBuilder() + .setDescription(description) + .setColor(getColor('success')) + .setTimestamp(); + + await interaction.editReply({ embeds: [responseEmbed] }); + + if (addedRoles.length > 0 || removedRoles.length > 0) { + try { + await logEvent({ + client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.REACTION_ROLE_UPDATE, + data: { + description: `Reaction roles updated for ${member.user.tag}`, + userId: member.user.id, + channelId: interaction.channelId, + fields: [ + { + name: '๐Ÿ‘ค Member', + value: `${member.user.tag} (${member.user.id})`, + inline: false + }, + ...(addedRoles.length > 0 ? [{ + name: 'โœ… Roles Added', + value: addedRoles.join(', '), + inline: false + }] : []), + ...(removedRoles.length > 0 ? [{ + name: 'โŒ Roles Removed', + value: removedRoles.join(', '), + inline: false + }] : []) + ] + } + }); + } catch (logError) { + logger.warn('Failed to log reaction role update:', logError); + } + } + + logger.info(`Reaction roles updated for ${member.user.tag}: +${addedRoles.length}, -${removedRoles.length}`); + + } catch (error) { + await handleInteractionError(interaction, error, { + type: 'select_menu', + customId: 'reaction_roles' + }); + } +} diff --git a/src/handlers/interactionHandlers/welcomeConfigButton.js b/src/handlers/interactionHandlers/welcomeConfigButton.js new file mode 100644 index 0000000000..77847a6a43 --- /dev/null +++ b/src/handlers/interactionHandlers/welcomeConfigButton.js @@ -0,0 +1,59 @@ +import { MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + WELCOME_CONFIG_ACTIONS, + buildWelcomeConfigModal, + buildWelcomeConfigPayload, + hasWelcomeSetup +} from '../../commands/Welcome/modules/welcomeConfig.js'; + +export async function handleWelcomeConfigButton(interaction, client, args) { + const action = args[0]; + + if (!WELCOME_CONFIG_ACTIONS.includes(action)) { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That welcome config action is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + const config = await getWelcomeConfig(client, interaction.guildId); + if (!hasWelcomeSetup(config)) { + await interaction.reply({ + embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + if (action === 'ping') { + const newPingValue = !Boolean(config.welcomePing); + await updateWelcomeConfig(client, interaction.guildId, { welcomePing: newPingValue }); + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.update( + buildWelcomeConfigPayload( + interaction.guild, + updatedConfig, + `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` + ) + ); + + logger.info(`[Welcome Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); + return; + } + + const modal = buildWelcomeConfigModal(action, config); + if (!modal) { + await interaction.reply({ + embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.showModal(modal); +} diff --git a/src/handlers/interactionHandlers/welcomeConfigModal.js b/src/handlers/interactionHandlers/welcomeConfigModal.js new file mode 100644 index 0000000000..ce49097f84 --- /dev/null +++ b/src/handlers/interactionHandlers/welcomeConfigModal.js @@ -0,0 +1,104 @@ +import { ChannelType, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; +import { + WELCOME_CONFIG_ACTIONS, + buildWelcomeConfigPayload, + hasWelcomeSetup, + isValidImageUrl, + parseChannelInput +} from '../../commands/Welcome/modules/welcomeConfig.js'; + +export async function handleWelcomeConfigModal(interaction, client, args) { + const action = args[0]; + + if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { + await interaction.reply({ + embeds: [errorEmbed('Invalid Option', 'That welcome config form is not supported.')], + flags: MessageFlags.Ephemeral + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const currentConfig = await getWelcomeConfig(client, interaction.guildId); + if (!hasWelcomeSetup(currentConfig)) { + await interaction.editReply({ + embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')] + }); + return; + } + + const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; + + try { + if (action === 'channel') { + const channelId = parseChannelInput(inputValue); + if (!channelId) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] + }); + return; + } + + const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); + if (!channel || channel.type !== ChannelType.GuildText) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { channelId: channel.id }); + } + + if (action === 'message') { + if (!inputValue) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Message', 'Welcome message cannot be empty.')] + }); + return; + } + + if (inputValue.length > 2000) { + await interaction.editReply({ + embeds: [errorEmbed('Message Too Long', 'Welcome message must be 2000 characters or less.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { welcomeMessage: inputValue }); + } + + if (action === 'image') { + if (inputValue && !isValidImageUrl(inputValue)) { + await interaction.editReply({ + embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] + }); + return; + } + + await updateWelcomeConfig(client, interaction.guildId, { + welcomeImage: inputValue || null + }); + } + + const updatedConfig = await getWelcomeConfig(client, interaction.guildId); + await interaction.editReply( + buildWelcomeConfigPayload( + interaction.guild, + updatedConfig, + `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` + ) + ); + + logger.info(`[Welcome Config] ${action} updated in guild ${interaction.guildId}`); + } catch (error) { + logger.error(`[Welcome Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); + await interaction.editReply({ + embeds: [errorEmbed('Update Failed', 'An error occurred while updating your welcome config.')] + }); + } +} diff --git a/src/handlers/interactions.js b/src/handlers/interactions.js index 626feefee6..ba1b0e8a30 100644 --- a/src/handlers/interactions.js +++ b/src/handlers/interactions.js @@ -18,24 +18,30 @@ export default async (client) => { try { const interactionFiles = (await readdir(typePath)).filter(file => file.endsWith('.js')); + let loadedCount = 0; for (const file of interactionFiles) { try { - const interaction = (await import(`../interactions/${type}/${file}`)).default; - - if (!interaction.name || !interaction.execute) { - logger.warn(`Interaction ${file} in ${type} is missing required properties.`); - continue; + const module = await import(`../interactions/${type}/${file}`); + const moduleExport = module.default; + const interactions = Array.isArray(moduleExport) ? moduleExport : [moduleExport]; + + for (const interaction of interactions) { + if (!interaction?.name || !interaction?.execute) { + logger.warn(`Interaction ${file} in ${type} is missing required properties.`); + continue; + } + + client[type].set(interaction.name, interaction); + loadedCount += 1; + logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name}`); } - - client[type].set(interaction.name, interaction); - logger.info(`Loaded ${type.slice(0, -1)}: ${interaction.name}`); } catch (error) { logger.error(`Error loading interaction ${file} in ${type}:`, error); } } - - logger.info(`Loaded ${interactionFiles.length} ${type}`); + + logger.info(`Loaded ${loadedCount} ${type}`); } catch (error) { if (error.code !== 'ENOENT') { logger.error(`Error loading ${type}:`, error); diff --git a/src/handlers/loggingButtonLoader.js b/src/handlers/loggingButtonLoader.js deleted file mode 100644 index dc52c8f59a..0000000000 --- a/src/handlers/loggingButtonLoader.js +++ /dev/null @@ -1,12 +0,0 @@ -import loggingButtonsHandler from './loggingButtons.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadLoggingButtons(client) { - try { - client.buttons.set('logging_toggle', loggingButtonsHandler); - client.buttons.set('logging_refresh_status', loggingButtonsHandler); - logger.info('Logging button handlers loaded'); - } catch (error) { - logger.error('Error loading logging button handlers:', error); - } -} diff --git a/src/handlers/ticketButtonLoader.js b/src/handlers/ticketButtonLoader.js deleted file mode 100644 index e256567727..0000000000 --- a/src/handlers/ticketButtonLoader.js +++ /dev/null @@ -1,34 +0,0 @@ -import createTicketHandler, { - createTicketModalHandler, - closeTicketModalHandler, - closeTicketHandler, - claimTicketHandler, - priorityTicketHandler, - transcriptTicketHandler, - unclaimTicketHandler, - reopenTicketHandler, - deleteTicketHandler -} from './ticketButtons.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadTicketButtons(client) { - try { - client.buttons.set('create_ticket', createTicketHandler); - client.buttons.set('ticket_close', closeTicketHandler); - client.buttons.set('ticket_claim', claimTicketHandler); - client.buttons.set('ticket_priority', priorityTicketHandler); - client.buttons.set('ticket_transcript', transcriptTicketHandler); - client.buttons.set('ticket_unclaim', unclaimTicketHandler); - client.buttons.set('ticket_reopen', reopenTicketHandler); - client.buttons.set('ticket_delete', deleteTicketHandler); - - client.modals.set('create_ticket_modal', createTicketModalHandler); - client.modals.set('ticket_close_modal', closeTicketModalHandler); - - logger.info('Ticket button handlers loaded'); - } catch (error) { - logger.error('Error loading ticket button handlers:', error); - } -} - - diff --git a/src/handlers/todoButtonLoader.js b/src/handlers/todoButtonLoader.js deleted file mode 100644 index f6caf78031..0000000000 --- a/src/handlers/todoButtonLoader.js +++ /dev/null @@ -1,21 +0,0 @@ -import todoAddHandler, { sharedTodoCompleteHandler, sharedTodoRemoveHandler, sharedTodoAddModalHandler, sharedTodoCompleteModalHandler, sharedTodoRemoveModalHandler } from './todoButtons.js'; -import { logger } from '../utils/logger.js'; - -export default async function loadTodoButtons(client) { - try { - client.buttons.set('shared_todo_add', todoAddHandler); - - client.buttons.set('shared_todo_complete', sharedTodoCompleteHandler); - client.buttons.set('shared_todo_remove', sharedTodoRemoveHandler); - - client.modals.set('shared_todo_add_modal', sharedTodoAddModalHandler); - client.modals.set('shared_todo_complete_modal', sharedTodoCompleteModalHandler); - client.modals.set('shared_todo_remove_modal', sharedTodoRemoveModalHandler); - - logger.info('Todo button handlers loaded'); - } catch (error) { - logger.error('Error loading todo button handlers:', error); - } -} - - diff --git a/src/handlers/verificationButtonLoader.js b/src/handlers/verificationButtonLoader.js deleted file mode 100644 index 00b40891e5..0000000000 --- a/src/handlers/verificationButtonLoader.js +++ /dev/null @@ -1,20 +0,0 @@ -import verificationButtonHandler from './verificationButtons.js'; -import { logger } from '../utils/logger.js'; - - - - - -export async function loadVerificationButtons(client) { - try { - client.buttons.set(verificationButtonHandler.customId, verificationButtonHandler); - logger.info('Verification button handler loaded'); - } catch (error) { - logger.error('Error loading verification button handler:', error); - } -} - -export default loadVerificationButtons; - - - diff --git a/src/handlers/wipedataButtonLoader.js b/src/handlers/wipedataButtonLoader.js deleted file mode 100644 index cc8a3356b3..0000000000 --- a/src/handlers/wipedataButtonLoader.js +++ /dev/null @@ -1,19 +0,0 @@ -import { wipedataConfirmHandler, wipedataCancelHandler } from './wipedataButtons.js'; -import { logger } from '../utils/logger.js'; - - - - - -export default async function loadWipedataButtons(client) { - try { - client.buttons.set('wipedata_yes', wipedataConfirmHandler); - client.buttons.set('wipedata_no', wipedataCancelHandler); - - logger.info('Wipedata button handlers loaded'); - } catch (error) { - logger.error('Error loading wipedata button handlers:', error); - } -} - - diff --git a/src/interactions/buttons/countdown.js b/src/interactions/buttons/countdown.js new file mode 100644 index 0000000000..6503f40ca5 --- /dev/null +++ b/src/interactions/buttons/countdown.js @@ -0,0 +1,12 @@ +import countdownButtonHandler from '../../handlers/countdownButtons.js'; + +export default [ + { + name: 'countdown_pause', + execute: countdownButtonHandler, + }, + { + name: 'countdown_cancel', + execute: countdownButtonHandler, + }, +]; \ No newline at end of file diff --git a/src/interactions/buttons/counterDelete.js b/src/interactions/buttons/counterDelete.js new file mode 100644 index 0000000000..df171dcb10 --- /dev/null +++ b/src/interactions/buttons/counterDelete.js @@ -0,0 +1,3 @@ +import counterDeleteActionHandler from '../../handlers/counterButtons.js'; + +export default counterDeleteActionHandler; \ No newline at end of file diff --git a/src/interactions/buttons/giveaway.js b/src/interactions/buttons/giveaway.js new file mode 100644 index 0000000000..962b6f5da4 --- /dev/null +++ b/src/interactions/buttons/giveaway.js @@ -0,0 +1,20 @@ +import { + giveawayJoinHandler, + giveawayEndHandler, + giveawayRerollHandler, + giveawayViewHandler, +} from '../../handlers/giveawayButtons.js'; + +function fromCustomId(handler) { + return { + name: handler.customId, + execute: handler.execute, + }; +} + +export default [ + fromCustomId(giveawayJoinHandler), + fromCustomId(giveawayEndHandler), + fromCustomId(giveawayRerollHandler), + fromCustomId(giveawayViewHandler), +]; \ No newline at end of file diff --git a/src/interactions/buttons/goodbyeConfig.js b/src/interactions/buttons/goodbyeConfig.js index 2a082b166a..2016b03be6 100644 --- a/src/interactions/buttons/goodbyeConfig.js +++ b/src/interactions/buttons/goodbyeConfig.js @@ -1,62 +1,8 @@ -import { MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - GOODBYE_CONFIG_ACTIONS, - buildGoodbyeConfigModal, - buildGoodbyeConfigPayload, - hasGoodbyeSetup -} from '../../commands/Welcome/modules/goodbyeConfig.js'; +import { handleGoodbyeConfigButton } from '../../handlers/interactionHandlers/goodbyeConfigButton.js'; export default { name: 'goodbye_config', async execute(interaction, client, args) { - const action = args[0]; - - if (!GOODBYE_CONFIG_ACTIONS.includes(action)) { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That goodbye config action is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const config = await getWelcomeConfig(client, interaction.guildId); - if (!hasGoodbyeSetup(config)) { - await interaction.reply({ - embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - if (action === 'ping') { - const newPingValue = !Boolean(config.goodbyePing); - await updateWelcomeConfig(client, interaction.guildId, { goodbyePing: newPingValue }); - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.update( - buildGoodbyeConfigPayload( - interaction.guild, - updatedConfig, - `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` - ) - ); - - logger.info(`[Goodbye Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); - return; - } - - const modal = buildGoodbyeConfigModal(action, config); - if (!modal) { - await interaction.reply({ - embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.showModal(modal); + return handleGoodbyeConfigButton(interaction, client, args); } }; \ No newline at end of file diff --git a/src/interactions/buttons/help.js b/src/interactions/buttons/help.js new file mode 100644 index 0000000000..9e7a011262 --- /dev/null +++ b/src/interactions/buttons/help.js @@ -0,0 +1,19 @@ +import { + helpBackButton, + helpBugReportButton, + helpPaginationButton, +} from '../../handlers/helpButtons.js'; + +const paginationIds = [ + 'help-page_first', + 'help-page_prev', + 'help-page_next', + 'help-page_last', +]; + +const paginationInteractions = paginationIds.map((name) => ({ + name, + execute: helpPaginationButton.execute, +})); + +export default [helpBackButton, helpBugReportButton, ...paginationInteractions]; \ No newline at end of file diff --git a/src/interactions/buttons/logging.js b/src/interactions/buttons/logging.js new file mode 100644 index 0000000000..7ee27ae635 --- /dev/null +++ b/src/interactions/buttons/logging.js @@ -0,0 +1,12 @@ +import loggingButtonsHandler from '../../handlers/loggingButtons.js'; + +export default [ + { + name: 'logging_toggle', + execute: loggingButtonsHandler.execute, + }, + { + name: 'logging_refresh_status', + execute: loggingButtonsHandler.execute, + }, +]; \ No newline at end of file diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js new file mode 100644 index 0000000000..b65be15517 --- /dev/null +++ b/src/interactions/buttons/ticket.js @@ -0,0 +1,20 @@ +import createTicketHandler, { + closeTicketHandler, + claimTicketHandler, + priorityTicketHandler, + transcriptTicketHandler, + unclaimTicketHandler, + reopenTicketHandler, + deleteTicketHandler, +} from '../../handlers/ticketButtons.js'; + +export default [ + createTicketHandler, + closeTicketHandler, + claimTicketHandler, + priorityTicketHandler, + transcriptTicketHandler, + unclaimTicketHandler, + reopenTicketHandler, + deleteTicketHandler, +]; \ No newline at end of file diff --git a/src/interactions/buttons/todoShared.js b/src/interactions/buttons/todoShared.js new file mode 100644 index 0000000000..02b5fb2009 --- /dev/null +++ b/src/interactions/buttons/todoShared.js @@ -0,0 +1,10 @@ +import todoAddHandler, { + sharedTodoCompleteHandler, + sharedTodoRemoveHandler, +} from '../../handlers/todoButtons.js'; + +export default [ + todoAddHandler, + sharedTodoCompleteHandler, + sharedTodoRemoveHandler, +]; \ No newline at end of file diff --git a/src/interactions/buttons/verification.js b/src/interactions/buttons/verification.js new file mode 100644 index 0000000000..f0bc0887e3 --- /dev/null +++ b/src/interactions/buttons/verification.js @@ -0,0 +1,6 @@ +import verificationButtonHandler from '../../handlers/verificationButtons.js'; + +export default { + name: verificationButtonHandler.customId, + execute: verificationButtonHandler.execute, +}; \ No newline at end of file diff --git a/src/interactions/buttons/welcomeConfig.js b/src/interactions/buttons/welcomeConfig.js index a7ea9c96b8..84359d6e2e 100644 --- a/src/interactions/buttons/welcomeConfig.js +++ b/src/interactions/buttons/welcomeConfig.js @@ -1,62 +1,8 @@ -import { MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - WELCOME_CONFIG_ACTIONS, - buildWelcomeConfigModal, - buildWelcomeConfigPayload, - hasWelcomeSetup -} from '../../commands/Welcome/modules/welcomeConfig.js'; +import { handleWelcomeConfigButton } from '../../handlers/interactionHandlers/welcomeConfigButton.js'; export default { name: 'welcome_config', async execute(interaction, client, args) { - const action = args[0]; - - if (!WELCOME_CONFIG_ACTIONS.includes(action)) { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That welcome config action is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - const config = await getWelcomeConfig(client, interaction.guildId); - if (!hasWelcomeSetup(config)) { - await interaction.reply({ - embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - if (action === 'ping') { - const newPingValue = !Boolean(config.welcomePing); - await updateWelcomeConfig(client, interaction.guildId, { welcomePing: newPingValue }); - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.update( - buildWelcomeConfigPayload( - interaction.guild, - updatedConfig, - `Ping setting updated: ${newPingValue ? 'Enabled' : 'Disabled'}.` - ) - ); - - logger.info(`[Welcome Config] Ping toggled to ${newPingValue} in guild ${interaction.guildId}`); - return; - } - - const modal = buildWelcomeConfigModal(action, config); - if (!modal) { - await interaction.reply({ - embeds: [errorEmbed('Config Action Failed', 'Unable to open the selected config form.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.showModal(modal); + return handleWelcomeConfigButton(interaction, client, args); } }; \ No newline at end of file diff --git a/src/interactions/buttons/wipedata.js b/src/interactions/buttons/wipedata.js new file mode 100644 index 0000000000..38d910b145 --- /dev/null +++ b/src/interactions/buttons/wipedata.js @@ -0,0 +1,6 @@ +import { + wipedataConfirmHandler, + wipedataCancelHandler, +} from '../../handlers/wipedataButtons.js'; + +export default [wipedataConfirmHandler, wipedataCancelHandler]; \ No newline at end of file diff --git a/src/interactions/modals/calculate.js b/src/interactions/modals/calculate.js new file mode 100644 index 0000000000..be3ab8fb20 --- /dev/null +++ b/src/interactions/modals/calculate.js @@ -0,0 +1,10 @@ +import calculateModalHandler from '../../handlers/calculateModals.js'; + +const execute = typeof calculateModalHandler === 'function' + ? calculateModalHandler + : calculateModalHandler.execute; + +export default { + name: 'calc_modal', + execute, +}; \ No newline at end of file diff --git a/src/interactions/modals/goodbyeConfigModal.js b/src/interactions/modals/goodbyeConfigModal.js index 136cef12b3..0f329fc73b 100644 --- a/src/interactions/modals/goodbyeConfigModal.js +++ b/src/interactions/modals/goodbyeConfigModal.js @@ -1,122 +1,8 @@ -import { ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - GOODBYE_CONFIG_ACTIONS, - buildGoodbyeConfigPayload, - hasGoodbyeSetup, - isValidImageUrl, - parseChannelInput -} from '../../commands/Welcome/modules/goodbyeConfig.js'; +import { handleGoodbyeConfigModal } from '../../handlers/interactionHandlers/goodbyeConfigModal.js'; export default { name: 'goodbye_config_modal', async execute(interaction, client, args) { - const action = args[0]; - - if (!GOODBYE_CONFIG_ACTIONS.includes(action) || action === 'ping') { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That goodbye config form is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const currentConfig = await getWelcomeConfig(client, interaction.guildId); - if (!hasGoodbyeSetup(currentConfig)) { - await interaction.editReply({ - embeds: [errorEmbed('No Goodbye Setup Found', 'Set up goodbye first using **/goodbye setup**.')] - }); - return; - } - - const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; - - try { - if (action === 'channel') { - const channelId = parseChannelInput(inputValue); - if (!channelId) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] - }); - return; - } - - const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { goodbyeChannelId: channel.id }); - } - - if (action === 'message') { - if (!inputValue) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Message', 'Goodbye message cannot be empty.')] - }); - return; - } - - if (inputValue.length > 2000) { - await interaction.editReply({ - embeds: [errorEmbed('Message Too Long', 'Goodbye message must be 2000 characters or less.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { - leaveMessage: inputValue, - leaveEmbed: { - ...(currentConfig.leaveEmbed || {}), - description: inputValue - } - }); - } - - if (action === 'image') { - if (inputValue && !isValidImageUrl(inputValue)) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] - }); - return; - } - - const existingLeaveEmbed = currentConfig.leaveEmbed || {}; - const nextLeaveEmbed = { ...existingLeaveEmbed }; - - if (inputValue) { - nextLeaveEmbed.image = inputValue; - } else { - delete nextLeaveEmbed.image; - } - - await updateWelcomeConfig(client, interaction.guildId, { - leaveEmbed: nextLeaveEmbed - }); - } - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.editReply( - buildGoodbyeConfigPayload( - interaction.guild, - updatedConfig, - `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` - ) - ); - - logger.info(`[Goodbye Config] ${action} updated in guild ${interaction.guildId}`); - } catch (error) { - logger.error(`[Goodbye Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); - await interaction.editReply({ - embeds: [errorEmbed('Update Failed', 'An error occurred while updating your goodbye config.')] - }); - } + return handleGoodbyeConfigModal(interaction, client, args); } }; \ No newline at end of file diff --git a/src/interactions/modals/ticket.js b/src/interactions/modals/ticket.js new file mode 100644 index 0000000000..83eb50a8e5 --- /dev/null +++ b/src/interactions/modals/ticket.js @@ -0,0 +1,6 @@ +import { + createTicketModalHandler, + closeTicketModalHandler, +} from '../../handlers/ticketButtons.js'; + +export default [createTicketModalHandler, closeTicketModalHandler]; \ No newline at end of file diff --git a/src/interactions/modals/todoShared.js b/src/interactions/modals/todoShared.js new file mode 100644 index 0000000000..8b15ac337c --- /dev/null +++ b/src/interactions/modals/todoShared.js @@ -0,0 +1,11 @@ +import { + sharedTodoAddModalHandler, + sharedTodoCompleteModalHandler, + sharedTodoRemoveModalHandler, +} from '../../handlers/todoButtons.js'; + +export default [ + sharedTodoAddModalHandler, + sharedTodoCompleteModalHandler, + sharedTodoRemoveModalHandler, +]; \ No newline at end of file diff --git a/src/interactions/modals/welcomeConfigModal.js b/src/interactions/modals/welcomeConfigModal.js index 2c0b5c7263..7f741259b9 100644 --- a/src/interactions/modals/welcomeConfigModal.js +++ b/src/interactions/modals/welcomeConfigModal.js @@ -1,107 +1,8 @@ -import { ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getWelcomeConfig, updateWelcomeConfig } from '../../utils/database.js'; -import { - WELCOME_CONFIG_ACTIONS, - buildWelcomeConfigPayload, - hasWelcomeSetup, - isValidImageUrl, - parseChannelInput -} from '../../commands/Welcome/modules/welcomeConfig.js'; +import { handleWelcomeConfigModal } from '../../handlers/interactionHandlers/welcomeConfigModal.js'; export default { name: 'welcome_config_modal', async execute(interaction, client, args) { - const action = args[0]; - - if (!WELCOME_CONFIG_ACTIONS.includes(action) || action === 'ping') { - await interaction.reply({ - embeds: [errorEmbed('Invalid Option', 'That welcome config form is not supported.')], - flags: MessageFlags.Ephemeral - }); - return; - } - - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const currentConfig = await getWelcomeConfig(client, interaction.guildId); - if (!hasWelcomeSetup(currentConfig)) { - await interaction.editReply({ - embeds: [errorEmbed('No Welcome Setup Found', 'Set up welcome first using **/welcome setup**.')] - }); - return; - } - - const inputValue = interaction.fields.getTextInputValue('value')?.trim() || ''; - - try { - if (action === 'channel') { - const channelId = parseChannelInput(inputValue); - if (!channelId) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'Provide a valid channel mention or channel ID.')] - }); - return; - } - - const channel = await interaction.guild.channels.fetch(channelId).catch(() => null); - if (!channel || channel.type !== ChannelType.GuildText) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Channel', 'The channel must be a text channel in this server.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { channelId: channel.id }); - } - - if (action === 'message') { - if (!inputValue) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Message', 'Welcome message cannot be empty.')] - }); - return; - } - - if (inputValue.length > 2000) { - await interaction.editReply({ - embeds: [errorEmbed('Message Too Long', 'Welcome message must be 2000 characters or less.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { welcomeMessage: inputValue }); - } - - if (action === 'image') { - if (inputValue && !isValidImageUrl(inputValue)) { - await interaction.editReply({ - embeds: [errorEmbed('Invalid Image URL', 'Image URL must start with `http://` or `https://`.')] - }); - return; - } - - await updateWelcomeConfig(client, interaction.guildId, { - welcomeImage: inputValue || null - }); - } - - const updatedConfig = await getWelcomeConfig(client, interaction.guildId); - await interaction.editReply( - buildWelcomeConfigPayload( - interaction.guild, - updatedConfig, - `${action.charAt(0).toUpperCase()}${action.slice(1)} setting updated successfully.` - ) - ); - - logger.info(`[Welcome Config] ${action} updated in guild ${interaction.guildId}`); - } catch (error) { - logger.error(`[Welcome Config] Failed to update ${action} in guild ${interaction.guildId}:`, error); - await interaction.editReply({ - embeds: [errorEmbed('Update Failed', 'An error occurred while updating your welcome config.')] - }); - } + return handleWelcomeConfigModal(interaction, client, args); } }; \ No newline at end of file diff --git a/src/interactions/selectMenus/helpCategory.js b/src/interactions/selectMenus/helpCategory.js new file mode 100644 index 0000000000..ce224ea566 --- /dev/null +++ b/src/interactions/selectMenus/helpCategory.js @@ -0,0 +1,3 @@ +import { helpCategorySelectMenu } from '../../handlers/helpSelectMenus.js'; + +export default helpCategorySelectMenu; \ No newline at end of file diff --git a/src/interactions/selectMenus/reaction_roles.js b/src/interactions/selectMenus/reaction_roles.js index bedee457c6..bdacb5d3e7 100644 --- a/src/interactions/selectMenus/reaction_roles.js +++ b/src/interactions/selectMenus/reaction_roles.js @@ -1,229 +1,7 @@ -import { EmbedBuilder, MessageFlags } from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { getReactionRoleMessage } from '../../services/reactionRoleService.js'; - - - - - - +import { handleReactionRolesSelectMenu } from '../../handlers/interactionHandlers/reactionRolesSelectMenu.js'; export async function execute(interaction, client) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) return; - - if (!interaction.inGuild() || !interaction.guild || !interaction.member) { - throw createError( - 'Reaction role interaction used outside a guild context', - ErrorTypes.VALIDATION, - 'This reaction role menu can only be used inside a server.', - { userId: interaction.user.id } - ); - } - - logger.debug(`Reaction role select menu interaction by ${interaction.user.tag} on message ${interaction.message.id}`); - - - const reactionRoleData = await getReactionRoleMessage(client, interaction.guildId, interaction.message.id); - - if (!reactionRoleData) { - logger.warn(`Reaction role data not found for message ${interaction.message.id} in guild ${interaction.guildId}`); - return interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setDescription('โŒ This reaction role message is no longer active.') - .setColor(getColor('error')) - ] - }); - } - - const member = interaction.member; - const selectedRoleIds = interaction.values; - - - const me = interaction.guild.members.me ?? await interaction.guild.members.fetchMe().catch(() => null); - - if (!me) { - throw createError( - 'Unable to fetch bot member for permission validation', - ErrorTypes.PERMISSION, - 'I could not verify my server permissions. Please try again.', - { guildId: interaction.guildId } - ); - } - - if (!me.permissions.has('ManageRoles')) { - throw createError( - 'Bot missing ManageRoles permission', - ErrorTypes.PERMISSION, - 'I do not have permission to manage roles in this server.', - { guildId: interaction.guildId } - ); - } - - const botRolePosition = me.roles.highest.position; - - - let availableRoleIds; - if (Array.isArray(reactionRoleData.roles)) { - availableRoleIds = reactionRoleData.roles; - } else if (typeof reactionRoleData.roles === 'object') { - availableRoleIds = Object.values(reactionRoleData.roles); - } else { - availableRoleIds = []; - } - - const addedRoles = []; - const removedRoles = []; - const skippedRoles = []; - - - for (const roleId of selectedRoleIds) { - if (!availableRoleIds.includes(roleId)) { - logger.warn(`Role ${roleId} not in available roles for message ${interaction.message.id}`); - continue; - } - - const role = interaction.guild.roles.cache.get(roleId); - if (!role) { - logger.warn(`Role ${roleId} not found in guild ${interaction.guildId}`); - skippedRoles.push(roleId); - continue; - } - - const roleHasDangerousPermissions = role.permissions.has([ - 'Administrator', - 'ManageGuild', - 'ManageRoles', - 'ManageChannels', - 'ManageWebhooks', - 'BanMembers', - 'KickMembers', - 'MentionEveryone' - ]); - - if (role.managed || roleHasDangerousPermissions) { - logger.warn(`Blocked self-assignment for protected role ${role.name} (${roleId})`); - skippedRoles.push(role.name); - continue; - } - - - if (role.position >= botRolePosition) { - logger.warn(`Cannot assign role ${role.name} (${roleId}), hierarchy issue`); - skippedRoles.push(role.name); - continue; - } - - - if (!member.roles.cache.has(roleId)) { - try { - await member.roles.add(role); - addedRoles.push(role.name); - logger.debug(`Added role ${role.name} to ${member.user.tag}`); - } catch (roleError) { - logger.error(`Failed to add role ${role.name} to ${member.user.tag}:`, roleError); - skippedRoles.push(role.name); - } - } - } - - - for (const roleId of availableRoleIds) { - if (selectedRoleIds.includes(roleId)) continue; - - const role = interaction.guild.roles.cache.get(roleId); - if (!role) continue; - - - if (role.position >= botRolePosition) continue; - - - if (member.roles.cache.has(roleId)) { - try { - await member.roles.remove(role); - removedRoles.push(role.name); - logger.debug(`Removed role ${role.name} from ${member.user.tag}`); - } catch (roleError) { - logger.error(`Failed to remove role ${role.name} from ${member.user.tag}:`, roleError); - } - } - } - - - let description = '๐ŸŽญ **Roles updated successfully!**\n\n'; - - if (addedRoles.length > 0) { - description += `โœ… **Added:** ${addedRoles.map(name => `**${name}**`).join(', ')}\n`; - } - - if (removedRoles.length > 0) { - description += `โŒ **Removed:** ${removedRoles.map(name => `**${name}**`).join(', ')}\n`; - } - - if (addedRoles.length === 0 && removedRoles.length === 0) { - description += 'No changes were made to your roles.'; - } - - if (skippedRoles.length > 0) { - description += `\nโš ๏ธ **Skipped:** ${skippedRoles.length} role${skippedRoles.length !== 1 ? 's' : ''} (permission issues)`; - } - - const responseEmbed = new EmbedBuilder() - .setDescription(description) - .setColor(getColor('success')) - .setTimestamp(); - - await interaction.editReply({ embeds: [responseEmbed] }); - - // Log role changes to audit system - if (addedRoles.length > 0 || removedRoles.length > 0) { - try { - await logEvent({ - client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_UPDATE, - data: { - description: `Reaction roles updated for ${member.user.tag}`, - userId: member.user.id, - channelId: interaction.channelId, - fields: [ - { - name: '๐Ÿ‘ค Member', - value: `${member.user.tag} (${member.user.id})`, - inline: false - }, - ...(addedRoles.length > 0 ? [{ - name: 'โœ… Roles Added', - value: addedRoles.join(', '), - inline: false - }] : []), - ...(removedRoles.length > 0 ? [{ - name: 'โŒ Roles Removed', - value: removedRoles.join(', '), - inline: false - }] : []) - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role update:', logError); - } - } - - logger.info(`Reaction roles updated for ${member.user.tag}: +${addedRoles.length}, -${removedRoles.length}`); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'select_menu', - customId: 'reaction_roles' - }); - } + return handleReactionRolesSelectMenu(interaction, client); } export default { diff --git a/src/services/birthdayService.js b/src/services/birthdayService.js index b356ae5269..fa341d3e01 100644 --- a/src/services/birthdayService.js +++ b/src/services/birthdayService.js @@ -369,7 +369,7 @@ export async function checkBirthdays(client) { } delete updatedTrackingData[userId]; } catch (error) { - console.error(`Error removing birthday role from ${userId}:`, error); + logger.error(`Error removing birthday role from ${userId}:`, error); } } @@ -389,7 +389,7 @@ export async function checkBirthdays(client) { await member.roles.add(birthdayRoleId, "Happy Birthday! ๐ŸŽ‰"); updatedTrackingData[userId] = true; } catch (error) { - console.error(`Error adding birthday role to ${member.user.tag}:`, error); + logger.error(`Error adding birthday role to ${member.user.tag}:`, error); } } } diff --git a/src/services/configService.js b/src/services/configService.js index 7982b52bcc..5eafa8ccaf 100644 --- a/src/services/configService.js +++ b/src/services/configService.js @@ -22,6 +22,9 @@ import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './guildConfig.js'; import { PermissionFlagsBits } from 'discord.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; +import { wrapServiceClassMethods } from '../utils/serviceErrorBoundary.js'; +import { z } from 'zod'; +import { LogIgnoreSchema, LoggingConfigSchema } from '../utils/schemas.js'; const configChangeHistory = new Map(); @@ -51,6 +54,21 @@ const SETTING_CONFLICTS = { 'logging': ['logChannelId'] }; +const ConfigValueSchemas = Object.freeze({ + logChannelId: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + reportChannelId: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + premiumRoleId: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + autoRole: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + modRole: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + adminRole: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + prefix: z.string().min(1).max(10), + dmOnClose: z.boolean(), + maxTicketsPerUser: z.number().int().min(1).max(50), + birthdayChannelId: z.union([z.string().min(1), z.object({ id: z.string().min(1) })]), + logIgnore: LogIgnoreSchema, + logging: LoggingConfigSchema, +}); + class ConfigService { @@ -102,6 +120,27 @@ class ConfigService { return true; } + const zodSchema = ConfigValueSchemas[key]; + if (zodSchema) { + const parsed = zodSchema.safeParse(value); + if (!parsed.success) { + throw createError( + 'Invalid configuration value', + ErrorTypes.VALIDATION, + 'Provided configuration value is invalid.', + { + key, + errorCode: 'VALIDATION_FAILED', + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code + })) + } + ); + } + } + if (rule.type === 'channel') { if (typeof value !== 'string' && typeof value !== 'object') { @@ -637,4 +676,11 @@ class ConfigService { } } +wrapServiceClassMethods(ConfigService, (methodName) => ({ + service: 'ConfigService', + operation: methodName, + message: `Configuration service operation failed: ${methodName}`, + userMessage: 'A configuration operation failed. Please try again in a moment.' +})); + export default ConfigService; diff --git a/src/services/database.js b/src/services/database.js index 5daf1bdcb6..12e1d0dadb 100644 --- a/src/services/database.js +++ b/src/services/database.js @@ -1,343 +1,4 @@ -import 'dotenv/config'; -import { pgDb } from '../utils/postgresDatabase.js'; -import { logger } from '../utils/logger.js'; -import { pgConfig } from '../config/postgres.js'; - -let db = null; -let useFallback = false; -let connectionType = 'none'; -const IS_PRODUCTION = process.env.NODE_ENV === 'production'; - -async function initializeServicesDatabase() { - try { - logger.info('Services: Attempting to connect to PostgreSQL...'); - const pgConnected = await pgDb.connect(); - if (pgConnected) { - db = pgDb; - connectionType = 'postgresql'; - logger.info('โœ… Services: PostgreSQL Database initialized'); - return; - } - if (IS_PRODUCTION) { - logger.error('Services: PostgreSQL connection unavailable in production. Refusing to use fallback storage.'); - throw new Error('Critical database initialization failure in production environment'); - } - } catch (error) { - if (IS_PRODUCTION) { - logger.error('Services: PostgreSQL connection failed in production:', error.message); - throw error; - } - logger.warn('Services: PostgreSQL connection failed, using mock database:', error.message); - } - - db = { - get: async (key, defaultValue = null) => defaultValue, - set: async (key, value, ttl = null) => true, - delete: async (key) => true, - list: async (prefix) => [], - exists: async (key) => false, - increment: async (key, amount = 1) => amount, - decrement: async (key, amount = 1) => -amount - }; - useFallback = true; - connectionType = 'memory'; - logger.info('Services: Using mock database (fallback)'); -} - -initializeServicesDatabase().catch((error) => { - logger.error('Fatal services database initialization failure:', error.message); - if (IS_PRODUCTION) { - process.exit(1); - } -}); - - -export function getGuildConfigKey(guildId) { - return `guild:${guildId}:config`; -} - -export function getGuildBirthdaysKey(guildId) { - return `guild:${guildId}:birthdays`; -} - -export function getEconomyKey(guildId, userId) { - return `guild:${guildId}:economy:${userId}`; -} - -export function getAFKKey(guildId, userId) { - return `guild:${guildId}:afk:${userId}`; -} - -export function giveawayKey(guildId) { - return `guild:${guildId}:giveaways`; -} - -export const getGiveawaysKey = giveawayKey; - -export function getTicketKey(guildId, channelId) { - return `guild:${guildId}:ticket:${channelId}`; -} - -export function getWelcomeConfigKey(guildId) { - return `guild:${guildId}:welcome`; -} - -export function getLevelingKey(guildId) { - return `guild:${guildId}:leveling`; -} - -export function getUserLevelKey(guildId, userId) { - return `guild:${guildId}:leveling:${userId}`; -} - -export function getInviteTrackingKey(guildId) { - return `guild:${guildId}:invites`; -} - -export function getMemberInvitesKey(guildId, userId) { - return `guild:${guildId}:invites:${userId}`; -} - -export function getInviteUsesKey(guildId, inviteCode) { - return `guild:${guildId}:invite_uses:${inviteCode}`; -} - -export function getFakeAccountKey(guildId, userId) { - return `guild:${guildId}:fake_account:${userId}`; -} - - -export async function initializeDatabase() { - await initializeServicesDatabase(); - logger.info('Services database initialized'); - return db; -} - -function extractData(replitResponse) { - if (replitResponse && typeof replitResponse === 'object') { - let current = replitResponse; - while (current && typeof current === 'object' && 'ok' in current && 'value' in current) { - current = current.value; - } - - if (current && typeof current === 'object' && 'tasks' in current) { - return current; - } - - return current; - } - return replitResponse; -} - -export async function getFromDb(key, defaultValue = null) { - try { - if (!db) { - await initializeServicesDatabase(); - } - const value = await db.get(key); - - const extractedData = extractData(value); - - const result = extractedData !== null ? extractedData : defaultValue; - return result; - } catch (error) { - logger.error(`Error getting value for key ${key}:`, error); - return defaultValue; - } -} - -export async function setInDb(key, value, ttl = null) { - try { - if (!db) { - await initializeServicesDatabase(); - } - await db.set(key, value, ttl); - - const verifyValue = await db.get(key); - const extractedVerifyValue = extractData(verifyValue); - - return true; - } catch (error) { - logger.error(`Error setting value for key ${key}:`, error); - return false; - } -} - -export async function deleteFromDb(key) { - try { - if (!db) { - await initializeServicesDatabase(); - } - await db.delete(key); - return true; - } catch (error) { - logger.error(`Error deleting key ${key}:`, error); - return false; - } -} - - -export async function setGuildConfig(client, guildId, newData) { - try { - if (!db) { - await initializeServicesDatabase(); - } - const key = getGuildConfigKey(guildId); - await db.set(key, newData); - return true; - } catch (error) { - logger.error('Error setting guild config:', error); - throw error; - } -} - - -export async function getTicketData(guildId, channelId) { - if (!db) { - await initializeServicesDatabase(); - } - const key = getTicketKey(guildId, channelId); - return await db.get(key); -} - -export async function getOpenTicketCountForUser(guildId, userId) { - try { - if (!db) { - await initializeServicesDatabase(); - } - - if (db?.pool && typeof db.isAvailable === 'function' && db.isAvailable()) { - const result = await db.pool.query( - `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} - WHERE guild_id = $1 - AND data->>'userId' = $2 - AND COALESCE(data->>'status', 'open') = 'open'`, - [guildId, userId] - ); - - return Number(result.rows?.[0]?.count || 0); - } - - if (typeof db?.list === 'function') { - const ticketKeys = await db.list(`guild:${guildId}:ticket:`); - let count = 0; - - for (const key of ticketKeys) { - const ticket = await getFromDb(key, null); - if (ticket && ticket.userId === userId && ticket.status === 'open') { - count += 1; - } - } - - return count; - } - - return 0; - } catch (error) { - logger.error(`Error counting open tickets for user ${userId} in guild ${guildId}:`, error); - return 0; - } -} - -export async function saveTicketData(guildId, channelId, data) { - if (!db) { - await initializeServicesDatabase(); - } - const key = getTicketKey(guildId, channelId); - await db.set(key, data); -} - -export async function deleteTicketData(guildId, channelId) { - if (!db) { - await initializeServicesDatabase(); - } - const key = getTicketKey(guildId, channelId); - await db.delete(key); -} - - -export async function getGuildGiveaways(client, guildId) { - if (!db) { - await initializeServicesDatabase(); - } - const key = giveawayKey(guildId); - return await db.get(key) || []; -} - -export async function saveGiveaway(client, guildId, giveawayData) { - if (!db) { - await initializeServicesDatabase(); - } - const key = giveawayKey(guildId); - const giveaways = await getGuildGiveaways(client, guildId); - const existingIndex = giveaways.findIndex(g => g.messageId === giveawayData.messageId); - - if (existingIndex >= 0) { - giveaways[existingIndex] = giveawayData; - } else { - giveaways.push(giveawayData); - } - - await db.set(key, giveaways); - return giveawayData; -} - -export async function deleteGiveaway(client, guildId, messageId) { - if (!db) { - await initializeServicesDatabase(); - } - const key = giveawayKey(guildId); - const giveaways = await getGuildGiveaways(client, guildId); - const updatedGiveaways = giveaways.filter(g => g.messageId !== messageId); - - if (updatedGiveaways.length !== giveaways.length) { - await db.set(key, updatedGiveaways); - return true; - } - - return false; -} - - -export async function getWelcomeConfig(client, guildId) { - if (!db) { - await initializeServicesDatabase(); - } - const key = getWelcomeConfigKey(guildId); - const config = await db.get(key); - - if (!config) { - const defaultConfig = { - enabled: false, - channel: null, - message: 'Welcome {user} to {server}!', - dmMessage: null, - role: null - }; - - await db.set(key, defaultConfig); - return defaultConfig; - } - - return config; -} - -export async function saveWelcomeConfig(client, guildId, config) { - if (!db) { - await initializeServicesDatabase(); - } - const key = getWelcomeConfigKey(guildId); - await db.set(key, config); - return config; -} - -export default { - db, - pgDb, - initializeServicesDatabase, - getConnectionType: () => connectionType, - useFallback: () => useFallback -}; - - +export * from '../utils/database.js'; +import * as database from '../utils/database.js'; +export default database; diff --git a/src/services/economy.js b/src/services/economy.js index 59e1f200e6..da439a355b 100644 --- a/src/services/economy.js +++ b/src/services/economy.js @@ -1,5 +1,5 @@ import { BotConfig } from "../config/bot.js"; -import { getEconomyKey } from './database.js'; +import { getEconomyKey } from '../utils/database.js'; import { getFromDb, setInDb } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { normalizeEconomyData } from '../utils/schemas.js'; diff --git a/src/services/economyService.js b/src/services/economyService.js index 3cd8f02f5b..3a843a5a16 100644 --- a/src/services/economyService.js +++ b/src/services/economyService.js @@ -20,6 +20,7 @@ import { logger } from '../utils/logger.js'; import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../utils/economy.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; +import { wrapServiceClassMethods } from '../utils/serviceErrorBoundary.js'; class EconomyService { @@ -530,4 +531,11 @@ class EconomyService { } } +wrapServiceClassMethods(EconomyService, (methodName) => ({ + service: 'EconomyService', + operation: methodName, + message: `Economy service operation failed: ${methodName}`, + userMessage: 'An economy operation failed. Please try again in a moment.' +})); + export default EconomyService; diff --git a/src/services/guildConfig.js b/src/services/guildConfig.js index 9d772f0ce9..ce98c8cf4b 100644 --- a/src/services/guildConfig.js +++ b/src/services/guildConfig.js @@ -1,6 +1,7 @@ import { getGuildConfig as getGuildConfigDb, setGuildConfig as setGuildConfigDb } from '../utils/database.js'; import { BotConfig } from '../config/bot.js'; -import { normalizeGuildConfig } from '../utils/schemas.js'; +import { normalizeGuildConfig, validateGuildConfigOrThrow } from '../utils/schemas.js'; +import { wrapServiceBoundary } from '../utils/serviceErrorBoundary.js'; const GUILD_CONFIG_DEFAULTS = { prefix: BotConfig.prefix, @@ -25,11 +26,16 @@ const GUILD_CONFIG_DEFAULTS = { -export async function getGuildConfig(client, guildId) { - const config = await getGuildConfigDb(client, guildId); +export const getGuildConfig = wrapServiceBoundary(async function getGuildConfig(client, guildId, context = {}) { + const config = await getGuildConfigDb(client, guildId, context); return normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); -} +}, { + service: 'guildConfigService', + operation: 'getGuildConfig', + message: 'Failed to fetch guild configuration', + userMessage: 'Failed to load server configuration. Please try again.' +}); @@ -38,10 +44,16 @@ export async function getGuildConfig(client, guildId) { -export async function setGuildConfig(client, guildId, config) { +export const setGuildConfig = wrapServiceBoundary(async function setGuildConfig(client, guildId, config, context = {}) { const normalized = normalizeGuildConfig(config, GUILD_CONFIG_DEFAULTS); - return await setGuildConfigDb(client, guildId, normalized); -} + const validated = validateGuildConfigOrThrow(normalized, { guildId, ...context }); + return await setGuildConfigDb(client, guildId, validated, context); +}, { + service: 'guildConfigService', + operation: 'setGuildConfig', + message: 'Failed to save guild configuration', + userMessage: 'Failed to save server configuration. Please try again.' +}); @@ -50,12 +62,18 @@ export async function setGuildConfig(client, guildId, config) { -export async function updateGuildConfig(client, guildId, updates) { - const currentConfig = await getGuildConfigDb(client, guildId); +export const updateGuildConfig = wrapServiceBoundary(async function updateGuildConfig(client, guildId, updates, context = {}) { + const currentConfig = await getGuildConfigDb(client, guildId, context); const newConfig = { ...currentConfig, ...updates }; const normalized = normalizeGuildConfig(newConfig, GUILD_CONFIG_DEFAULTS); - return await setGuildConfigDb(client, guildId, normalized); -} + const validated = validateGuildConfigOrThrow(normalized, { guildId, ...context }); + return await setGuildConfigDb(client, guildId, validated, context); +}, { + service: 'guildConfigService', + operation: 'updateGuildConfig', + message: 'Failed to update guild configuration', + userMessage: 'Failed to update server configuration. Please try again.' +}); @@ -65,10 +83,15 @@ export async function updateGuildConfig(client, guildId, updates) { -export async function getConfigValue(client, guildId, key, defaultValue = null) { - const config = await getGuildConfig(client, guildId); +export const getConfigValue = wrapServiceBoundary(async function getConfigValue(client, guildId, key, defaultValue = null, context = {}) { + const config = await getGuildConfig(client, guildId, context); return config[key] !== undefined ? config[key] : defaultValue; -} +}, { + service: 'guildConfigService', + operation: 'getConfigValue', + message: 'Failed to read guild configuration value', + userMessage: 'Failed to read a server setting. Please try again.' +}); @@ -78,8 +101,13 @@ export async function getConfigValue(client, guildId, key, defaultValue = null) -export async function setConfigValue(client, guildId, key, value) { - return await updateGuildConfig(client, guildId, { [key]: value }); -} +export const setConfigValue = wrapServiceBoundary(async function setConfigValue(client, guildId, key, value, context = {}) { + return await updateGuildConfig(client, guildId, { [key]: value }, context); +}, { + service: 'guildConfigService', + operation: 'setConfigValue', + message: 'Failed to update guild configuration value', + userMessage: 'Failed to update a server setting. Please try again.' +}); diff --git a/src/services/ticket.js b/src/services/ticket.js index 6895b25dfc..8fd6b2bfb6 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -7,11 +7,12 @@ import { PermissionFlagsBits } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; -import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from './database.js'; +import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from '../utils/database.js'; import { logger } from '../utils/logger.js'; import { createEmbed, errorEmbed } from '../utils/embeds.js'; import { logTicketEvent } from '../utils/ticketLogging.js'; import { BotConfig } from '../config/bot.js'; +import { ensureTypedServiceError } from '../utils/serviceErrorBoundary.js'; @@ -52,7 +53,19 @@ export async function getUserTicketCount(guildId, userId) { try { return await getOpenTicketCountForUser(guildId, userId); } catch (error) { - logger.error('Error counting user tickets:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'getUserTicketCount', + message: 'Ticket operation failed: getUserTicketCount', + userMessage: 'Failed to count open tickets.', + context: { guildId, userId } + }); + logger.error('Error counting user tickets:', { + guildId, + userId, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return 0; } } @@ -221,10 +234,23 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso return { success: true, channel, ticketData }; } catch (error) { - logger.error('Error creating ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'createTicket', + message: 'Ticket operation failed: createTicket', + userMessage: 'Failed to create ticket. Please try again in a moment.', + context: { guildId: guild?.id, userId: member?.id } + }); + logger.error('Error creating ticket:', { + guildId: guild?.id, + userId: member?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to create ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -257,7 +283,7 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' await channel.setParent(closedCategoryId, { lockPermissions: false }); movedToClosedCategory = true; } catch (moveError) { - logger.warn(`Could not move ticket ${channel.id} to closed category ${closedCategoryId}: ${moveError.message}`); + logger.warn(`Could not move ticket ${channel.id} to closed category ${closedCategoryId}: ${moveError.message}`); } } else { logger.warn(`Configured closed category is invalid for guild ${channel.guild.id}: ${closedCategoryId}`); @@ -278,7 +304,7 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' await ticketCreator.send({ embeds: [dmEmbed] }); } } catch (dmError) { - console.warn(`Could not send DM to ticket creator ${ticketData.userId}:`, dmError.message); + logger.warn(`Could not send DM to ticket creator ${ticketData.userId}: ${dmError.message}`); } } @@ -301,7 +327,7 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' } } } catch (permError) { - console.warn('Could not update user permissions for closed ticket:', permError.message); + logger.warn(`Could not update user permissions for closed ticket: ${permError.message}`); } const messages = await channel.messages.fetch(); @@ -375,10 +401,24 @@ components: [] return { success: true, ticketData }; } catch (error) { - logger.error('Error closing ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'closeTicket', + message: 'Ticket operation failed: closeTicket', + userMessage: 'Failed to close ticket. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, closerId: closer?.id } + }); + logger.error('Error closing ticket:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: closer?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to close ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -484,10 +524,24 @@ export async function claimTicket(channel, claimer) { return { success: true, ticketData }; } catch (error) { - logger.error('Error claiming ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'claimTicket', + message: 'Ticket operation failed: claimTicket', + userMessage: 'Failed to claim ticket. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, claimerId: claimer?.id } + }); + logger.error('Error claiming ticket:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: claimer?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to claim ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -616,10 +670,24 @@ export async function reopenTicket(channel, reopener) { }; } catch (error) { - logger.error('Error reopening ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'reopenTicket', + message: 'Ticket operation failed: reopenTicket', + userMessage: 'Failed to reopen ticket. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, reopenerId: reopener?.id } + }); + logger.error('Error reopening ticket:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: reopener?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to reopen ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -667,10 +735,24 @@ export async function deleteTicket(channel, deleter) { return { success: true, ticketData }; } catch (error) { - logger.error('Error deleting ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'deleteTicket', + message: 'Ticket operation failed: deleteTicket', + userMessage: 'Failed to delete ticket. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, deleterId: deleter?.id } + }); + logger.error('Error deleting ticket:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: deleter?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to delete ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -784,10 +866,24 @@ export async function unclaimTicket(channel, unclaimer) { return { success: true, ticketData }; } catch (error) { - logger.error('Error unclaiming ticket:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'unclaimTicket', + message: 'Ticket operation failed: unclaimTicket', + userMessage: 'Failed to unclaim ticket. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, unclaimerId: unclaimer?.id } + }); + logger.error('Error unclaiming ticket:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: unclaimer?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to unclaim ticket' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -879,10 +975,24 @@ export async function updateTicketPriority(channel, priority, updater) { return { success: true, ticketData }; } catch (error) { - logger.error('Error updating ticket priority:', error); + const typedError = ensureTypedServiceError(error, { + service: 'ticketService', + operation: 'updateTicketPriority', + message: 'Ticket operation failed: updateTicketPriority', + userMessage: 'Failed to update ticket priority. Please try again in a moment.', + context: { guildId: channel?.guild?.id, channelId: channel?.id, updaterId: updater?.id, priority } + }); + logger.error('Error updating ticket priority:', { + guildId: channel?.guild?.id, + channelId: channel?.id, + userId: updater?.id, + error: typedError.message, + errorCode: typedError.context?.errorCode + }); return { success: false, - error: error.message || 'Failed to update ticket priority' + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } diff --git a/src/services/verificationService.js b/src/services/verificationService.js index f16e185c47..9a1542b7a2 100644 --- a/src/services/verificationService.js +++ b/src/services/verificationService.js @@ -18,6 +18,7 @@ import { logger } from '../utils/logger.js'; import { getGuildConfig, setGuildConfig } from './guildConfig.js'; import { createError, ErrorTypes } from '../utils/errorHandler.js'; import { insertVerificationAudit } from '../utils/database.js'; +import { ensureTypedServiceError } from '../utils/serviceErrorBoundary.js'; const verificationCooldowns = new Map(); @@ -153,13 +154,22 @@ export async function verifyUser(client, guildId, userId, options = {}) { }; } catch (error) { + const typedError = ensureTypedServiceError(error, { + service: 'verificationService', + operation: 'verifyUser', + type: ErrorTypes.UNKNOWN, + message: 'Verification operation failed: verifyUser', + userMessage: 'Verification failed. Please try again in a moment.', + context: { guildId, userId, source: options.source } + }); logger.error('Error verifying user', { guildId, userId, source: options.source, - error: error.message + error: typedError.message, + errorCode: typedError.context?.errorCode }); - throw error; + throw typedError; } } @@ -312,16 +322,26 @@ export async function autoVerifyOnJoin(client, guild, member, verificationConfig }; } catch (error) { + const typedError = ensureTypedServiceError(error, { + service: 'verificationService', + operation: 'autoVerifyOnJoin', + type: ErrorTypes.UNKNOWN, + message: 'Verification operation failed: autoVerifyOnJoin', + userMessage: 'Automatic verification failed. Please verify manually.', + context: { guildId: guild.id, userId: member.id } + }); logger.error('Error in auto-verification on join', { guildId: guild.id, userId: member.id, - error: error.message + error: typedError.message, + errorCode: typedError.context?.errorCode }); return { autoVerified: false, reason: 'auto_verify_error', - error: error.message + error: typedError.userMessage || typedError.message, + errorCode: typedError.context?.errorCode }; } } @@ -430,12 +450,21 @@ export async function removeVerification(client, guildId, userId, options = {}) }; } catch (error) { + const typedError = ensureTypedServiceError(error, { + service: 'verificationService', + operation: 'removeVerification', + type: ErrorTypes.UNKNOWN, + message: 'Verification operation failed: removeVerification', + userMessage: 'Failed to remove verification. Please try again in a moment.', + context: { guildId, userId, reason } + }); logger.error('Error removing verification', { guildId, userId, - error: error.message + error: typedError.message, + errorCode: typedError.context?.errorCode }); - throw error; + throw typedError; } } diff --git a/src/utils/abuseProtection.js b/src/utils/abuseProtection.js new file mode 100644 index 0000000000..0e5f7237bb --- /dev/null +++ b/src/utils/abuseProtection.js @@ -0,0 +1,197 @@ +import { checkRateLimit, getRateLimitStatus, clearAllRateLimits } from './rateLimiter.js'; +import { logger } from './logger.js'; + +const DEFAULT_PROTECTION_POLICY = Object.freeze({ + maxAttempts: 2, + windowMs: 30_000 +}); + +const DEFAULT_ANOMALY_POLICY = Object.freeze({ + threshold: 3, + windowMs: 5 * 60_000 +}); + +const RISKY_COMMAND_CATEGORIES = new Set([ + 'moderation', + 'ticket', + 'config', + 'verification' +]); + +const RISKY_COMMAND_NAMES = new Set([ + 'wipedata', + 'massban', + 'masskick', + 'ban', + 'kick', + 'timeout', + 'untimeout', + 'purge', + 'warn', + 'unban', + 'lock', + 'unlock', + 'ticket', + 'rsetup' +]); + +const blockedAttemptStore = new Map(); + +function normalizeCommandCategory(command) { + return String(command?.category || '').trim().toLowerCase(); +} + +function normalizeCommandName(commandName) { + return String(commandName || '').trim().toLowerCase(); +} + +function getCommandPolicy(command) { + const protection = command?.abuseProtection || {}; + const maxAttempts = Number.isInteger(protection.maxAttempts) && protection.maxAttempts > 0 + ? protection.maxAttempts + : DEFAULT_PROTECTION_POLICY.maxAttempts; + const windowMs = Number.isInteger(protection.windowMs) && protection.windowMs > 0 + ? protection.windowMs + : DEFAULT_PROTECTION_POLICY.windowMs; + + return { maxAttempts, windowMs }; +} + +function getAnomalyPolicy(command) { + const anomaly = command?.abuseProtection?.anomaly || {}; + const threshold = Number.isInteger(anomaly.threshold) && anomaly.threshold > 0 + ? anomaly.threshold + : DEFAULT_ANOMALY_POLICY.threshold; + const windowMs = Number.isInteger(anomaly.windowMs) && anomaly.windowMs > 0 + ? anomaly.windowMs + : DEFAULT_ANOMALY_POLICY.windowMs; + + return { threshold, windowMs }; +} + +function getProtectionKey(interaction, commandName) { + const guildScope = interaction.guildId || 'dm'; + const userId = interaction.user?.id || 'unknown_user'; + return `${guildScope}:${userId}:${normalizeCommandName(commandName)}`; +} + +function recordBlockedAttempt(key, commandName, interaction, command, remainingMs) { + const now = Date.now(); + const anomalyPolicy = getAnomalyPolicy(command); + const current = blockedAttemptStore.get(key); + + if (!current || now - current.windowStart > anomalyPolicy.windowMs) { + blockedAttemptStore.set(key, { + count: 1, + windowStart: now, + thresholdReachedAt: null + }); + return; + } + + current.count += 1; + + if (current.count >= anomalyPolicy.threshold && !current.thresholdReachedAt) { + current.thresholdReachedAt = now; + logger.warn('Abuse anomaly detected for risky command cooldown breaches', { + event: 'interaction.command.abuse_anomaly', + guildId: interaction.guildId, + userId: interaction.user?.id, + command: normalizeCommandName(commandName), + anomalyCount: current.count, + anomalyWindowMs: anomalyPolicy.windowMs, + cooldownRemainingMs: remainingMs + }); + } +} + +export function formatCooldownDuration(ms) { + if (!Number.isFinite(ms) || ms <= 0) { + return 'a moment'; + } + + const totalSeconds = Math.ceil(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes > 0 && seconds > 0) { + return `${minutes}m ${seconds}s`; + } + + if (minutes > 0) { + return `${minutes}m`; + } + + return `${seconds}s`; +} + +export function isRiskyCommand(command, commandName) { + const protectionEnabled = command?.abuseProtection?.enabled; + if (protectionEnabled === false) { + return false; + } + + if (protectionEnabled === true) { + return true; + } + + const normalizedName = normalizeCommandName(commandName); + if (RISKY_COMMAND_NAMES.has(normalizedName)) { + return true; + } + + const normalizedCategory = normalizeCommandCategory(command); + return RISKY_COMMAND_CATEGORIES.has(normalizedCategory); +} + +export async function enforceAbuseProtection(interaction, command, commandName) { + if (!isRiskyCommand(command, commandName)) { + return { + allowed: true, + risky: false, + remainingMs: 0, + policy: null + }; + } + + const policy = getCommandPolicy(command); + const key = getProtectionKey(interaction, commandName); + const allowed = await checkRateLimit(key, policy.maxAttempts, policy.windowMs); + + if (allowed) { + return { + allowed: true, + risky: true, + remainingMs: 0, + policy + }; + } + + const status = getRateLimitStatus(key, policy.windowMs); + const remainingMs = Math.max(0, status?.remaining || 0); + + logger.info('Risky command blocked by cooldown policy', { + event: 'interaction.command.abuse_blocked', + guildId: interaction.guildId, + userId: interaction.user?.id, + command: normalizeCommandName(commandName), + maxAttempts: policy.maxAttempts, + windowMs: policy.windowMs, + remainingMs, + attemptCount: status?.attempts || 0 + }); + + recordBlockedAttempt(key, commandName, interaction, command, remainingMs); + + return { + allowed: false, + risky: true, + remainingMs, + policy + }; +} + +export function resetAbuseProtectionState() { + blockedAttemptStore.clear(); + clearAllRateLimits(); +} diff --git a/src/utils/commandInputValidation.js b/src/utils/commandInputValidation.js new file mode 100644 index 0000000000..a5f6f4cc75 --- /dev/null +++ b/src/utils/commandInputValidation.js @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { createError, ErrorTypes } from './errorHandler.js'; + +const OptionValueSchema = z.union([ + z.string().max(2000), + z.number().finite(), + z.boolean(), + z.null() +]); + +const CommandOptionSchema = z.lazy(() => + z.object({ + name: z.string().min(1).max(32), + type: z.number().int().min(1).max(20), + value: OptionValueSchema.optional(), + options: z.array(CommandOptionSchema).max(25).optional() + }) +); + +const CommandInputSchema = z.object({ + commandName: z.string().min(1).max(32), + options: z.array(CommandOptionSchema).max(25) +}); + +export function validateChatInputPayloadOrThrow(interaction, context = {}) { + const payload = { + commandName: interaction?.commandName, + options: Array.isArray(interaction?.options?.data) ? interaction.options.data : [] + }; + + const parsed = CommandInputSchema.safeParse(payload); + if (parsed.success) { + return parsed.data; + } + + throw createError( + 'Invalid command input payload', + ErrorTypes.VALIDATION, + 'One or more command inputs are invalid. Please review your options and try again.', + { + ...context, + errorCode: 'VALIDATION_FAILED', + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code + })) + } + ); +} diff --git a/src/utils/database.js b/src/utils/database.js index 800d5d6239..50ab53a2ae 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -2,7 +2,7 @@ import { pgDb } from './postgresDatabase.js'; import { MemoryStorage } from './memoryStorage.js'; import { logger } from './logger.js'; import { BotConfig } from '../config/bot.js'; -import { normalizeGuildConfig } from './schemas.js'; +import { normalizeGuildConfig, validateGuildConfigOrThrow } from './schemas.js'; import { DEFAULT_GUILD_CONFIG } from './constants.js'; class DatabaseWrapper { @@ -12,6 +12,7 @@ class DatabaseWrapper { this.useFallback = false; this.connectionType = 'none'; this.degradedModeWarningShown = false; + this.degradedReason = null; } async initialize() { @@ -25,18 +26,33 @@ class DatabaseWrapper { if (pgConnected) { this.db = pgDb; this.connectionType = 'postgresql'; + this.degradedReason = null; logger.info('โœ… PostgreSQL Database initialized - using persistent database'); this.initialized = true; return; } + + const pgFailure = pgDb.getLastFailure?.(); + if (pgFailure?.reason === 'SCHEMA_VERSION_MISMATCH') { + const schemaError = new Error( + `Schema version mismatch detected (${pgFailure.message}). Run migrations before startup.` + ); + schemaError.code = 'SCHEMA_VERSION_MISMATCH'; + throw schemaError; + } } catch (error) { logger.warn('PostgreSQL connection failed:', error.message); + + if (error.code === 'SCHEMA_VERSION_MISMATCH') { + throw error; + } } this.db = new MemoryStorage(); this.useFallback = true; this.connectionType = 'memory'; + this.degradedReason = 'POSTGRES_UNAVAILABLE'; logger.warn('โš ๏ธ DATABASE DEGRADED MODE ENABLED - Using in-memory storage (data will be lost on restart)'); logger.warn('โš ๏ธ Please check PostgreSQL connection and restart the bot when fixed'); this.initialized = true; @@ -47,6 +63,15 @@ class DatabaseWrapper { if (this.useFallback) { logger.debug(`[DEGRADED] Writing to memory: ${key}`); } + + if (typeof key === 'string' && /^guild:[^:]+:config$/.test(key)) { + const guildId = key.split(':')[1]; + validateGuildConfigOrThrow(value, { + guildId, + errorCode: 'VALIDATION_FAILED' + }); + } + return this.db.set(key, value, ttl); } @@ -124,7 +149,8 @@ class DatabaseWrapper { initialized: this.initialized, connectionType: this.connectionType, isDegraded: this.useFallback, - isAvailable: this.isAvailable() + isAvailable: this.isAvailable(), + degradedReason: this.degradedReason }; } @@ -143,6 +169,11 @@ export async function initializeDatabase() { return { db }; } catch (error) { logger.error("โŒ Database Initialization Error:", error); + + if (error.code === 'SCHEMA_VERSION_MISMATCH') { + throw error; + } + return { db }; } } @@ -235,7 +266,7 @@ export const getGuildBirthdaysKey = (guildId) => `guild:${guildId}:birthdays`; * @param {string} guildId - Guild ID * @returns {Promise} Guild configuration */ -export async function getGuildConfig(client, guildId) { +export async function getGuildConfig(client, guildId, context = {}) { try { if (!client.db || typeof client.db.get !== "function") { return {}; @@ -247,7 +278,13 @@ export async function getGuildConfig(client, guildId) { return normalizeGuildConfig(cleanedConfig, DEFAULT_GUILD_CONFIG); } catch (error) { - logger.error(`Error fetching config for guild ${guildId}`, error); + logger.error(`Error fetching config for guild ${guildId}`, { + error, + traceId: context.traceId, + guildId, + userId: context.userId, + command: context.command + }); return {}; } } @@ -259,7 +296,7 @@ export async function getGuildConfig(client, guildId) { * @param {Object} config - Configuration to save * @returns {Promise} Success status */ -export async function setGuildConfig(client, guildId, config) { +export async function setGuildConfig(client, guildId, config, context = {}) { try { if (!client.db || typeof client.db.set !== "function") { logger.error("Database client is not available for setGuildConfig"); @@ -267,10 +304,17 @@ export async function setGuildConfig(client, guildId, config) { } const key = getGuildConfigKey(guildId); - await client.db.set(key, config); + const validated = validateGuildConfigOrThrow(config, { guildId, ...context }); + await client.db.set(key, validated); return true; } catch (error) { - logger.error(`Error saving config for guild ${guildId}`, error); + logger.error(`Error saving config for guild ${guildId}`, { + error, + traceId: context.traceId, + guildId, + userId: context.userId, + command: context.command + }); return false; } } @@ -291,7 +335,7 @@ export const getColor = (path, fallback = "#000000") => { for (const part of parts) { if (current[part] === undefined) { - console.warn(`Color path '${path}' not found in config, using fallback`); + logger.warn(`Color path '${path}' not found in config, using fallback`); return fallback; } current = current[part]; @@ -310,14 +354,14 @@ export async function getGuildBirthdays(client, guildId) { const key = getGuildBirthdaysKey(guildId); try { if (!client.db || typeof client.db.get !== "function") { - console.error("Database client is not available for getGuildBirthdays."); + logger.error("Database client is not available for getGuildBirthdays."); return {}; } const rawData = await client.db.get(key, {}); return unwrapReplitData(rawData) || {}; } catch (error) { - console.error(`Error retrieving birthdays for guild ${guildId}:`, error); + logger.error(`Error retrieving birthdays for guild ${guildId}:`, error); return {}; } } @@ -334,7 +378,7 @@ export async function getGuildBirthdays(client, guildId) { export async function setBirthday(client, guildId, userId, month, day) { try { if (!client.db || typeof client.db.set !== "function") { - console.error("Database client is not available for setBirthday."); + logger.error("Database client is not available for setBirthday."); return false; } @@ -344,7 +388,7 @@ export async function setBirthday(client, guildId, userId, month, day) { await client.db.set(key, birthdays); return true; } catch (error) { - console.error(`Error setting birthday for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error setting birthday for user ${userId} in guild ${guildId}:`, error); return false; } } @@ -359,7 +403,7 @@ export async function setBirthday(client, guildId, userId, month, day) { export async function deleteBirthday(client, guildId, userId) { try { if (!client.db || typeof client.db.set !== "function") { - console.error("Database client is not available for deleteBirthday."); + logger.error("Database client is not available for deleteBirthday."); return false; } @@ -371,7 +415,7 @@ export async function deleteBirthday(client, guildId, userId) { } return true; } catch (error) { - console.error(`Error deleting birthday for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error deleting birthday for user ${userId} in guild ${guildId}:`, error); return false; } } @@ -401,14 +445,14 @@ export async function getGuildGiveaways(client, guildId) { const key = giveawayKey(guildId); try { if (!client.db || typeof client.db.get !== "function") { - console.error("Database client is not available for getGuildGiveaways."); + logger.error("Database client is not available for getGuildGiveaways."); return {}; } const giveaways = await client.db.get(key, {}); return unwrapReplitData(giveaways) || {}; } catch (error) { - console.error(`Error getting giveaways for guild ${guildId}:`, error); + logger.error(`Error getting giveaways for guild ${guildId}:`, error); return {}; } } @@ -423,7 +467,7 @@ export async function getGuildGiveaways(client, guildId) { export async function saveGiveaway(client, guildId, giveawayData) { try { if (!client.db || typeof client.db.set !== "function") { - console.error("Database client is not available for saveGiveaway."); + logger.error("Database client is not available for saveGiveaway."); return false; } @@ -435,7 +479,7 @@ export async function saveGiveaway(client, guildId, giveawayData) { await client.db.set(key, giveaways); return true; } catch (error) { - console.error('Error saving giveaway:', error); + logger.error('Error saving giveaway:', error); return false; } } @@ -459,7 +503,7 @@ export async function deleteGiveaway(client, guildId, messageId) { } return false; } catch (error) { - console.error('Error deleting giveaway:', error); + logger.error('Error deleting giveaway:', error); return false; } } @@ -543,6 +587,95 @@ export function giveawayKey(guildId) { return `guild:${guildId}:giveaways`; } +export const getGiveawaysKey = giveawayKey; + +export function getTicketKey(guildId, channelId) { + return `guild:${guildId}:ticket:${channelId}`; +} + +export function getInviteTrackingKey(guildId) { + return `guild:${guildId}:invites`; +} + +export function getMemberInvitesKey(guildId, userId) { + return `guild:${guildId}:invites:${userId}`; +} + +export function getInviteUsesKey(guildId, inviteCode) { + return `guild:${guildId}:invite_uses:${inviteCode}`; +} + +export function getFakeAccountKey(guildId, userId) { + return `guild:${guildId}:fake_account:${userId}`; +} + +export async function getTicketData(guildId, channelId) { + if (!db.initialized) { + await db.initialize(); + } + + const key = getTicketKey(guildId, channelId); + return await db.get(key); +} + +export async function getOpenTicketCountForUser(guildId, userId) { + try { + if (!db.initialized) { + await db.initialize(); + } + + if (db.db?.pool && typeof db.db.isAvailable === 'function' && db.db.isAvailable()) { + const { pgConfig } = await import('../config/postgres.js'); + const result = await db.db.pool.query( + `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} + WHERE guild_id = $1 + AND data->>'userId' = $2 + AND COALESCE(data->>'status', 'open') = 'open'`, + [guildId, userId] + ); + + return Number(result.rows?.[0]?.count || 0); + } + + if (typeof db.list === 'function') { + const ticketKeys = await db.list(`guild:${guildId}:ticket:`); + let count = 0; + + for (const key of ticketKeys) { + const ticket = await getFromDb(key, null); + if (ticket && ticket.userId === userId && ticket.status === 'open') { + count += 1; + } + } + + return count; + } + + return 0; + } catch (error) { + logger.error(`Error counting open tickets for user ${userId} in guild ${guildId}:`, error); + return 0; + } +} + +export async function saveTicketData(guildId, channelId, data) { + if (!db.initialized) { + await db.initialize(); + } + + const key = getTicketKey(guildId, channelId); + await db.set(key, data); +} + +export async function deleteTicketData(guildId, channelId) { + if (!db.initialized) { + await db.initialize(); + } + + const key = getTicketKey(guildId, channelId); + await db.delete(key); +} + @@ -627,7 +760,7 @@ function normalizeWelcomeConfig(raw = {}) { export async function getWelcomeConfig(client, guildId) { if (!client.db) { - console.warn('Database not available for getWelcomeConfig'); + logger.warn('Database not available for getWelcomeConfig'); return normalizeWelcomeConfig(); } @@ -637,7 +770,7 @@ export async function getWelcomeConfig(client, guildId) { const unwrapped = unwrapReplitData(config); return normalizeWelcomeConfig(unwrapped); } catch (error) { - console.error(`Error getting welcome config for guild ${guildId}:`, error); + logger.error(`Error getting welcome config for guild ${guildId}:`, error); return normalizeWelcomeConfig(); } } @@ -658,7 +791,7 @@ export async function saveWelcomeConfig(client, guildId, config) { await client.db.set(key, mergedConfig); return true; } catch (error) { - console.error(`Error saving welcome config for guild ${guildId}:`, error); + logger.error(`Error saving welcome config for guild ${guildId}:`, error); return false; } } @@ -678,7 +811,7 @@ export async function updateWelcomeConfig(client, guildId, updates) { await saveWelcomeConfig(client, guildId, updatedConfig); return updatedConfig; } catch (error) { - console.error(`Error updating welcome config for guild ${guildId}:`, error); + logger.error(`Error updating welcome config for guild ${guildId}:`, error); throw error; } } @@ -792,7 +925,7 @@ export async function getUserLevelData(client, guildId, userId) { return levelData; } catch (error) { - console.error(`Error getting level data for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error getting level data for user ${userId} in guild ${guildId}:`, error); return { xp: 0, level: 0, @@ -828,7 +961,7 @@ export async function saveUserLevelData(client, guildId, userId, data) { await setInDb(key, levelData); return true; } catch (error) { - console.error(`Error saving level data for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error saving level data for user ${userId} in guild ${guildId}:`, error); return false; } } @@ -852,7 +985,7 @@ export function getXpForLevel(level) { export async function getLeaderboard(client, guildId, limit = 10) { try { if (!client.db || typeof client.db.list !== "function") { - console.error("Database client is not available for getLeaderboard."); + logger.error("Database client is not available for getLeaderboard."); return []; } @@ -886,7 +1019,7 @@ export async function getLeaderboard(client, guildId, limit = 10) { rank: 0 }; } catch (error) { - console.error(`Error processing leaderboard key ${key}:`, error); + logger.error(`Error processing leaderboard key ${key}:`, error); return null; } }); @@ -902,7 +1035,7 @@ rank: 0 return userData.slice(0, limit); } catch (error) { - console.error(`Error getting leaderboard for guild ${guildId}:`, error); + logger.error(`Error getting leaderboard for guild ${guildId}:`, error); return []; } } @@ -926,7 +1059,7 @@ export function getApplicationRolesKey(guildId) { export async function getApplicationRoles(client, guildId) { try { if (!client.db || typeof client.db.get !== "function") { - console.error("Database client is not available for getApplicationRoles."); + logger.error("Database client is not available for getApplicationRoles."); return []; } @@ -935,7 +1068,7 @@ export async function getApplicationRoles(client, guildId) { const unwrappedRoles = unwrapReplitData(roles); return Array.isArray(unwrappedRoles) ? unwrappedRoles : []; } catch (error) { - console.error(`Error getting application roles for guild ${guildId}:`, error); + logger.error(`Error getting application roles for guild ${guildId}:`, error); return []; } } @@ -950,7 +1083,7 @@ export async function getApplicationRoles(client, guildId) { export async function saveApplicationRoles(client, guildId, roles) { try { if (!client.db || typeof client.db.set !== "function") { - console.error("Database client is not available for saveApplicationRoles."); + logger.error("Database client is not available for saveApplicationRoles."); return false; } @@ -958,7 +1091,7 @@ export async function saveApplicationRoles(client, guildId, roles) { await client.db.set(key, roles); return true; } catch (error) { - console.error(`Error saving application roles for guild ${guildId}:`, error); + logger.error(`Error saving application roles for guild ${guildId}:`, error); return false; } } @@ -1000,7 +1133,7 @@ export function getApplicationKey(guildId, applicationId) { export async function getApplicationSettings(client, guildId) { if (!client.db) { - console.warn('Database not available for getApplicationSettings'); + logger.warn('Database not available for getApplicationSettings'); return { enabled: false, applicationChannelId: null, @@ -1047,7 +1180,7 @@ cooldown: 7, return { ...defaultSettings, ...unwrapped }; } catch (error) { - console.error(`Error getting application settings for guild ${guildId}:`, error); + logger.error(`Error getting application settings for guild ${guildId}:`, error); return { enabled: false, applicationChannelId: null, @@ -1133,7 +1266,7 @@ export async function deleteApplication(client, guildId, applicationId, userIdHi return true; } catch (error) { - console.error(`Error deleting application ${applicationId} in guild ${guildId}:`, error); + logger.error(`Error deleting application ${applicationId} in guild ${guildId}:`, error); return false; } } @@ -1179,7 +1312,7 @@ export async function cleanupExpiredApplications(client, guildId) { return { removed, scanned: applicationKeys.length }; } catch (error) { - console.error(`Error cleaning expired applications for guild ${guildId}:`, error); + logger.error(`Error cleaning expired applications for guild ${guildId}:`, error); return { removed: 0, scanned: 0 }; } } @@ -1200,7 +1333,7 @@ export async function saveApplicationSettings(client, guildId, settings) { await client.db.set(key, mergedSettings); return true; } catch (error) { - console.error(`Error saving application settings for guild ${guildId}:`, error); + logger.error(`Error saving application settings for guild ${guildId}:`, error); return false; } } @@ -1229,7 +1362,7 @@ status: 'pending', try { if (!client.db || typeof client.db.set !== "function") { - console.error("Database client is not available for createApplication."); + logger.error("Database client is not available for createApplication."); throw new Error("Database not available"); } @@ -1249,7 +1382,7 @@ status: 'pending', return newApplication; } catch (error) { - console.error(`Error creating application for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error creating application for user ${userId} in guild ${guildId}:`, error); throw error; } } @@ -1268,7 +1401,7 @@ export async function getApplication(client, guildId, applicationId) { const application = await client.db.get(key, null); return unwrapReplitData(application); } catch (error) { - console.error(`Error getting application ${applicationId} in guild ${guildId}:`, error); + logger.error(`Error getting application ${applicationId} in guild ${guildId}:`, error); return null; } } @@ -1298,7 +1431,7 @@ export async function updateApplication(client, guildId, applicationId, updates) await client.db.set(key, updatedApplication); return updatedApplication; } catch (error) { - console.error(`Error updating application ${applicationId} in guild ${guildId}:`, error); + logger.error(`Error updating application ${applicationId} in guild ${guildId}:`, error); throw error; } } @@ -1314,7 +1447,7 @@ export async function getUserApplications(client, guildId, userId) { const userKey = getUserApplicationsKey(guildId, userId); try { if (!client.db || typeof client.db.get !== "function") { - console.error("Database client is not available for getUserApplications."); + logger.error("Database client is not available for getUserApplications."); return []; } @@ -1332,7 +1465,7 @@ export async function getUserApplications(client, guildId, userId) { const applications = await Promise.all(applicationPromises); return applications.filter(Boolean); } catch (error) { - console.error(`Error getting applications for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error getting applications for user ${userId} in guild ${guildId}:`, error); return []; } } @@ -1358,7 +1491,7 @@ export async function getApplications(client, guildId, filters = {}) { try { if (!client.db || typeof client.db.list !== "function") { - console.error("Database client is not available for getApplications."); + logger.error("Database client is not available for getApplications."); return []; } @@ -1396,7 +1529,7 @@ export async function getApplications(client, guildId, filters = {}) { return applications.slice(offset, offset + limit); } catch (error) { - console.error(`Error getting applications for guild ${guildId}:`, error); + logger.error(`Error getting applications for guild ${guildId}:`, error); return []; } } @@ -1537,7 +1670,7 @@ export async function getModlogSettings(client, guildId) { return { ...defaultSettings, ...unwrapped }; } catch (error) { - console.error(`Error getting modlog settings for guild ${guildId}:`, error); + logger.error(`Error getting modlog settings for guild ${guildId}:`, error); return { enabled: false, channelId: null, @@ -1588,7 +1721,7 @@ export async function saveModlogSettings(client, guildId, settings) { await client.db.set(key, mergedSettings); return true; } catch (error) { - console.error(`Error saving modlog settings for guild ${guildId}:`, error); + logger.error(`Error saving modlog settings for guild ${guildId}:`, error); return false; } } @@ -1624,7 +1757,7 @@ export async function createModlogEntry(client, entry) { return newEntry; } catch (error) { - console.error(`Error creating modlog entry for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error creating modlog entry for user ${userId} in guild ${guildId}:`, error); throw error; } } @@ -1642,7 +1775,7 @@ export async function getModlogEntry(client, guildId, caseId) { const entry = await client.db.get(key, null); return unwrapReplitData(entry); } catch (error) { - console.error(`Error getting modlog entry ${caseId} in guild ${guildId}:`, error); + logger.error(`Error getting modlog entry ${caseId} in guild ${guildId}:`, error); return null; } } @@ -1672,7 +1805,7 @@ export async function updateModlogEntry(client, guildId, caseId, updates) { await client.db.set(key, updatedEntry); return updatedEntry; } catch (error) { - console.error(`Error updating modlog entry ${caseId} in guild ${guildId}:`, error); + logger.error(`Error updating modlog entry ${caseId} in guild ${guildId}:`, error); throw error; } } @@ -1695,7 +1828,7 @@ export async function getUserModlogEntries(client, guildId, userId) { const entries = await Promise.all(entryPromises); return entries.filter(Boolean); } catch (error) { - console.error(`Error getting modlog entries for user ${userId} in guild ${guildId}:`, error); + logger.error(`Error getting modlog entries for user ${userId} in guild ${guildId}:`, error); return []; } } @@ -1746,7 +1879,7 @@ export async function getModlogEntries(client, guildId, filters = {}) { return entries.slice(offset, offset + limit); } catch (error) { - console.error(`Error getting modlog entries for guild ${guildId}:`, error); + logger.error(`Error getting modlog entries for guild ${guildId}:`, error); return []; } } @@ -1778,7 +1911,7 @@ export function getJoinToCreateChannelsKey(guildId) { export async function getJoinToCreateConfig(client, guildId) { if (!client.db) { - console.warn('Database not available for getJoinToCreateConfig'); + logger.warn('Database not available for getJoinToCreateConfig'); return { enabled: false, triggerChannels: [], @@ -1806,7 +1939,7 @@ export async function getJoinToCreateConfig(client, guildId) { ...unwrapped }; } catch (error) { - console.error(`Error getting Join to Create config for guild ${guildId}:`, error); + logger.error(`Error getting Join to Create config for guild ${guildId}:`, error); return { enabled: false, triggerChannels: [], @@ -1835,7 +1968,7 @@ export async function saveJoinToCreateConfig(client, guildId, config) { await client.db.set(key, mergedConfig); return true; } catch (error) { - console.error(`Error saving Join to Create config for guild ${guildId}:`, error); + logger.error(`Error saving Join to Create config for guild ${guildId}:`, error); return false; } } @@ -1855,7 +1988,7 @@ export async function updateJoinToCreateConfig(client, guildId, updates) { await saveJoinToCreateConfig(client, guildId, updatedConfig); return updatedConfig; } catch (error) { - console.error(`Error updating Join to Create config for guild ${guildId}:`, error); + logger.error(`Error updating Join to Create config for guild ${guildId}:`, error); throw error; } } @@ -1892,7 +2025,7 @@ export async function addJoinToCreateTrigger(client, guildId, channelId, options return await saveJoinToCreateConfig(client, guildId, config); } catch (error) { - console.error(`Error adding Join to Create trigger for guild ${guildId}:`, error); + logger.error(`Error adding Join to Create trigger for guild ${guildId}:`, error); return false; } } @@ -1922,7 +2055,7 @@ export async function removeJoinToCreateTrigger(client, guildId, channelId) { return await saveJoinToCreateConfig(client, guildId, config); } catch (error) { - console.error(`Error removing Join to Create trigger for guild ${guildId}:`, error); + logger.error(`Error removing Join to Create trigger for guild ${guildId}:`, error); return false; } } @@ -1948,7 +2081,7 @@ export async function registerTemporaryChannel(client, guildId, channelId, owner return await saveJoinToCreateConfig(client, guildId, config); } catch (error) { - console.error(`Error registering temporary channel for guild ${guildId}:`, error); + logger.error(`Error registering temporary channel for guild ${guildId}:`, error); return false; } } @@ -1971,7 +2104,7 @@ export async function unregisterTemporaryChannel(client, guildId, channelId) { return false; } catch (error) { - console.error(`Error unregistering temporary channel for guild ${guildId}:`, error); + logger.error(`Error unregistering temporary channel for guild ${guildId}:`, error); return false; } } @@ -1988,7 +2121,7 @@ export async function getTemporaryChannelInfo(client, guildId, channelId) { const config = await getJoinToCreateConfig(client, guildId); return config.temporaryChannels[channelId] || null; } catch (error) { - console.error(`Error getting temporary channel info for guild ${guildId}:`, error); + logger.error(`Error getting temporary channel info for guild ${guildId}:`, error); return null; } } diff --git a/src/utils/errorHandler.js b/src/utils/errorHandler.js index c80d7b9492..db8e1ca0dd 100644 --- a/src/utils/errorHandler.js +++ b/src/utils/errorHandler.js @@ -29,6 +29,7 @@ import { logger } from './logger.js'; import { createEmbed } from './embeds.js'; import { MessageFlags } from 'discord.js'; +import { getErrorMetadata, getDefaultErrorCodeByType, resolveErrorCode, ErrorCodes } from './errorRegistry.js'; @@ -55,6 +56,7 @@ export class TitanBotError extends Error { this.type = type; this.userMessage = userMessage; this.context = context; + this.code = context?.errorCode || getDefaultErrorCodeByType(type); this.timestamp = new Date().toISOString(); } } @@ -175,6 +177,9 @@ export function getUserMessage(error, context = {}) { export async function handleInteractionError(interaction, error, context = {}) { const errorType = categorizeError(error); const userMessage = getUserMessage(error, context); + const resolvedErrorCode = resolveErrorCode({ error, errorType, context }); + const errorMetadata = getErrorMetadata(resolvedErrorCode); + const traceId = context.traceId || interaction?.traceContext?.traceId || interaction?.traceId || error?.context?.traceId; @@ -188,8 +193,17 @@ export async function handleInteractionError(interaction, error, context = {}) { const isExpectedError = Boolean(error?.context?.expected === true || error?.context?.suppressErrorLog === true); const logData = { + event: 'interaction.error', + errorCode: resolvedErrorCode, + remediationHint: errorMetadata.remediation, + severity: errorMetadata.severity, + retryable: errorMetadata.retryable, error: error.message, type: errorType, + traceId, + guildId: interaction.guildId, + userId: interaction.user.id, + command: interaction.commandName || context.command, interaction: { type: interaction.type, commandName: interaction.commandName, @@ -240,13 +254,26 @@ export async function handleInteractionError(interaction, error, context = {}) { try { if (!interaction || !interaction.id) { - logger.warn('Interaction was null or invalid when handling error'); + logger.warn('Interaction was null or invalid when handling error', { + event: 'interaction.error.invalid_interaction', + errorCode: ErrorCodes.INTERACTION_INVALID, + remediationHint: getErrorMetadata(ErrorCodes.INTERACTION_INVALID).remediation, + traceId + }); return; } if (interaction.createdTimestamp && (Date.now() - interaction.createdTimestamp) > 14 * 60 * 1000) { - logger.warn('Interaction expired before error handler could send response'); + logger.warn('Interaction expired before error handler could send response', { + event: 'interaction.error.expired', + errorCode: ErrorCodes.INTERACTION_EXPIRED, + remediationHint: getErrorMetadata(ErrorCodes.INTERACTION_EXPIRED).remediation, + traceId, + guildId: interaction.guildId, + userId: interaction.user.id, + command: interaction.commandName || context.command + }); return; } @@ -266,10 +293,27 @@ export async function handleInteractionError(interaction, error, context = {}) { } catch (replyError) { if (replyError.code === 40060 || replyError.code === 10062) { - logger.warn('Interaction already acknowledged or expired, cannot send error response:', replyError.code); + logger.warn('Interaction already acknowledged or expired, cannot send error response:', { + event: 'interaction.error.response_unavailable', + errorCode: String(replyError.code), + traceId, + guildId: interaction.guildId, + userId: interaction.user.id, + command: interaction.commandName || context.command, + code: replyError.code + }); return; } - logger.error('Failed to send error response:', replyError); + logger.error('Failed to send error response:', { + event: 'interaction.error.response_failed', + errorCode: String(replyError.code || ErrorCodes.INTERACTION_RESPONSE_FAILED), + remediationHint: getErrorMetadata(ErrorCodes.INTERACTION_RESPONSE_FAILED).remediation, + traceId, + guildId: interaction.guildId, + userId: interaction.user.id, + command: interaction.commandName || context.command, + error: replyError + }); } } @@ -320,7 +364,12 @@ export function withErrorHandling(fn, context = {}) { export function createError(message, type = ErrorTypes.UNKNOWN, userMessage = null, context = {}) { - return new TitanBotError(message, type, userMessage, context); + const normalizedContext = { + ...context, + errorCode: context?.errorCode || getDefaultErrorCodeByType(type) + }; + + return new TitanBotError(message, type, userMessage, normalizedContext); } export default { diff --git a/src/utils/errorRegistry.js b/src/utils/errorRegistry.js new file mode 100644 index 0000000000..54930c54e2 --- /dev/null +++ b/src/utils/errorRegistry.js @@ -0,0 +1,131 @@ +const ErrorCodes = Object.freeze({ + VALIDATION_FAILED: 'VALIDATION_FAILED', + PERMISSION_DENIED: 'PERMISSION_DENIED', + CONFIGURATION_ERROR: 'CONFIGURATION_ERROR', + DATABASE_ERROR: 'DATABASE_ERROR', + NETWORK_ERROR: 'NETWORK_ERROR', + DISCORD_API_ERROR: 'DISCORD_API_ERROR', + USER_INPUT_ERROR: 'USER_INPUT_ERROR', + RATE_LIMITED: 'RATE_LIMITED', + INTERACTION_INVALID: 'INTERACTION_INVALID', + INTERACTION_EXPIRED: 'INTERACTION_EXPIRED', + INTERACTION_RESPONSE_FAILED: 'INTERACTION_RESPONSE_FAILED', + UNKNOWN_ERROR: 'UNKNOWN_ERROR' +}); + +const ErrorCodeRegistry = Object.freeze({ + [ErrorCodes.VALIDATION_FAILED]: { + severity: 'low', + retryable: false, + remediation: 'Validate command inputs before processing and return field-specific guidance.' + }, + [ErrorCodes.PERMISSION_DENIED]: { + severity: 'low', + retryable: false, + remediation: 'Review bot/user role permissions and required Discord permissions for this command.' + }, + [ErrorCodes.CONFIGURATION_ERROR]: { + severity: 'medium', + retryable: false, + remediation: 'Check required environment variables and guild feature configuration.' + }, + [ErrorCodes.DATABASE_ERROR]: { + severity: 'high', + retryable: true, + remediation: 'Check Postgres connectivity, pool saturation, statement timeouts, and recent migrations.' + }, + [ErrorCodes.NETWORK_ERROR]: { + severity: 'medium', + retryable: true, + remediation: 'Check network reachability, upstream service status, and retry/backoff behavior.' + }, + [ErrorCodes.DISCORD_API_ERROR]: { + severity: 'high', + retryable: true, + remediation: 'Check Discord API status, rate-limit response patterns, and bot token validity.' + }, + [ErrorCodes.USER_INPUT_ERROR]: { + severity: 'low', + retryable: false, + remediation: 'Validate user-provided IDs/mentions and return clearer input examples.' + }, + [ErrorCodes.RATE_LIMITED]: { + severity: 'low', + retryable: true, + remediation: 'Apply cooldown-aware retries and reduce bursty command execution.' + }, + [ErrorCodes.INTERACTION_INVALID]: { + severity: 'medium', + retryable: false, + remediation: 'Ensure interaction object is available and valid before replying.' + }, + [ErrorCodes.INTERACTION_EXPIRED]: { + severity: 'medium', + retryable: false, + remediation: 'Defer or reply to interactions earlier to avoid 15-minute expiry windows.' + }, + [ErrorCodes.INTERACTION_RESPONSE_FAILED]: { + severity: 'medium', + retryable: false, + remediation: 'Check interaction acknowledgement state and Discord response error codes.' + }, + [ErrorCodes.UNKNOWN_ERROR]: { + severity: 'high', + retryable: false, + remediation: 'Capture trace context and stack, then classify this failure under a specific error code.' + } +}); + +const TypeToErrorCode = Object.freeze({ + validation: ErrorCodes.VALIDATION_FAILED, + permission: ErrorCodes.PERMISSION_DENIED, + configuration: ErrorCodes.CONFIGURATION_ERROR, + database: ErrorCodes.DATABASE_ERROR, + network: ErrorCodes.NETWORK_ERROR, + discord_api: ErrorCodes.DISCORD_API_ERROR, + user_input: ErrorCodes.USER_INPUT_ERROR, + rate_limit: ErrorCodes.RATE_LIMITED, + unknown: ErrorCodes.UNKNOWN_ERROR +}); + +function normalizeErrorCode(errorCode) { + if (errorCode === null || errorCode === undefined) { + return null; + } + + return String(errorCode).trim().toUpperCase(); +} + +export function getErrorMetadata(errorCode) { + const normalized = normalizeErrorCode(errorCode); + if (!normalized) { + return ErrorCodeRegistry[ErrorCodes.UNKNOWN_ERROR]; + } + + return ErrorCodeRegistry[normalized] || ErrorCodeRegistry[ErrorCodes.UNKNOWN_ERROR]; +} + +export function getDefaultErrorCodeByType(errorType = 'unknown') { + return TypeToErrorCode[errorType] || ErrorCodes.UNKNOWN_ERROR; +} + +export function resolveErrorCode({ error, errorType = 'unknown', context = {} } = {}) { + const contextCode = normalizeErrorCode(context?.errorCode); + if (contextCode) { + return contextCode; + } + + const nestedContextCode = normalizeErrorCode(error?.context?.errorCode); + if (nestedContextCode) { + return nestedContextCode; + } + + const code = normalizeErrorCode(error?.code); + if (code) { + return code; + } + + return getDefaultErrorCodeByType(errorType); +} + +export { ErrorCodes, ErrorCodeRegistry, TypeToErrorCode }; diff --git a/src/utils/logger.js b/src/utils/logger.js index b5e363fd53..9bd12b992c 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -2,6 +2,7 @@ import winston from 'winston'; import 'winston-daily-rotate-file'; import path from 'path'; import { fileURLToPath } from 'url'; +import { getTraceContext } from './traceContext.js'; const { createLogger, format, transports } = winston; const { combine, timestamp, printf, colorize, errors, json } = format; @@ -24,23 +25,94 @@ const resolvedLogLevel = validLogLevels.has(requestedLogLevel) ? requestedLogLevel : defaultLogLevel; -if (requestedLogLevel && !validLogLevels.has(requestedLogLevel)) { - console.warn( - `[logger] Invalid LOG_LEVEL "${process.env.LOG_LEVEL}". Falling back to "${defaultLogLevel}".` - ); -} +const pendingInvalidLevelWarning = requestedLogLevel && !validLogLevels.has(requestedLogLevel) + ? `[logger] Invalid LOG_LEVEL "${process.env.LOG_LEVEL}". Falling back to "${defaultLogLevel}".` + : null; const shouldPromoteUserFacingLogs = process.env.NODE_ENV === 'production' && resolvedLogLevel === 'warn'; +const LOG_SCHEMA_DEFAULTS = Object.freeze({ + event: 'application.log', + guildId: null, + userId: null, + command: null, + errorCode: null, + traceId: null, +}); + const logFormat = printf(({ level, message, timestamp, stack, displayLevel }) => { const visibleLevel = displayLevel || level; const logMessage = `[${timestamp}] [${visibleLevel}]: ${stack || message}`; return logMessage; }); +const attachTraceContext = format((info) => { + const traceContext = getTraceContext(); + if (!traceContext) { + return info; + } + + info.traceId = info.traceId || traceContext.traceId; + info.guildId = info.guildId || traceContext.guildId; + info.userId = info.userId || traceContext.userId; + info.command = info.command || traceContext.command; + info.interactionId = info.interactionId || traceContext.interactionId; + + return info; +}); + +function deriveErrorCode(info) { + if (info.errorCode) { + return info.errorCode; + } + + if (typeof info.code === 'string' || typeof info.code === 'number') { + return String(info.code); + } + + if (typeof info.type === 'string') { + return info.type; + } + + if (info.error && (typeof info.error.code === 'string' || typeof info.error.code === 'number')) { + return String(info.error.code); + } + + return null; +} + +function normalizeEvent(info) { + if (typeof info.event === 'string' && info.event.trim()) { + return info.event; + } + + const displayLevel = typeof info.displayLevel === 'string' ? info.displayLevel.toLowerCase().trim() : null; + if (displayLevel === 'startup') { + return 'system.startup'; + } + + if (displayLevel === 'status') { + return 'system.status'; + } + + return `log.${info.level || 'info'}`; +} + +const enforceLogSchema = format((info) => { + info.event = normalizeEvent(info); + info.guildId = info.guildId ?? LOG_SCHEMA_DEFAULTS.guildId; + info.userId = info.userId ?? LOG_SCHEMA_DEFAULTS.userId; + info.command = info.command ?? LOG_SCHEMA_DEFAULTS.command; + info.traceId = info.traceId ?? LOG_SCHEMA_DEFAULTS.traceId; + info.errorCode = deriveErrorCode(info); + return info; +}); + const logger = createLogger({ level: resolvedLogLevel, format: combine( + attachTraceContext(), + enforceLogSchema(), timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), errors({ stack: true }), format.json() @@ -107,6 +179,10 @@ logger.stream = { }, }; +if (pendingInvalidLevelWarning) { + logger.warn(pendingInvalidLevelWarning); +} + function startupLog(message) { if (shouldPromoteUserFacingLogs) { logger.log({ diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index 5d2b9605f7..bb4ecd1689 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -1,6 +1,7 @@ import pg from 'pg'; import { pgConfig } from '../config/postgres.js'; import { logger } from './logger.js'; +import { assertAllowlistedIdentifier, quoteIdentifier } from './sqlIdentifiers.js'; /** * PostgreSQL Database wrapper for Titan Bot @@ -11,6 +12,10 @@ class PostgreSQLDatabase { this.pool = null; this.isConnected = false; this.connectionPromise = null; + this.allowedTableIdentifiers = new Set(Object.values(pgConfig.tables)); + this.allowedMigrationIdentifiers = new Set([pgConfig.migration.table]); + this.lastFailureReason = null; + this.lastFailureMessage = null; } @@ -62,6 +67,9 @@ class PostgreSQLDatabase { await client.query('SELECT NOW()'); client.release(); + this.lastFailureReason = null; + this.lastFailureMessage = null; + this.isConnected = true; logger.info('โœ… PostgreSQL Database initialized successfully'); @@ -87,8 +95,37 @@ class PostgreSQLDatabase { } } + if (pgConfig.migration.enabled) { + const migrationCheck = await this.verifySchemaVersion(); + if (!migrationCheck.ok) { + const shouldBootstrapSchema = + migrationCheck.reason === 'MISSING_MIGRATION_VERSION' + && pgConfig.features.autoMigrate; + + if (shouldBootstrapSchema) { + await this.setSchemaVersion( + pgConfig.migration.expectedVersion, + pgConfig.migration.expectedLabel + ); + logger.warn( + `No schema version found. Bootstrapped schema ledger to version ${pgConfig.migration.expectedVersion} (${pgConfig.migration.expectedLabel}).` + ); + return true; + } + + const error = new Error( + `Schema version check failed: expected ${migrationCheck.expectedVersion} but found ${migrationCheck.currentVersion === null ? 'none' : migrationCheck.currentVersion}` + ); + error.code = 'SCHEMA_VERSION_MISMATCH'; + throw error; + } + } + return true; } catch (error) { + this.lastFailureReason = error.code || 'POSTGRES_CONNECTION_FAILED'; + this.lastFailureMessage = error.message || 'Unknown PostgreSQL error'; + if (this.pool) { try { await this.pool.end(); @@ -99,12 +136,19 @@ class PostgreSQLDatabase { } const isLastAttempt = attempt >= attempts; + const isSchemaMismatch = error.code === 'SCHEMA_VERSION_MISMATCH'; if (isLastAttempt) { logger.error('โŒ Failed to initialize PostgreSQL Database:', error); this.isConnected = false; return false; } + if (isSchemaMismatch) { + logger.error('โŒ Failed to initialize PostgreSQL Database:', error); + this.isConnected = false; + return false; + } + logger.warn(`PostgreSQL connection attempt ${attempt} failed: ${error.message}`); const backoff = Math.round(baseDelay * Math.pow(multiplier, attempt - 1)); await new Promise(resolve => setTimeout(resolve, backoff)); @@ -123,6 +167,82 @@ class PostgreSQLDatabase { return this.isConnected && this.pool; } + getLastFailure() { + return { + reason: this.lastFailureReason, + message: this.lastFailureMessage + }; + } + + async ensureMigrationLedger() { + const migrationTable = assertAllowlistedIdentifier( + pgConfig.migration.table, + this.allowedMigrationIdentifiers, + 'PostgreSQL migration table identifier' + ); + const safeMigrationTable = quoteIdentifier(migrationTable); + + await this.pool.query(` + CREATE TABLE IF NOT EXISTS ${safeMigrationTable} ( + version INTEGER PRIMARY KEY, + label VARCHAR(255) NOT NULL, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + return safeMigrationTable; + } + + async getLatestSchemaVersion() { + const safeMigrationTable = await this.ensureMigrationLedger(); + const result = await this.pool.query( + `SELECT version, label, applied_at FROM ${safeMigrationTable} ORDER BY version DESC LIMIT 1` + ); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0]; + } + + async setSchemaVersion(version, label) { + const safeMigrationTable = await this.ensureMigrationLedger(); + await this.pool.query( + `INSERT INTO ${safeMigrationTable} (version, label) + VALUES ($1, $2) + ON CONFLICT (version) + DO UPDATE SET label = EXCLUDED.label, applied_at = CURRENT_TIMESTAMP`, + [version, label] + ); + } + + async verifySchemaVersion() { + const latest = await this.getLatestSchemaVersion(); + const expectedVersion = Number(pgConfig.migration.expectedVersion); + + if (!latest) { + return { + ok: false, + expectedVersion, + currentVersion: null, + reason: 'MISSING_MIGRATION_VERSION' + }; + } + + const currentVersion = Number(latest.version); + const isValid = currentVersion === expectedVersion; + + return { + ok: isValid, + expectedVersion, + currentVersion, + label: latest.label, + appliedAt: latest.applied_at, + reason: isValid ? 'OK' : 'SCHEMA_VERSION_MISMATCH' + }; + } + /** * Create database tables */ @@ -375,12 +495,27 @@ class PostgreSQLDatabase { { name: 'update_afk_status_updated_at', table: pgConfig.tables.afk_status }, ]; + const allowedTriggerIdentifiers = new Set(triggers.map(trigger => trigger.name)); + for (const trigger of triggers) { try { - await this.pool.query(`DROP TRIGGER IF EXISTS ${trigger.name} ON ${trigger.table};`); + const safeTriggerIdentifier = assertAllowlistedIdentifier( + trigger.name, + allowedTriggerIdentifiers, + 'Trigger identifier' + ); + const safeTableIdentifier = assertAllowlistedIdentifier( + trigger.table, + this.allowedTableIdentifiers, + 'Trigger table identifier' + ); + + await this.pool.query( + `DROP TRIGGER IF EXISTS ${quoteIdentifier(safeTriggerIdentifier)} ON ${quoteIdentifier(safeTableIdentifier)};` + ); await this.pool.query( - `CREATE TRIGGER ${trigger.name} - BEFORE UPDATE ON ${trigger.table} + `CREATE TRIGGER ${quoteIdentifier(safeTriggerIdentifier)} + BEFORE UPDATE ON ${quoteIdentifier(safeTableIdentifier)} FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();` ); } catch (error) { diff --git a/src/utils/safeMathParser.js b/src/utils/safeMathParser.js new file mode 100644 index 0000000000..6f8381e801 --- /dev/null +++ b/src/utils/safeMathParser.js @@ -0,0 +1,293 @@ +const SUPPORTED_FUNCTIONS = Object.freeze({ + sin: Math.sin, + cos: Math.cos, + tan: Math.tan, + sqrt: Math.sqrt, + abs: Math.abs, + log: Math.log, + log10: Math.log10, + exp: Math.exp +}); + +const SUPPORTED_CONSTANTS = Object.freeze({ + pi: Math.PI, + e: Math.E +}); + +const OPERATOR_PRECEDENCE = Object.freeze({ + 'u-': 5, + '^': 4, + '*': 3, + '/': 3, + '%': 3, + '+': 2, + '-': 2 +}); + +const RIGHT_ASSOCIATIVE_OPERATORS = new Set(['^', 'u-']); + +function normalizeExpression(input) { + if (typeof input !== 'string') { + throw new Error('Expression must be a string'); + } + + return input + .trim() + .toLowerCase() + .replace(/ร—/g, '*') + .replace(/รท/g, '/') + .replace(/ฯ€/g, 'pi') + .replace(/โˆš/g, 'sqrt'); +} + +function preprocessDegrees(expression) { + return expression.replace(/(\d+(?:\.\d+)?)\s*deg\b/g, '($1*pi/180)'); +} + +function isDigit(character) { + return character >= '0' && character <= '9'; +} + +function isAlpha(character) { + return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || character === '_'; +} + +function tokenize(expression) { + const tokens = []; + let index = 0; + + while (index < expression.length) { + const character = expression[index]; + + if (character === ' ' || character === '\t' || character === '\n') { + index += 1; + continue; + } + + if (isDigit(character) || character === '.') { + let numberText = ''; + let dotCount = 0; + + while (index < expression.length) { + const current = expression[index]; + if (!isDigit(current) && current !== '.') { + break; + } + + if (current === '.') { + dotCount += 1; + if (dotCount > 1) { + throw new Error('Invalid number format'); + } + } + + numberText += current; + index += 1; + } + + if (numberText === '.' || numberText.length === 0) { + throw new Error('Invalid number format'); + } + + tokens.push({ type: 'number', value: Number(numberText) }); + continue; + } + + if (isAlpha(character)) { + let identifier = ''; + while (index < expression.length && isAlpha(expression[index])) { + identifier += expression[index]; + index += 1; + } + + if (Object.prototype.hasOwnProperty.call(SUPPORTED_CONSTANTS, identifier)) { + tokens.push({ type: 'number', value: SUPPORTED_CONSTANTS[identifier] }); + continue; + } + + if (Object.prototype.hasOwnProperty.call(SUPPORTED_FUNCTIONS, identifier)) { + tokens.push({ type: 'function', value: identifier }); + continue; + } + + throw new Error(`Unsupported token: ${identifier}`); + } + + if ('+-*/%^()'.includes(character)) { + if (character === '(') { + tokens.push({ type: 'leftParen', value: character }); + } else if (character === ')') { + tokens.push({ type: 'rightParen', value: character }); + } else { + tokens.push({ type: 'operator', value: character }); + } + + index += 1; + continue; + } + + throw new Error(`Unsupported character: ${character}`); + } + + return tokens; +} + +function toRpn(tokens) { + const output = []; + const stack = []; + let previousTokenType = null; + + for (const token of tokens) { + if (token.type === 'number') { + output.push(token); + previousTokenType = 'number'; + continue; + } + + if (token.type === 'function') { + stack.push(token); + previousTokenType = 'function'; + continue; + } + + if (token.type === 'operator') { + let operatorValue = token.value; + + if (operatorValue === '-' && (previousTokenType === null || previousTokenType === 'operator' || previousTokenType === 'leftParen' || previousTokenType === 'function')) { + operatorValue = 'u-'; + } + + while (stack.length > 0) { + const top = stack[stack.length - 1]; + if (top.type !== 'operator') { + break; + } + + const currentPrecedence = OPERATOR_PRECEDENCE[operatorValue]; + const topPrecedence = OPERATOR_PRECEDENCE[top.value]; + const isRightAssociative = RIGHT_ASSOCIATIVE_OPERATORS.has(operatorValue); + + if ((isRightAssociative && currentPrecedence < topPrecedence) || (!isRightAssociative && currentPrecedence <= topPrecedence)) { + output.push(stack.pop()); + } else { + break; + } + } + + stack.push({ type: 'operator', value: operatorValue }); + previousTokenType = 'operator'; + continue; + } + + if (token.type === 'leftParen') { + stack.push(token); + previousTokenType = 'leftParen'; + continue; + } + + if (token.type === 'rightParen') { + let hasOpeningParen = false; + + while (stack.length > 0) { + const top = stack.pop(); + if (top.type === 'leftParen') { + hasOpeningParen = true; + break; + } + + output.push(top); + } + + if (!hasOpeningParen) { + throw new Error('Mismatched parentheses'); + } + + if (stack.length > 0 && stack[stack.length - 1].type === 'function') { + output.push(stack.pop()); + } + + previousTokenType = 'rightParen'; + } + } + + while (stack.length > 0) { + const top = stack.pop(); + if (top.type === 'leftParen' || top.type === 'rightParen') { + throw new Error('Mismatched parentheses'); + } + output.push(top); + } + + return output; +} + +function evaluateRpn(rpnTokens) { + const stack = []; + + for (const token of rpnTokens) { + if (token.type === 'number') { + stack.push(token.value); + continue; + } + + if (token.type === 'operator') { + if (token.value === 'u-') { + if (stack.length < 1) { + throw new Error('Invalid expression'); + } + stack.push(-stack.pop()); + continue; + } + + if (stack.length < 2) { + throw new Error('Invalid expression'); + } + + const right = stack.pop(); + const left = stack.pop(); + + if (token.value === '+') stack.push(left + right); + else if (token.value === '-') stack.push(left - right); + else if (token.value === '*') stack.push(left * right); + else if (token.value === '/') stack.push(left / right); + else if (token.value === '%') stack.push(left % right); + else if (token.value === '^') stack.push(Math.pow(left, right)); + else throw new Error(`Unsupported operator: ${token.value}`); + + continue; + } + + if (token.type === 'function') { + if (stack.length < 1) { + throw new Error('Invalid function usage'); + } + + const value = stack.pop(); + const handler = SUPPORTED_FUNCTIONS[token.value]; + if (!handler) { + throw new Error(`Unsupported function: ${token.value}`); + } + stack.push(handler(value)); + continue; + } + } + + if (stack.length !== 1) { + throw new Error('Invalid expression'); + } + + return stack[0]; +} + +export function evaluateMathExpression(expression) { + const normalized = preprocessDegrees(normalizeExpression(expression)); + const tokens = tokenize(normalized); + const rpn = toRpn(tokens); + const value = evaluateRpn(rpn); + + if (!Number.isFinite(value)) { + throw new Error('Expression result is not finite'); + } + + return value; +} diff --git a/src/utils/schemas.js b/src/utils/schemas.js index 3acb11f553..c90dde1c47 100644 --- a/src/utils/schemas.js +++ b/src/utils/schemas.js @@ -1,13 +1,14 @@ import { z } from 'zod'; +import { createError, ErrorTypes } from './errorHandler.js'; -const LogIgnoreSchema = z +export const LogIgnoreSchema = z .object({ users: z.array(z.string()).default([]), channels: z.array(z.string()).default([]) }) .default({ users: [], channels: [] }); -const LoggingConfigSchema = z +export const LoggingConfigSchema = z .object({ enabled: z.boolean().default(false), channelId: z.string().nullable().optional(), @@ -100,4 +101,27 @@ export function normalizeEconomyData(raw, defaults = {}) { return parsed.success ? parsed.data : { ...defaults, ...base }; } +export function validateGuildConfigOrThrow(rawConfig, context = {}) { + const parsed = GuildConfigSchema.safeParse(rawConfig); + + if (parsed.success) { + return parsed.data; + } + + throw createError( + 'Invalid guild configuration payload', + ErrorTypes.VALIDATION, + 'Configuration payload is invalid. Please review provided values and try again.', + { + ...context, + errorCode: 'VALIDATION_FAILED', + issues: parsed.error.issues.map((issue) => ({ + path: issue.path.join('.'), + message: issue.message, + code: issue.code + })) + } + ); +} + diff --git a/src/utils/serviceErrorBoundary.js b/src/utils/serviceErrorBoundary.js new file mode 100644 index 0000000000..0771dbc67b --- /dev/null +++ b/src/utils/serviceErrorBoundary.js @@ -0,0 +1,121 @@ +import { createError, ErrorTypes, TitanBotError } from './errorHandler.js'; +import { resolveErrorCode, getErrorMetadata } from './errorRegistry.js'; + +function normalizeBoundaryContext(context = {}) { + if (!context || typeof context !== 'object') { + return {}; + } + + return context; +} + +function inferErrorType(error, fallbackType = ErrorTypes.UNKNOWN) { + const message = error?.message?.toLowerCase?.() || ''; + const code = error?.code; + + if (typeof code === 'string') { + if (code.includes('PERMISSION') || code.includes('FORBIDDEN')) { + return ErrorTypes.PERMISSION; + } + + if (code.includes('VALIDATION') || code.includes('INVALID')) { + return ErrorTypes.VALIDATION; + } + + if (code.includes('DB') || code.includes('SQL') || code.includes('POSTGRES')) { + return ErrorTypes.DATABASE; + } + } + + if (message.includes('permission') || message.includes('forbidden')) { + return ErrorTypes.PERMISSION; + } + + if (message.includes('database') || message.includes('sql') || message.includes('connection') || message.includes('timeout')) { + return ErrorTypes.DATABASE; + } + + if (message.includes('validation') || message.includes('invalid') || message.includes('required')) { + return ErrorTypes.VALIDATION; + } + + return fallbackType; +} + +export function ensureTypedServiceError(error, options = {}) { + if (error instanceof TitanBotError) { + return error; + } + + const context = normalizeBoundaryContext(options.context); + const fallbackType = options.type || ErrorTypes.UNKNOWN; + const type = inferErrorType(error, fallbackType); + const service = options.service || 'unknown_service'; + const operation = options.operation || 'unknown_operation'; + const errorCode = resolveErrorCode({ + error, + errorType: type, + context: { + errorCode: options.errorCode || `${service}.${operation}.failed` + } + }); + const errorMetadata = getErrorMetadata(errorCode); + const message = options.message || `${service}.${operation} failed`; + const userMessage = options.userMessage || 'Something went wrong while processing your request.'; + + return createError(message, type, userMessage, { + ...context, + service, + operation, + errorCode, + remediationHint: errorMetadata.remediation, + severity: errorMetadata.severity, + retryable: errorMetadata.retryable, + originalErrorMessage: error?.message || String(error), + originalErrorName: error?.name || 'Error', + expected: false + }); +} + +export function wrapServiceBoundary(fn, options = {}) { + return function wrappedServiceBoundary(...args) { + try { + const result = fn.apply(this, args); + + if (result && typeof result.then === 'function') { + return result.catch((error) => { + throw ensureTypedServiceError(error, typeof options === 'function' ? options(...args) : options); + }); + } + + return result; + } catch (error) { + throw ensureTypedServiceError(error, typeof options === 'function' ? options(...args) : options); + } + }; +} + +export function wrapServiceClassMethods(ServiceClass, optionsFactory) { + const methodNames = Object.getOwnPropertyNames(ServiceClass) + .filter((name) => name !== 'length' && name !== 'name' && name !== 'prototype') + .filter((name) => typeof ServiceClass[name] === 'function'); + + for (const methodName of methodNames) { + ServiceClass[methodName] = wrapServiceBoundary( + ServiceClass[methodName], + (...args) => { + const baseOptions = typeof optionsFactory === 'function' + ? optionsFactory(methodName, ...args) + : {}; + + return { + service: ServiceClass.name || 'ServiceClass', + operation: methodName, + ...baseOptions + }; + } + ); + } + + return ServiceClass; +} diff --git a/src/utils/sqlIdentifiers.js b/src/utils/sqlIdentifiers.js new file mode 100644 index 0000000000..60dc697041 --- /dev/null +++ b/src/utils/sqlIdentifiers.js @@ -0,0 +1,21 @@ +const SQL_IDENTIFIER_PATTERN = /^[a-z_][a-z0-9_]*$/; + +export function assertAllowlistedIdentifier(identifier, allowlist, label = 'SQL identifier') { + if (typeof identifier !== 'string' || identifier.trim().length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + + if (!SQL_IDENTIFIER_PATTERN.test(identifier)) { + throw new Error(`${label} contains unsafe characters: ${identifier}`); + } + + if (!allowlist.has(identifier)) { + throw new Error(`${label} is not in the allowlist: ${identifier}`); + } + + return identifier; +} + +export function quoteIdentifier(identifier) { + return `"${identifier}"`; +} diff --git a/src/utils/ticketPermissions.js b/src/utils/ticketPermissions.js index 662243c406..7ba2dc2375 100644 --- a/src/utils/ticketPermissions.js +++ b/src/utils/ticketPermissions.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits } from 'discord.js'; import { getGuildConfig } from '../services/guildConfig.js'; -import { getTicketData } from '../services/database.js'; +import { getTicketData } from './database.js'; export async function getTicketPermissionContext({ client, interaction }) { const guildId = interaction.guildId; diff --git a/src/utils/traceContext.js b/src/utils/traceContext.js new file mode 100644 index 0000000000..4b0f3040d0 --- /dev/null +++ b/src/utils/traceContext.js @@ -0,0 +1,45 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import crypto from 'crypto'; + +const traceStorage = new AsyncLocalStorage(); + +function sanitizeCommandName(interaction) { + if (interaction?.isChatInputCommand?.() && interaction.commandName) { + return interaction.commandName; + } + + if (interaction?.isButton?.() || interaction?.isModalSubmit?.() || interaction?.isStringSelectMenu?.()) { + return interaction.customId || null; + } + + return null; +} + +export function createTraceId(prefix = 'trc') { + return `${prefix}_${crypto.randomUUID().replace(/-/g, '')}`; +} + +export function createInteractionTraceContext(interaction, overrides = {}) { + return { + traceId: createTraceId(), + interactionId: interaction?.id || null, + interactionType: interaction?.type || null, + guildId: interaction?.guildId || null, + channelId: interaction?.channelId || null, + userId: interaction?.user?.id || null, + command: sanitizeCommandName(interaction), + ...overrides + }; +} + +export function runWithTraceContext(traceContext, callback) { + return traceStorage.run(traceContext, callback); +} + +export function getTraceContext() { + return traceStorage.getStore() || null; +} + +export function getTraceId() { + return getTraceContext()?.traceId || null; +} diff --git a/tests/failure-paths/abuseProtection.test.js b/tests/failure-paths/abuseProtection.test.js new file mode 100644 index 0000000000..652ffb13d0 --- /dev/null +++ b/tests/failure-paths/abuseProtection.test.js @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + enforceAbuseProtection, + formatCooldownDuration, + isRiskyCommand, + resetAbuseProtectionState +} from '../../src/utils/abuseProtection.js'; +import { logger } from '../../src/utils/logger.js'; + +function createInteraction(overrides = {}) { + return { + guildId: 'guild-1', + user: { id: 'user-1' }, + ...overrides + }; +} + +test('detects risky commands by category and explicit name', () => { + assert.equal(isRiskyCommand({ category: 'moderation' }, 'ban'), true); + assert.equal(isRiskyCommand({ category: 'fun' }, 'wipedata'), true); + assert.equal(isRiskyCommand({ category: 'fun' }, 'ping'), false); +}); + +test('blocks risky command after cooldown limit is exceeded', async () => { + resetAbuseProtectionState(); + const interaction = createInteraction(); + const riskyCommand = { category: 'moderation' }; + + const first = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); + const second = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); + const third = await enforceAbuseProtection(interaction, riskyCommand, 'ban'); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, true); + assert.equal(third.allowed, false); + assert.ok(third.remainingMs > 0); +}); + +test('supports command-level abuse policy overrides', async () => { + resetAbuseProtectionState(); + const interaction = createInteraction(); + const command = { + category: 'fun', + abuseProtection: { + enabled: true, + maxAttempts: 1, + windowMs: 5_000 + } + }; + + const first = await enforceAbuseProtection(interaction, command, 'custom-risky'); + const second = await enforceAbuseProtection(interaction, command, 'custom-risky'); + + assert.equal(first.allowed, true); + assert.equal(second.allowed, false); + assert.equal(second.policy.maxAttempts, 1); + assert.equal(second.policy.windowMs, 5_000); +}); + +test('logs anomaly warning after repeated blocked attempts', async () => { + resetAbuseProtectionState(); + const interaction = createInteraction({ guildId: 'guild-2', user: { id: 'user-2' } }); + const command = { + category: 'moderation', + abuseProtection: { + maxAttempts: 1, + windowMs: 60_000, + anomaly: { + threshold: 3, + windowMs: 60_000 + } + } + }; + + const warnLogs = []; + const originalWarn = logger.warn; + logger.warn = (message, meta) => { + warnLogs.push({ message, meta }); + }; + + try { + await enforceAbuseProtection(interaction, command, 'ban'); + await enforceAbuseProtection(interaction, command, 'ban'); + await enforceAbuseProtection(interaction, command, 'ban'); + await enforceAbuseProtection(interaction, command, 'ban'); + + const anomalyLog = warnLogs.find((entry) => entry.meta?.event === 'interaction.command.abuse_anomaly'); + assert.ok(anomalyLog, 'expected anomaly log event after repeated blocked attempts'); + assert.equal(anomalyLog.meta.command, 'ban'); + } finally { + logger.warn = originalWarn; + } +}); + +test('formats cooldown duration for user-facing messaging', () => { + assert.equal(formatCooldownDuration(500), '1s'); + assert.equal(formatCooldownDuration(61_000), '1m 1s'); +}); diff --git a/tests/failure-paths/database.failure.test.js b/tests/failure-paths/database.failure.test.js new file mode 100644 index 0000000000..8242859033 --- /dev/null +++ b/tests/failure-paths/database.failure.test.js @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { db, initializeDatabase } from '../../src/utils/database.js'; +import { pgDb } from '../../src/utils/postgresDatabase.js'; + +function resetDbSingleton() { + db.initialized = false; + db.db = null; + db.useFallback = false; + db.connectionType = 'none'; + db.degradedModeWarningShown = false; + db.degradedReason = null; +} + +test('DB down path falls back to memory with degraded status', async () => { + const originalConnect = pgDb.connect; + const originalGetLastFailure = pgDb.getLastFailure; + + try { + resetDbSingleton(); + + pgDb.connect = async () => false; + pgDb.getLastFailure = () => ({ + reason: 'POSTGRES_CONNECTION_FAILED', + message: 'connect ECONNREFUSED' + }); + + await initializeDatabase(); + + const status = db.getStatus(); + assert.equal(status.initialized, true); + assert.equal(status.isDegraded, true); + assert.equal(status.connectionType, 'memory'); + assert.equal(status.degradedReason, 'POSTGRES_UNAVAILABLE'); + + await db.set('health:key', { ok: true }); + const value = await db.get('health:key'); + assert.deepEqual(value, { ok: true }); + } finally { + pgDb.connect = originalConnect; + pgDb.getLastFailure = originalGetLastFailure; + resetDbSingleton(); + } +}); diff --git a/tests/failure-paths/errorHandler.failure.test.js b/tests/failure-paths/errorHandler.failure.test.js new file mode 100644 index 0000000000..549cf70103 --- /dev/null +++ b/tests/failure-paths/errorHandler.failure.test.js @@ -0,0 +1,101 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { handleInteractionError } from '../../src/utils/errorHandler.js'; +import { logger } from '../../src/utils/logger.js'; + +function createLoggerCapture() { + const entries = []; + const originals = { + warn: logger.warn, + error: logger.error, + debug: logger.debug + }; + + logger.warn = (message, meta) => entries.push({ level: 'warn', message, meta }); + logger.error = (message, meta) => entries.push({ level: 'error', message, meta }); + logger.debug = (message, meta) => entries.push({ level: 'debug', message, meta }); + + return { + entries, + restore() { + logger.warn = originals.warn; + logger.error = originals.error; + logger.debug = originals.debug; + } + }; +} + +function createInteraction(overrides = {}) { + return { + id: 'interaction-1', + createdTimestamp: Date.now(), + deferred: false, + replied: false, + guildId: 'guild-1', + channelId: 'channel-1', + commandName: 'ping', + type: 2, + customId: null, + user: { id: 'user-1' }, + async reply() {}, + async editReply() {}, + ...overrides + }; +} + +test('expired interaction path logs INTERACTION_EXPIRED and skips reply', async () => { + const capture = createLoggerCapture(); + let replyCalled = false; + + try { + const interaction = createInteraction({ + createdTimestamp: Date.now() - (15 * 60 * 1000), + reply: async () => { + replyCalled = true; + } + }); + + const error = new Error('database timeout during operation'); + await handleInteractionError(interaction, error, {}); + + assert.equal(replyCalled, false, 'expired interactions should not attempt reply'); + + const expiredLog = capture.entries.find( + (entry) => entry.level === 'warn' && entry.meta?.event === 'interaction.error.expired' + ); + + assert.ok(expiredLog, 'should log interaction expiry event'); + assert.equal(expiredLog.meta?.errorCode, 'INTERACTION_EXPIRED'); + } finally { + capture.restore(); + } +}); + +test('Discord API response failure path logs response_unavailable when API says expired', async () => { + const capture = createLoggerCapture(); + + try { + const interaction = createInteraction({ + reply: async () => { + const err = new Error('Unknown interaction'); + err.code = 10062; + throw err; + } + }); + + const apiError = new Error('Discord API request failed'); + apiError.code = 10062; + + await handleInteractionError(interaction, apiError, {}); + + const unavailableLog = capture.entries.find( + (entry) => entry.level === 'warn' && entry.meta?.event === 'interaction.error.response_unavailable' + ); + + assert.ok(unavailableLog, 'should log unavailable response event'); + assert.equal(unavailableLog.meta?.errorCode, '10062'); + } finally { + capture.restore(); + } +}); diff --git a/tests/failure-paths/safeMathParser.test.js b/tests/failure-paths/safeMathParser.test.js new file mode 100644 index 0000000000..22437ca4a3 --- /dev/null +++ b/tests/failure-paths/safeMathParser.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { evaluateMathExpression } from '../../src/utils/safeMathParser.js'; + +test('safe parser evaluates arithmetic precedence correctly', () => { + const result = evaluateMathExpression('2 + 2 * 3'); + assert.equal(result, 8); +}); + +test('safe parser evaluates trig with degree conversion', () => { + const result = evaluateMathExpression('sin(45 deg)'); + assert.ok(Math.abs(result - 0.7071067811865476) < 1e-10); +}); + +test('safe parser supports constants and exponent operator', () => { + const result = evaluateMathExpression('pi ^ 2'); + assert.ok(Math.abs(result - (Math.PI ** 2)) < 1e-10); +}); + +test('safe parser rejects code-like tokens', () => { + assert.throws( + () => evaluateMathExpression('process.exit()'), + /Unsupported token|Unsupported character/ + ); +}); + +test('safe parser rejects malformed expressions', () => { + assert.throws( + () => evaluateMathExpression('2 + (3 * 4'), + /Mismatched parentheses/ + ); +}); diff --git a/tests/failure-paths/zodValidationCoverage.test.js b/tests/failure-paths/zodValidationCoverage.test.js new file mode 100644 index 0000000000..dc4852b4fd --- /dev/null +++ b/tests/failure-paths/zodValidationCoverage.test.js @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { validateChatInputPayloadOrThrow } from '../../src/utils/commandInputValidation.js'; +import { validateGuildConfigOrThrow } from '../../src/utils/schemas.js'; + +test('zod command input validation accepts valid command payload', () => { + const interaction = { + commandName: 'ping', + options: { + data: [ + { name: 'target', type: 3, value: 'user123' }, + { name: 'count', type: 4, value: 3 } + ] + } + }; + + const result = validateChatInputPayloadOrThrow(interaction); + assert.equal(result.commandName, 'ping'); + assert.equal(result.options.length, 2); +}); + +test('zod command input validation rejects invalid option payload shape', () => { + const interaction = { + commandName: 'ping', + options: { + data: [ + { name: 'bad', type: 3, value: { nested: 'not-allowed' } } + ] + } + }; + + assert.throws( + () => validateChatInputPayloadOrThrow(interaction), + /Invalid command input payload/ + ); +}); + +test('zod guild config validation accepts valid config write payload', () => { + const config = { + prefix: '!', + logging: { + enabled: true, + enabledEvents: { + 'message.delete': true + } + }, + logIgnore: { + users: ['123'], + channels: ['456'] + } + }; + + const validated = validateGuildConfigOrThrow(config); + assert.equal(validated.prefix, '!'); + assert.equal(validated.logging.enabled, true); +}); + +test('zod guild config validation rejects invalid config write payload', () => { + const invalidConfig = { + prefix: '!', + logging: { + enabled: 'yes', + enabledEvents: {} + } + }; + + assert.throws( + () => validateGuildConfigOrThrow(invalidConfig), + /Invalid guild configuration payload/ + ); +}); From 7eed04e382fd0cd8f86138f2e6ab104d68749a5e Mon Sep 17 00:00:00 2001 From: codebymitch Date: Fri, 6 Mar 2026 22:23:54 +1100 Subject: [PATCH 06/10] Add detailed defaults & docs to botConfig --- src/config/bot.js | 239 ++++++++++++++++++++++++++++++++++++---------- 1 file changed, 189 insertions(+), 50 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index b416b8df94..4c0c78579b 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -2,68 +2,107 @@ import { logger } from '../utils/logger.js'; export const botConfig = { - - + // ========================= + // BOT PRESENCE (what users see under the bot name) + // ========================= + // `status` options: + // - "online" = green dot + // - "idle" = yellow moon + // - "dnd" = red do-not-disturb + // - "invisible" = appears offline presence: { + // Current online state shown on Discord. status: "online", + + // Activity lines shown under the bot name. + // `type` number mapping from Discord: + // 0 = Playing + // 1 = Streaming + // 2 = Listening + // 3 = Watching + // 4 = Custom + // 5 = Competing activities: [ { + // Text users will see (example: "Playing /help | Titan Bot"). name: "/help | Titan Bot", + // Activity type number (0 = Playing). type: 0, }, ], }, - + // ========================= + // COMMAND BEHAVIOR + // ========================= commands: { - prefix: "!", + // Bot owner user IDs (comma-separated in OWNER_IDS env var). + // Owners can access owner/admin-level bot commands. owners: process.env.OWNER_IDS?.split(",") || [], + + // Default wait time between command uses (in seconds). defaultCooldown: 3, + + // If true, old commands are removed before re-registering. deleteCommands: false, + + // Optional server ID used for testing slash commands quickly. testGuildId: process.env.TEST_GUILD_ID, }, - - prefix: "!", - - + // ========================= + // APPLICATIONS SYSTEM + // ========================= applications: { + // Default questions shown when someone fills out an application. defaultQuestions: [ { question: "What is your name?", required: true }, { question: "How old are you?", required: true }, { question: "Why do you want to join?", required: true }, ], + + // Embed colors by application status. statusColors: { pending: "#FFA500", approved: "#00FF00", denied: "#FF0000", }, + + // How long users must wait before submitting another application (hours). applicationCooldown: 24, + + // Auto-delete denied applications after this many days. deleteDeniedAfter: 7, + + // Auto-delete approved applications after this many days. deleteApprovedAfter: 30, + + // Role IDs allowed to manage applications. managerRoles: [], // Will be populated from environment or database }, - + // ========================= + // EMBED COLORS & BRANDING + // ========================= // IMPORTANT: This is the SINGLE SOURCE OF TRUTH for all bot colors embeds: { colors: { - + // Main brand colors. primary: "#336699", secondary: "#2F3136", - + // Standard status colors for success/error/warning/info messages. success: "#57F287", error: "#ED4245", warning: "#FEE75C", info: "#3498DB", - + // Neutral utility colors. light: "#FFFFFF", dark: "#202225", gray: "#99AAB5", - + // Discord-style palette shortcuts. blurple: "#5865F2", green: "#57F287", yellow: "#FEE75C", @@ -71,7 +110,7 @@ export const botConfig = { red: "#ED4245", black: "#000000", - + // Feature-specific colors. giveaway: { active: "#57F287", ended: "#ED4245", @@ -86,7 +125,7 @@ export const botConfig = { birthday: "#E91E63", moderation: "#9B59B6", - + // Ticket priority color mapping. priority: { none: "#95A5A6", low: "#3498db", @@ -96,44 +135,78 @@ export const botConfig = { }, }, footer: { + // Default footer text used in bot embeds. text: "Titan Bot", + // Footer icon URL (null = no icon). icon: null, }, + // Default thumbnail URL for embeds (null = no thumbnail). thumbnail: null, author: { + // Optional default embed author block. name: null, icon: null, url: null, }, }, - + // ========================= + // ECONOMY SETTINGS + // ========================= economy: { currency: { + // Currency display name. name: "coins", + // Plural display name. namePlural: "coins", + // Currency symbol shown in balances. symbol: "$", }, + + // Starting balance for new users. startingBalance: 0, + + // Maximum bank amount before upgrades (if upgrades are used). baseBankCapacity: 100000, + + // Daily reward amount. dailyAmount: 100, + + // Work command random payout range. workMin: 10, workMax: 100, + + // Beg command random payout range. begMin: 5, begMax: 50, + + // Chance to succeed when robbing (0.4 = 40%). robSuccessRate: 0.4, + + // Jail time after failed rob (milliseconds). + // 3600000 = 1 hour. robFailJailTime: 3600000, }, - + // ========================= + // SHOP SETTINGS + // ========================= + // Add shop defaults here when needed. shop: { }, - + // ========================= + // TICKET SYSTEM + // ========================= tickets: { + // Category ID where new tickets are created (null = no forced category). defaultCategory: null, + + // Role IDs allowed to manage/support tickets. supportRoles: [], + + // Priority options users/staff can assign. priorities: { none: { emoji: "โšช", @@ -161,109 +234,170 @@ export const botConfig = { label: "Urgent", }, }, + + // Default priority for new tickets. defaultPriority: "none", + + // Category ID where closed tickets are archived. archiveCategory: null, + + // Channel ID where ticket logs are sent. logChannel: null, }, - + // ========================= + // GIVEAWAY SETTINGS + // ========================= giveaways: { + // Default giveaway duration in milliseconds. + // 86400000 = 24 hours. defaultDuration: 86400000, + + // Allowed winner count range. minimumWinners: 1, maximumWinners: 10, + + // Allowed giveaway duration range in milliseconds. + // 300000 = 5 minutes. minimumDuration: 300000, + // 2592000000 = 30 days. maximumDuration: 2592000000, + + // Role IDs allowed to host giveaways. allowedRoles: [], + + // Role IDs that bypass giveaway restrictions. bypassRoles: [], }, - + // ========================= + // BIRTHDAY SETTINGS + // ========================= birthday: { + // Role ID given to users on their birthday. defaultRole: null, + + // Channel ID where birthday announcements are posted. announcementChannel: null, + + // Timezone used to calculate birthday dates. timezone: "UTC", }, - + // ========================= + // VERIFICATION SETTINGS + // ========================= verification: { - + // Message shown when posting the verification panel. defaultMessage: "Click the button below to verify yourself and gain access to the server!", - - + + // Text on the verification button. defaultButtonText: "Verify", - - + + // Automatic verification behavior. autoVerify: { - + // How automatic verification decides who is auto-approved: + // - "none" = everyone is auto-verified immediately + // - "account_age" = account must be older than set days + // - "server_size" = auto-verify everyone only in smaller servers defaultCriteria: "none", - + // Days used when `defaultCriteria` is `account_age`. defaultAccountAgeDays: 7, + + // Member count threshold used when `defaultCriteria` is `server_size`. + // Example: 1000 means auto-verify if server has fewer than 1000 members. serverSizeThreshold: 1000, - - + + // Allowed safety limits for account-age requirements. + // 1 = minimum day, 365 = maximum days. minAccountAge: 1, maxAccountAge: 365, - - + + // If true, user receives a DM after verification. sendDMNotification: true, - - + + // Human-readable descriptions for each criteria mode. criteria: { account_age: "Account must be older than specified days", server_size: "All users if server has less than 1000 members", none: "All users immediately" } }, - - - + + // Minimum time between verification attempts (milliseconds). + // 5000 = 5 seconds. verificationCooldown: 5000, - - + + // Maximum failed attempts allowed inside the time window below. maxVerificationAttempts: 3, + + // Time window for counting attempts (milliseconds). + // 60000 = 1 minute. attemptWindow: 60000, - + // In-memory safety limits (helps avoid unbounded memory growth). maxCooldownEntries: 10000, maxAttemptEntries: 10000, + // Cleanup frequency for cooldown/attempt maps (milliseconds). + // 300000 = 5 minutes. cooldownCleanupInterval: 300000, + // Maximum metadata payload size for audit entries (bytes). maxAuditMetadataBytes: 4096, + // Maximum number of audit entries kept in memory. maxInMemoryAuditEntries: 1000, - - + + // If true, log every verification action. logAllVerifications: true, + // If true, preserve verification audit history. keepAuditTrail: true }, - + // ========================= + // WELCOME / GOODBYE MESSAGES + // ========================= welcome: { + // Welcome template posted when a user joins. + // Placeholders: {user}, {server}, {memberCount} defaultWelcomeMessage: "Welcome {user} to {server}! We now have {memberCount} members!", + // Goodbye template posted when a user leaves. + // Placeholders: {user}, {memberCount} defaultGoodbyeMessage: "{user} has left the server. We now have {memberCount} members.", + // Channel ID for welcome messages. defaultWelcomeChannel: null, + // Channel ID for goodbye messages. defaultGoodbyeChannel: null, }, - + // ========================= + // COUNTER CHANNELS + // ========================= counters: { defaults: { + // Default naming/description templates for counter entries. name: "{name} Counter", description: "Server {name} counter", + // Channel type used for counters (typically "voice"). type: "voice", + // Channel name format. `{count}` is replaced automatically. channelName: "{name}-{count}", }, permissions: { + // Default denied permissions for the counter channel. deny: ["VIEW_CHANNEL"], + // Default allowed permissions for the counter channel. allow: ["VIEW_CHANNEL", "CONNECT", "SPEAK"], }, messages: { + // Default response messages for counter actions. created: "โœ… Created counter **{name}**", deleted: "๐Ÿ—‘๏ธ Deleted counter **{name}**", updated: "๐Ÿ”„ Updated counter **{name}**", }, types: { + // Built-in counter types and how each count is calculated. members: { name: "๐Ÿ‘ฅ Members", description: "Total members in the server", @@ -284,7 +418,9 @@ export const botConfig = { }, }, - + // ========================= + // GENERIC BOT MESSAGES + // ========================= messages: { noPermission: "You do not have permission to use this command.", cooldownActive: "Please wait {time} before using this command again.", @@ -295,27 +431,30 @@ export const botConfig = { maintenanceMode: "The bot is currently in maintenance mode.", }, - + // ========================= + // FEATURE TOGGLES + // ========================= + // Set any feature to `false` to disable it globally. features: { - + // Core systems. economy: true, leveling: true, moderation: true, logging: true, welcome: true, - + // Community engagement systems. tickets: true, giveaways: true, birthday: true, counter: true, - + // Security and self-service systems. verification: true, reactionRoles: true, joinToCreate: true, - + // Utility/quality-of-life modules. voice: true, search: true, tools: true, From f53091dd58f38f363e258c3ee83998af8d753bf3 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sat, 7 Mar 2026 22:33:00 +1100 Subject: [PATCH 07/10] Resolve merge conflict in verification config --- src/config/bot.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/config/bot.js b/src/config/bot.js index 4c0c78579b..dfb53823d5 100644 --- a/src/config/bot.js +++ b/src/config/bot.js @@ -25,7 +25,7 @@ export const botConfig = { activities: [ { // Text users will see (example: "Playing /help | Titan Bot"). - name: "/help | Titan Bot", + name: "Made with โค๏ธ", // Activity type number (0 = Playing). type: 0, }, @@ -348,9 +348,9 @@ export const botConfig = { maxInMemoryAuditEntries: 1000, // If true, log every verification action. - logAllVerifications: true, + logAllVerifications: true, // If true, preserve verification audit history. - keepAuditTrail: true + keepAuditTrail: true, }, // ========================= From 0988274a8ff372c88c3a3ed0ac3e4310e862f8c2 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 15 Mar 2026 21:10:20 +1100 Subject: [PATCH 08/10] Clean up stale birthdays & backup on leave/join --- .../Birthday/modules/birthday_list.js | 49 +++++++++++++++---- .../Birthday/modules/next_birthdays.js | 31 +++++++++--- src/events/guildMemberAdd.js | 16 ++++++ src/events/guildMemberRemove.js | 16 ++++++ 4 files changed, 96 insertions(+), 16 deletions(-) diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js index 8baf355914..81662b4cc4 100644 --- a/src/commands/Birthday/modules/birthday_list.js +++ b/src/commands/Birthday/modules/birthday_list.js @@ -1,6 +1,7 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getAllBirthdays } from '../../../services/birthdayService.js'; +import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; @@ -30,22 +31,52 @@ export default { color: 'info' }); - let birthdayList = `**${sortedBirthdays.length} birthdays in ${interaction.guild.name}**\n\n`; - sortedBirthdays.forEach((birthday, index) => { - const member = interaction.guild.members.cache.get(birthday.userId); - const userName = member ? member.user.username : `User ${birthday.userId}`; - birthdayList += `${index + 1}. **${userName}** - ${birthday.monthName} ${birthday.day}\n`; - }); + // Batch fetch to verify which users are still in the guild + const userIds = sortedBirthdays.map(b => b.userId); + const fetchedMembers = await interaction.guild.members.fetch({ user: userIds }).catch(() => null); + + let birthdayList = ''; + let displayIndex = 0; + const staleUserIds = []; + + for (const birthday of sortedBirthdays) { + if (fetchedMembers && !fetchedMembers.has(birthday.userId)) { + staleUserIds.push(birthday.userId); + continue; + } + displayIndex++; + birthdayList += `${displayIndex}. <@${birthday.userId}> - ${birthday.monthName} ${birthday.day}\n`; + } + + // Clean up birthday entries for members who left the server + if (fetchedMembers && staleUserIds.length > 0) { + for (const userId of staleUserIds) { + deleteBirthday(client, guildId, userId).catch(() => null); + } + } + + if (displayIndex === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [createEmbed({ + title: 'โŒ No Birthdays', + description: 'No birthdays have been set by current server members.', + color: 'error' + })] + }); + } + + birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - embed.setDescription(birthdayList || "No birthdays found"); - embed.setFooter({ text: `Total: ${sortedBirthdays.length} birthdays` }); + embed.setDescription(birthdayList); + embed.setFooter({ text: `Total: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}` }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); logger.info('Birthday list retrieved successfully', { userId: interaction.user.id, guildId, - birthdayCount: sortedBirthdays.length, + birthdayCount: displayIndex, + staleRemoved: staleUserIds.length, commandName: 'birthday_list' }); } catch (error) { diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js index 73bba8969b..778d392e22 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.js @@ -1,6 +1,7 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; +import { deleteBirthday } from '../../../utils/database.js'; import { logger } from '../../../utils/logger.js'; import { handleInteractionError } from '../../../utils/errorHandler.js'; @@ -31,11 +32,15 @@ export default { color: 'info' }); - for (let i = 0; i < next5.length; i++) { - const birthday = next5[i]; + let displayIndex = 0; + for (const birthday of next5) { const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - const userName = member ? member.user.username : `User ${birthday.userId}`; - + if (!member) { + deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); + continue; + } + displayIndex++; + let timeUntil = ''; if (birthday.daysUntil === 0) { timeUntil = '๐ŸŽ‰ **Today!**'; @@ -46,12 +51,24 @@ export default { } embed.addFields({ - name: `${i + 1}. ${userName}`, - value: `๐Ÿ“… **Date:** ${birthday.monthName} ${birthday.day}\nโฐ **Time:** ${timeUntil}`, + name: `${displayIndex}. ${member.displayName}`, + value: `<@${birthday.userId}>\n๐Ÿ“… **Date:** ${birthday.monthName} ${birthday.day}\nโฐ **Time:** ${timeUntil}`, inline: false }); } + if (displayIndex === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'โŒ No Upcoming Birthdays', + description: 'No upcoming birthdays found for current server members.', + color: 'error' + }) + ] + }); + } + embed.setFooter({ text: 'Use /birthday set to add your birthday!', iconURL: interaction.guild.iconURL() @@ -62,7 +79,7 @@ export default { logger.info('Next birthdays retrieved successfully', { userId: interaction.user.id, guildId: interaction.guildId, - upcomingCount: next5.length, + upcomingCount: displayIndex, commandName: 'next_birthdays' }); } catch (error) { diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index aee86a555e..0310155716 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -5,6 +5,7 @@ import { getWelcomeConfig } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { getServerCounters, updateCounter } from '../services/counterService.js'; +import { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; export default { @@ -149,6 +150,21 @@ export default { logger.debug('Error updating counters on member join:', error); } + // Restore birthday data if the member previously left + try { + const backupKey = `guild:${guild.id}:birthdays:left`; + const backup = (await member.client.db.get(backupKey)) || {}; + if (backup[user.id]) { + const { month, day } = backup[user.id]; + await dbSetBirthday(member.client, guild.id, user.id, month, day); + delete backup[user.id]; + await member.client.db.set(backupKey, backup); + logger.debug(`Birthday restored for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error restoring birthday on member join:', error); + } + } catch (error) { logger.error('Error in guildMemberAdd event:', error); } diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index 12f768b206..a48aa6121f 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -4,6 +4,7 @@ import { getWelcomeConfig } from '../utils/database.js'; import { formatWelcomeMessage } from '../utils/welcome.js'; import { logEvent, EVENT_TYPES } from '../services/loggingService.js'; import { getServerCounters, updateCounter } from '../services/counterService.js'; +import { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; export default { @@ -120,6 +121,21 @@ export default { logger.debug('Error updating counters on member leave:', error); } + // Backup and remove birthday data when a member leaves + try { + const birthdays = await getGuildBirthdays(member.client, guild.id); + if (birthdays[user.id]) { + const backupKey = `guild:${guild.id}:birthdays:left`; + const backup = (await member.client.db.get(backupKey)) || {}; + backup[user.id] = birthdays[user.id]; + await member.client.db.set(backupKey, backup); + await deleteBirthday(member.client, guild.id, user.id); + logger.debug(`Birthday backed up and removed for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error handling birthday on member leave:', error); + } + } catch (error) { logger.error('Error in guildMemberRemove event:', error); } From bb531bc28ca8a4bb56f335eedcc635d84c92405b Mon Sep 17 00:00:00 2001 From: codebymitch Date: Fri, 27 Mar 2026 17:00:23 +1100 Subject: [PATCH 09/10] Simplify/Fix economy leaderboard and adjust DB mapping --- src/commands/Economy/eleaderboard.js | 64 ++++++---------------------- src/utils/postgresDatabase.js | 48 +++++---------------- 2 files changed, 24 insertions(+), 88 deletions(-) diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js index 9048d6ea58..d967116901 100644 --- a/src/commands/Economy/eleaderboard.js +++ b/src/commands/Economy/eleaderboard.js @@ -1,8 +1,5 @@ import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; - -import { getEconomyKey } from '../../utils/database.js'; -import { botConfig } from '../../config/bot.js'; +import { createEmbed } from '../../utils/embeds.js'; import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; @@ -11,17 +8,6 @@ export default { data: new SlashCommandBuilder() .setName("eleaderboard") .setDescription("View the server's top 10 richest users.") - .addStringOption((option) => - option - .setName("sort_by") - .setDescription("The metric to sort the leaderboard by.") - .addChoices( - { name: "Net Worth (Cash + Bank)", value: "net_worth" }, - { name: "Cash", value: "cash" }, - { name: "Bank", value: "bank" }, - ) - .setRequired(false), - ) .setDMPermission(false), @@ -30,11 +16,10 @@ export default { if (!deferred) return; const guildId = interaction.guildId; - const sortBy = interaction.options.getString("sort_by") || "net_worth"; - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId, sortBy }); + logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - const prefix = `guild:${guildId}:economy:`; + const prefix = `economy:${guildId}:`; let allKeys = await client.db.list(prefix); @@ -59,19 +44,12 @@ export default { if (userData) { allUserData.push({ userId: userId, - cash: userData.wallet || 0, - bank: userData.bank || 0, net_worth: (userData.wallet || 0) + (userData.bank || 0), }); } } - allUserData.sort((a, b) => { - if (sortBy === "net_worth") return b.net_worth - a.net_worth; - if (sortBy === "cash") return b.cash - a.cash; - if (sortBy === "bank") return b.bank - a.bank; -return b.net_worth - a.net_worth; - }); + allUserData.sort((a, b) => b.net_worth - a.net_worth); const topUsers = allUserData.slice(0, 10); const userRank = @@ -82,44 +60,28 @@ return b.net_worth - a.net_worth; for (let i = 0; i < topUsers.length; i++) { const user = topUsers[i]; - const member = await interaction.guild.members - .fetch(user.userId) - .catch(() => null); - const username = member - ? member.user.username - : `Unknown User (${user.userId})`; const rank = i + 1; const emoji = rankEmoji[i] || `**#${rank}**`; - const value = - sortBy === "net_worth" - ? user.net_worth - : sortBy === "cash" - ? user.cash - : user.bank; leaderboardEntries.push( - `${emoji} **${username}** - $${value.toLocaleString()}`, + `${emoji} <@${user.userId}> - ๐Ÿฆ ${user.net_worth.toLocaleString()}`, ); } - const fieldNameMap = { - net_worth: "Net Worth (Cash + Bank)", - cash: "Cash Balance", - bank: "Bank Balance", - }; - logger.info(`[ECONOMY] Leaderboard generated`, { guildId, - sortBy, userCount: allUserData.length, userRank }); - const embed = createEmbed( - `๐Ÿ‘‘ Economy Leaderboard (${fieldNameMap[sortBy]})`, - leaderboardEntries.join("\n"), - ).setFooter({ - text: `Your Rank: ${userRank > 0 ? userRank : "Not Ranked"} | Data sorted by ${fieldNameMap[sortBy]}`, + const description = leaderboardEntries.length > 0 + ? leaderboardEntries.join("\n") + : "No economy data is available for this server yet."; + + const embed = createEmbed({ + title: `Economy Leaderboard`, + description, + footer: `Your Rank: ${userRank > 0 ? `#${userRank}` : "No ranking data available"}`, }); await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); diff --git a/src/utils/postgresDatabase.js b/src/utils/postgresDatabase.js index bb4ecd1689..7e8d76d902 100644 --- a/src/utils/postgresDatabase.js +++ b/src/utils/postgresDatabase.js @@ -3,10 +3,6 @@ import { pgConfig } from '../config/postgres.js'; import { logger } from './logger.js'; import { assertAllowlistedIdentifier, quoteIdentifier } from './sqlIdentifiers.js'; -/** - * PostgreSQL Database wrapper for Titan Bot - * Replaces Redis with PostgreSQL for better data persistence and querying - */ class PostgreSQLDatabase { constructor() { this.pool = null; @@ -775,36 +771,6 @@ class PostgreSQLDatabase { } } - /** - * Parse a Redis-style key to determine type and components - * Maps Redis-style hierarchical keys to specific database tables and operations - * - * KEY FORMATS SUPPORTED: - * - Guild Config: 'guild:{guildId}:config' โ†’ guild_config - * - Guild Birthdays: 'guild:{guildId}:birthdays' โ†’ guild_birthdays - * - Guild Giveaways: 'guild:{guildId}:giveaways' โ†’ guild_giveaways - * - Welcome Config: 'guild:{guildId}:welcome' โ†’ welcome_config - * - Leveling Config: 'guild:{guildId}:leveling:config' โ†’ leveling_config - * - User Levels: 'guild:{guildId}:leveling:users:{userId}' โ†’ user_level - * - Economy: 'guild:{guildId}:economy:{userId}' โ†’ economy - * - AFK Status: 'guild:{guildId}:afk:{userId}' โ†’ afk_status - * - Tickets: 'guild:{guildId}:ticket:{channelId}' โ†’ ticket - * - Counters: 'counters:{guildId}' โ†’ counters - * - Temp Data: 'temp:*' โ†’ temp_data (auto-TTL) - * - Cache Data: 'cache:*' โ†’ cache_data (auto-TTL) - * - * @param {string} key - The Redis-style key to parse (colon-separated format) - * @returns {Object} Parsed key information with type, IDs, and full key - * @example - * // Guild config key - * parseKey('guild:123456789:config') - * // Returns: { type: 'guild_config', guildId: '123456789', fullKey: 'guild:123456789:config' } - * - * @example - * // User economy key - * parseKey('guild:123456789:economy:987654321') - * // Returns: { type: 'economy', guildId: '123456789', userId: '987654321', fullKey: '...' } - */ parseKey(key) { if (key.startsWith('temp:')) { @@ -915,12 +881,20 @@ class PostgreSQLDatabase { ); return userLevelResult.rows.length > 0 ? userLevelResult.rows[0] : defaultValue; - case 'economy': + case 'economy': { const economyResult = await this.pool.query( `SELECT balance, bank, data FROM ${pgConfig.tables.economy} WHERE guild_id = $1 AND user_id = $2`, [parsedKey.guildId, parsedKey.userId] ); - return economyResult.rows.length > 0 ? economyResult.rows[0] : defaultValue; + if (economyResult.rows.length === 0) return defaultValue; + const row = economyResult.rows[0]; + // Return the full data blob when available (contains wallet, bank, etc.) + // Fall back to constructing a compatible object from the columns + if (row.data && typeof row.data === 'object' && Object.keys(row.data).length > 0) { + return row.data; + } + return { wallet: row.balance ?? 0, bank: row.bank ?? 0 }; + } case 'afk_status': const afkResult = await this.pool.query( @@ -1099,7 +1073,7 @@ class PostgreSQLDatabase { VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) ON CONFLICT (guild_id, user_id) DO UPDATE SET balance = $3, bank = $4, data = $5, updated_at = CURRENT_TIMESTAMP`, - [parsedKey.guildId, parsedKey.userId, value.balance || 0, value.bank || 0, value.data || {}] + [parsedKey.guildId, parsedKey.userId, value.wallet ?? value.balance ?? 0, value.bank ?? 0, value] ); return true; From fbb7d2bb9ef06ebbcd48ada6bd22490e480744ff Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sun, 12 Apr 2026 18:53:16 +1000 Subject: [PATCH 10/10] Add interactive embed builder command Introduce a new /embedbuilder slash command that provides an interactive, live-preview embed builder. Features include editing title/description, color presets + custom hex, author/footer, thumbnail/image management, add/edit/remove/reorder fields (max 25), timestamp toggle, JSON export, and posting to a selected channel. Uses modals, select menus, and buttons with collectors, input validation for URLs/hex, ephemeral follow-ups, a 15-minute idle timeout, and integrates existing InteractionHelper, embed helpers, logger, and error handling. Command defaults to ManageMessages permission. --- src/commands/Tools/embedbuilder.js | 1178 ++++++++++++++++++++++++++++ 1 file changed, 1178 insertions(+) create mode 100644 src/commands/Tools/embedbuilder.js diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js new file mode 100644 index 0000000000..95e056573a --- /dev/null +++ b/src/commands/Tools/embedbuilder.js @@ -0,0 +1,1178 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ChannelSelectMenuBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, + ChannelType, + EmbedBuilder, +} from 'discord.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { successEmbed, errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { getColor } from '../../config/bot.js'; + +// โ”€โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const MAX_FIELDS = 25; +const IDLE_TIMEOUT = 900_000; // 15 minutes + +const COLOR_PRESETS = [ + { label: 'Primary (Blue)', value: '#336699', emoji: '๐Ÿ”ต' }, + { label: 'Success (Green)', value: '#57F287', emoji: '๐ŸŸข' }, + { label: 'Error (Red)', value: '#ED4245', emoji: '๐Ÿ”ด' }, + { label: 'Warning (Yellow)', value: '#FEE75C', emoji: '๐ŸŸก' }, + { label: 'Info (Bright Blue)', value: '#3498DB', emoji: '๐Ÿ’™' }, + { label: 'Blurple (Discord)', value: '#5865F2', emoji: '๐ŸŸฃ' }, + { label: 'Fuchsia', value: '#EB459E', emoji: '๐Ÿ’œ' }, + { label: 'Gold', value: '#F1C40F', emoji: '๐ŸŸ ' }, + { label: 'White', value: '#FFFFFF', emoji: 'โšช' }, + { label: 'Dark', value: '#202225', emoji: 'โšซ' }, + { label: 'Custom Hex...', value: '__custom__', emoji: '๐ŸŽจ' }, +]; + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function isValidUrl(str) { + try { + const url = new URL(str); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function isValidHex(str) { + return /^#[0-9A-Fa-f]{6}$/.test(str); +} + +// โ”€โ”€โ”€ Embed Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Builds the live preview embed from current state. + */ +function buildPreviewEmbed(state) { + const embed = new EmbedBuilder(); + + if (state.title) embed.setTitle(state.title.substring(0, 256)); + if (state.description) embed.setDescription(state.description.substring(0, 4096)); + + try { + embed.setColor(state.color || getColor('primary')); + } catch { + embed.setColor(getColor('primary')); + } + + if (state.author?.name) { + const obj = { name: state.author.name.substring(0, 256) }; + if (state.author.iconUrl && isValidUrl(state.author.iconUrl)) obj.iconURL = state.author.iconUrl; + if (state.author.url && isValidUrl(state.author.url)) obj.url = state.author.url; + embed.setAuthor(obj); + } + + if (state.footer?.text) { + const obj = { text: state.footer.text.substring(0, 2048) }; + if (state.footer.iconUrl && isValidUrl(state.footer.iconUrl)) obj.iconURL = state.footer.iconUrl; + embed.setFooter(obj); + } + + if (state.thumbnail && isValidUrl(state.thumbnail)) embed.setThumbnail(state.thumbnail); + if (state.image && isValidUrl(state.image)) embed.setImage(state.image); + if (state.timestamp) embed.setTimestamp(); + + if (state.fields.length > 0) embed.addFields(state.fields.slice(0, 25)); + + // Ensure the embed renders if completely empty + if ( + !state.title && + !state.description && + state.fields.length === 0 && + !state.author?.name + ) { + embed.setDescription('*(Empty โ€” use the menu below to add content)*'); + } + + return embed; +} + +/** + * Builds the status/control dashboard embed (shown below the preview). + */ +function buildDashboardEmbed(state) { + const trunc = (str, n) => + str.length > n ? str.substring(0, n) + 'โ€ฆ' : str; + + const lines = [ + `**Title** โ€บ ${state.title ? `\`${trunc(state.title, 40)}\`` : '`Not set`'}`, + `**Description** โ€บ ${state.description ? `${state.description.length} character(s)` : '`Not set`'}`, + `**Color** โ€บ ${state.color ? `\`${state.color}\`` : '`Default`'}`, + `**Author** โ€บ ${state.author?.name ? `\`${trunc(state.author.name, 30)}\`` : '`Not set`'}`, + `**Footer** โ€บ ${state.footer?.text ? `\`${trunc(state.footer.text, 30)}\`` : '`Not set`'}`, + `**Thumbnail** โ€บ ${state.thumbnail ? 'โœ… Set' : '`Not set`'}`, + `**Image** โ€บ ${state.image ? 'โœ… Set' : '`Not set`'}`, + `**Timestamp** โ€บ ${state.timestamp ? 'โœ… Enabled' : '`Disabled`'}`, + `**Fields** โ€บ ${state.fields.length} / ${MAX_FIELDS}`, + ]; + + return new EmbedBuilder() + .setTitle('๐Ÿ› ๏ธ Embed Builder โ€” Control Panel') + .setDescription(lines.join('\n')) + .setColor(getColor('info')) + .setFooter({ text: 'The preview above updates live ยท Closes after 5 min of inactivity' }); +} + +/** + * Builds the main action select menu. + */ +function buildMainMenu(state) { + const select = new StringSelectMenuBuilder() + .setCustomId('eb_menu') + .setPlaceholder('Choose an action...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Content') + .setDescription('Set the title and description') + .setValue('edit_content') + .setEmoji('โœ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Color') + .setDescription('Pick a preset or enter a custom hex code') + .setValue('set_color') + .setEmoji('๐ŸŽจ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Author') + .setDescription('Configure the author block at the top of the embed') + .setValue('set_author') + .setEmoji('๐Ÿ‘ค'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Footer') + .setDescription('Configure the footer text and icon') + .setValue('set_footer') + .setEmoji('๐Ÿ“„'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Images') + .setDescription('Set the thumbnail or large banner image') + .setValue('set_images') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) + .setDescription('Add a new inline or block field') + .setValue('add_field') + .setEmoji('โž•'), + ); + + if (state.fields.length > 0) { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Field') + .setDescription('Modify the name, value, or inline setting of a field') + .setValue('edit_field') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Remove Field') + .setDescription('Delete a field from the embed') + .setValue('remove_field') + .setEmoji('โž–'), + ); + + if (state.fields.length >= 2) { + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Reorder Fields') + .setDescription('Move a field up or down in the list') + .setValue('reorder_fields') + .setEmoji('โ†•๏ธ'), + ); + } + } + + select.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(state.timestamp ? 'Disable Timestamp' : 'Enable Timestamp') + .setDescription('Toggle the automatic timestamp in the footer') + .setValue('toggle_timestamp') + .setEmoji('๐Ÿ•'), + new StringSelectMenuOptionBuilder() + .setLabel('Post Embed') + .setDescription('Send the finished embed to a channel') + .setValue('post_embed') + .setEmoji('๐Ÿ“ค'), + new StringSelectMenuOptionBuilder() + .setLabel('JSON / Raw Data') + .setDescription('View the raw JSON for this embed') + .setValue('json_export') + .setEmoji('๐Ÿ“‹'), + new StringSelectMenuOptionBuilder() + .setLabel('Reset Everything') + .setDescription('Clear all fields and start over') + .setValue('reset_all') + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + return select; +} + +/** + * Updates the dashboard message with the latest state. + */ +async function refreshDashboard(interaction, state) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [buildPreviewEmbed(state), buildDashboardEmbed(state)], + components: [new ActionRowBuilder().addComponents(buildMainMenu(state))], + }); +} + +// โ”€โ”€โ”€ Option Handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleEditContent(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_content') + .setTitle('Edit Content') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('eb_title') + .setLabel('Title (max 256 characters)') + .setStyle(TextInputStyle.Short) + .setValue(state.title || '') + .setMaxLength(256) + .setRequired(false) + .setPlaceholder('My Embed Title'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('eb_description') + .setLabel('Description (max 4000 characters)') + .setStyle(TextInputStyle.Paragraph) + .setValue(state.description ? state.description.substring(0, 4000) : '') + .setMaxLength(4000) + .setRequired(false) + .setPlaceholder('Write your embed description here...'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_content' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + // Defer immediately to avoid interaction timeout + await submitted.deferUpdate().catch(() => {}); + + state.title = submitted.fields.getTextInputValue('eb_title').trim() || null; + state.description = submitted.fields.getTextInputValue('eb_description').trim() || null; + + await refreshDashboard(rootInteraction, state); +} + +async function handleSetColor(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate().catch(() => {}); + + const colorSelect = new StringSelectMenuBuilder() + .setCustomId('eb_color_pick') + .setPlaceholder('Choose a color...') + .addOptions( + COLOR_PRESETS.map(c => + new StringSelectMenuOptionBuilder() + .setLabel(c.label) + .setValue(c.value) + .setEmoji(c.emoji) + .setDescription(c.value !== '__custom__' ? c.value : 'Enter your own #RRGGBB value'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŽจ Set Color') + .setDescription( + 'Select a preset color or choose **Custom Hex** to enter your own `#RRGGBB` value.', + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(colorSelect)], + flags: MessageFlags.Ephemeral, + }); + + const colorCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_color_pick', + time: 60_000, + max: 1, + }); + + colorCollector.on('collect', async colorInter => { + const picked = colorInter.values[0]; + + if (picked === '__custom__') { + const hexModal = new ModalBuilder() + .setCustomId('eb_custom_hex') + .setTitle('Custom Color') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('hex_value') + .setLabel('Hex Color Code') + .setStyle(TextInputStyle.Short) + .setPlaceholder('#5865F2') + .setMaxLength(7) + .setMinLength(7) + .setRequired(true), + ), + ); + + await colorInter.showModal(hexModal); + + const hexSubmit = await colorInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_custom_hex' && i.user.id === colorInter.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!hexSubmit) return; + + const hex = hexSubmit.fields.getTextInputValue('hex_value').trim(); + if (!isValidHex(hex)) { + await hexSubmit.reply({ + embeds: [ + errorEmbed( + 'Invalid Hex', + `\`${hex}\` is not a valid hex color. Use the format \`#RRGGBB\` (e.g. \`#5865F2\`).`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.color = hex; + await hexSubmit.deferUpdate().catch(() => {}); + } else { + state.color = picked; + await colorInter.deferUpdate().catch(() => {}); + } + + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleSetAuthor(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_author') + .setTitle('Set Author') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_name') + .setLabel('Author Name (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.name || '') + .setMaxLength(256) + .setRequired(false) + .setPlaceholder('Your Name'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_icon') + .setLabel('Author Icon URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.iconUrl || '') + .setRequired(false) + .setPlaceholder('https://example.com/icon.png'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('author_url') + .setLabel('Author Link URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.author?.url || '') + .setRequired(false) + .setPlaceholder('https://example.com'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_author' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('author_name').trim(); + const iconUrl = submitted.fields.getTextInputValue('author_icon').trim(); + const url = submitted.fields.getTextInputValue('author_url').trim(); + + if (iconUrl && !isValidUrl(iconUrl)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Author icon URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (url && !isValidUrl(url)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Author link URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.author = name ? { name, iconUrl: iconUrl || null, url: url || null } : null; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleSetFooter(selectInteraction, rootInteraction, state) { + const modal = new ModalBuilder() + .setCustomId('eb_footer') + .setTitle('Set Footer') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('footer_text') + .setLabel('Footer Text (leave blank to remove)') + .setStyle(TextInputStyle.Short) + .setValue(state.footer?.text || '') + .setMaxLength(2048) + .setRequired(false) + .setPlaceholder('Built with TitanBot'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('footer_icon') + .setLabel('Footer Icon URL (optional)') + .setStyle(TextInputStyle.Short) + .setValue(state.footer?.iconUrl || '') + .setRequired(false) + .setPlaceholder('https://example.com/icon.png'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_footer' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const text = submitted.fields.getTextInputValue('footer_text').trim(); + const iconUrl = submitted.fields.getTextInputValue('footer_icon').trim(); + + if (iconUrl && !isValidUrl(iconUrl)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Footer icon URL must be a valid `https://` URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + state.footer = text ? { text, iconUrl: iconUrl || null } : null; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleSetImages(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate().catch(() => {}); + + const imageSelect = new StringSelectMenuBuilder() + .setCustomId('eb_image_pick') + .setPlaceholder('What would you like to change?') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Set Thumbnail') + .setDescription('Small image displayed in the top-right corner') + .setValue('set_thumbnail') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Large Image') + .setDescription('Full-width banner image at the bottom') + .setValue('set_image') + .setEmoji('๐Ÿ“ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Clear Thumbnail') + .setDescription('Remove the current thumbnail') + .setValue('clear_thumbnail') + .setEmoji('๐Ÿ—‘๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Clear Large Image') + .setDescription('Remove the current large image') + .setValue('clear_image') + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ–ผ๏ธ Set Images') + .setDescription('Choose which image to set or remove.') + .addFields( + { name: 'Thumbnail', value: state.thumbnail ? `[View](${state.thumbnail})` : '`Not set`', inline: true }, + { name: 'Large Image', value: state.image ? `[View](${state.image})` : '`Not set`', inline: true }, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(imageSelect)], + flags: MessageFlags.Ephemeral, + }); + + const imgMenuCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_image_pick', + time: 60_000, + max: 1, + }); + + imgMenuCollector.on('collect', async imgInter => { + const pick = imgInter.values[0]; + + if (pick === 'clear_thumbnail') { + state.thumbnail = null; + await imgInter.deferUpdate(); + await refreshDashboard(rootInteraction, state); + return; + } + if (pick === 'clear_image') { + state.image = null; + await imgInter.deferUpdate(); + await refreshDashboard(rootInteraction, state); + return; + } + + const isThumb = pick === 'set_thumbnail'; + + const urlModal = new ModalBuilder() + .setCustomId('eb_image_url') + .setTitle(isThumb ? 'Set Thumbnail' : 'Set Large Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_url') + .setLabel('Image URL') + .setStyle(TextInputStyle.Short) + .setValue(isThumb ? (state.thumbnail || '') : (state.image || '')) + .setRequired(true) + .setPlaceholder('https://example.com/image.png'), + ), + ); + + await imgInter.showModal(urlModal); + + const submitted = await imgInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_image_url' && i.user.id === imgInter.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!submitted) return; + + const url = submitted.fields.getTextInputValue('image_url').trim(); + if (!isValidUrl(url)) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid URL', 'Image URL must be a valid `https://` link to a publicly accessible image.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (isThumb) state.thumbnail = url; + else state.image = url; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleAddField(selectInteraction, rootInteraction, state) { + if (state.fields.length >= MAX_FIELDS) { + await selectInteraction.deferUpdate(); + await selectInteraction.followUp({ + embeds: [errorEmbed('Fields Full', `Embeds can have a maximum of ${MAX_FIELDS} fields.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId('eb_add_field') + .setTitle('Add Field') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_name') + .setLabel('Field Name (max 256 characters)') + .setStyle(TextInputStyle.Short) + .setMaxLength(256) + .setRequired(true) + .setPlaceholder('Field Title'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_value') + .setLabel('Field Value (max 1024 characters)') + .setStyle(TextInputStyle.Paragraph) + .setMaxLength(1024) + .setRequired(true) + .setPlaceholder('Field content goes here...'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_inline') + .setLabel('Inline? Type "yes" to place side-by-side') + .setStyle(TextInputStyle.Short) + .setValue('no') + .setMaxLength(3) + .setRequired(false) + .setPlaceholder('yes or no'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'eb_add_field' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('field_name').trim(); + const value = submitted.fields.getTextInputValue('field_value').trim(); + const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); + const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + + state.fields.push({ name, value, inline }); + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); +} + +async function handleEditField(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_edit_field_pick') + .setPlaceholder('Select a field to edit...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 80)}${f.value.length > 80 ? 'โ€ฆ' : ''} ยท ${f.inline ? 'Inline' : 'Block'}`, + ) + .setValue(String(i)) + .setEmoji('๐Ÿ“'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ Edit Field') + .setDescription('Select the field you want to modify.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_edit_field_pick', + time: 60_000, + max: 1, + }); + + pickCollector.on('collect', async pickInter => { + const idx = parseInt(pickInter.values[0], 10); + const field = state.fields[idx]; + if (!field) { await pickInter.deferUpdate(); return; } + + const modal = new ModalBuilder() + .setCustomId('eb_edit_field_modal') + .setTitle(`Edit Field ${idx + 1}`) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_name') + .setLabel('Field Name') + .setStyle(TextInputStyle.Short) + .setValue(field.name) + .setMaxLength(256) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_value') + .setLabel('Field Value') + .setStyle(TextInputStyle.Paragraph) + .setValue(field.value.substring(0, 4000)) + .setMaxLength(1024) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('field_inline') + .setLabel('Inline? (yes / no)') + .setStyle(TextInputStyle.Short) + .setValue(field.inline ? 'yes' : 'no') + .setMaxLength(3) + .setRequired(false), + ), + ); + + await pickInter.showModal(modal); + + const submitted = await pickInter + .awaitModalSubmit({ + filter: i => + i.customId === 'eb_edit_field_modal' && i.user.id === pickInter.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const name = submitted.fields.getTextInputValue('field_name').trim(); + const value = submitted.fields.getTextInputValue('field_value').trim(); + const inlineRaw = submitted.fields.getTextInputValue('field_inline').trim().toLowerCase(); + const inline = inlineRaw === 'yes' || inlineRaw === 'y' || inlineRaw === 'true'; + + state.fields[idx] = { name, value, inline }; + + await submitted.deferUpdate().catch(() => {}); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleRemoveField(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_remove_field_pick') + .setPlaceholder('Select a field to remove...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 90)}${f.value.length > 90 ? 'โ€ฆ' : ''}`, + ) + .setValue(String(i)) + .setEmoji('โž–'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Field') + .setDescription('Select the field you want to delete.') + .setColor(getColor('warning')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_remove_field_pick', + time: 60_000, + max: 1, + }); + + removeCollector.on('collect', async removeInter => { + await removeInter.deferUpdate(); + const idx = parseInt(removeInter.values[0], 10); + state.fields.splice(idx, 1); + await refreshDashboard(rootInteraction, state); + }); +} + +async function handleReorderFields(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const pickSelect = new StringSelectMenuBuilder() + .setCustomId('eb_reorder_pick') + .setPlaceholder('Select a field to move...') + .addOptions( + state.fields.slice(0, 25).map((f, i) => + new StringSelectMenuOptionBuilder() + .setLabel(`${i + 1}. ${f.name.substring(0, 50)}`) + .setDescription( + `${f.value.substring(0, 90)}${f.value.length > 90 ? 'โ€ฆ' : ''}`, + ) + .setValue(String(i)) + .setEmoji('โ†•๏ธ'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โ†•๏ธ Reorder Fields') + .setDescription('Select a field, then use the arrows to move it up or down.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(pickSelect)], + flags: MessageFlags.Ephemeral, + }); + + const pickCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_reorder_pick', + time: 60_000, + max: 1, + }); + + pickCollector.on('collect', async pickInter => { + await pickInter.deferUpdate(); + const sourceIdx = parseInt(pickInter.values[0], 10); + + const upBtn = new ButtonBuilder() + .setCustomId('eb_reorder_up') + .setLabel('Move Up') + .setStyle(ButtonStyle.Primary) + .setEmoji('โฌ†๏ธ') + .setDisabled(sourceIdx === 0); + + const downBtn = new ButtonBuilder() + .setCustomId('eb_reorder_down') + .setLabel('Move Down') + .setStyle(ButtonStyle.Primary) + .setEmoji('โฌ‡๏ธ') + .setDisabled(sourceIdx === state.fields.length - 1); + + const cancelBtn = new ButtonBuilder() + .setCustomId('eb_reorder_cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Secondary); + + await pickInter.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โ†•๏ธ Move Field') + .setDescription( + `Moving **${state.fields[sourceIdx].name}** โ€” currently at position **${sourceIdx + 1}** of **${state.fields.length}**.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(upBtn, downBtn, cancelBtn)], + flags: MessageFlags.Ephemeral, + }); + + const dirCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === selectInteraction.user.id && + ['eb_reorder_up', 'eb_reorder_down', 'eb_reorder_cancel'].includes(i.customId), + time: 30_000, + max: 1, + }); + + dirCollector.on('collect', async dirInter => { + await dirInter.deferUpdate(); + if (dirInter.customId === 'eb_reorder_cancel') return; + + const targetIdx = + dirInter.customId === 'eb_reorder_up' ? sourceIdx - 1 : sourceIdx + 1; + + if (targetIdx < 0 || targetIdx >= state.fields.length) return; + + const temp = state.fields[sourceIdx]; + state.fields[sourceIdx] = state.fields[targetIdx]; + state.fields[targetIdx] = temp; + + await refreshDashboard(rootInteraction, state); + }); + }); +} + +async function handlePostEmbed(selectInteraction, rootInteraction, state, guild) { + if ( + !state.title && + !state.description && + state.fields.length === 0 && + !state.author?.name + ) { + await selectInteraction.deferUpdate(); + await selectInteraction.followUp({ + embeds: [ + errorEmbed( + 'Empty Embed', + 'Add at least a title, description, or field before posting.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + await selectInteraction.deferUpdate(); + + const chanSelect = new ChannelSelectMenuBuilder() + .setCustomId('eb_post_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ค Post Embed') + .setDescription('Select the channel where this embed will be sent.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(chanSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'eb_post_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInter => { + await chanInter.deferUpdate(); + const channel = chanInter.channels.first(); + + if (!channel) { + await chanInter.followUp({ + embeds: [errorEmbed('No Channel', 'Could not resolve the selected channel.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const perms = channel.permissionsFor(guild.members.me); + if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { + await chanInter.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **Send Messages** and **Embed Links** permissions in ${channel} to post there.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const finalEmbed = buildPreviewEmbed(state); + + // Remove the placeholder description before sending + if (finalEmbed.data.description === '*(Empty โ€” use the menu below to add content)*') { + finalEmbed.setDescription(null); + } + + await channel.send({ embeds: [finalEmbed] }); + + await chanInter.followUp({ + embeds: [successEmbed('โœ… Embed Sent', `Your embed has been posted to ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + }); +} + +async function handleJsonExport(selectInteraction, rootInteraction, state) { + await selectInteraction.deferUpdate(); + + const previewEmbed = buildPreviewEmbed(state); + const json = JSON.stringify(previewEmbed.toJSON(), null, 2); + + if (json.length <= 3980) { + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Embed JSON') + .setDescription(`\`\`\`json\n${json}\n\`\`\``) + .setColor(getColor('info')), + ], + flags: MessageFlags.Ephemeral, + }); + } else { + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Embed JSON') + .setDescription('The JSON is too long to display inline โ€” see the attached file.') + .setColor(getColor('info')), + ], + files: [ + { + attachment: Buffer.from(json, 'utf-8'), + name: 'embed.json', + }, + ], + flags: MessageFlags.Ephemeral, + }); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + data: new SlashCommandBuilder() + .setName('embedbuilder') + .setDescription('Build and post a fully custom embed with live preview') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), + + async execute(interaction) { + try { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferSuccess) return; + + const guild = interaction.guild; + + // Builder state โ€” holds every embed property being constructed + const state = { + title: null, + description: null, + color: getColor('primary'), + author: null, + footer: null, + thumbnail: null, + image: null, + timestamp: false, + fields: [], + }; + + await refreshDashboard(interaction, state); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === 'eb_menu', + time: IDLE_TIMEOUT, + }); + + collector.on('collect', async ci => { + try { + switch (ci.values[0]) { + case 'edit_content': + await handleEditContent(ci, interaction, state); + break; + case 'set_color': + await handleSetColor(ci, interaction, state); + break; + case 'set_author': + await handleSetAuthor(ci, interaction, state); + break; + case 'set_footer': + await handleSetFooter(ci, interaction, state); + break; + case 'set_images': + await handleSetImages(ci, interaction, state); + break; + case 'add_field': + await handleAddField(ci, interaction, state); + break; + case 'edit_field': + await handleEditField(ci, interaction, state); + break; + case 'remove_field': + await handleRemoveField(ci, interaction, state); + break; + case 'reorder_fields': + await handleReorderFields(ci, interaction, state); + break; + case 'toggle_timestamp': + state.timestamp = !state.timestamp; + await ci.deferUpdate(); + await refreshDashboard(interaction, state); + break; + case 'post_embed': + await handlePostEmbed(ci, interaction, state, guild); + break; + case 'json_export': + await handleJsonExport(ci, interaction, state); + break; + case 'reset_all': + state.title = null; + state.description = null; + state.color = getColor('primary'); + state.author = null; + state.footer = null; + state.thumbnail = null; + state.image = null; + state.timestamp = false; + state.fields = []; + await ci.deferUpdate(); + await refreshDashboard(interaction, state); + break; + default: + await ci.deferUpdate(); + } + } catch (error) { + logger.error('Error in embedbuilder collector:', error); + const msg = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred.' + : 'An unexpected error occurred.'; + if (!ci.replied && !ci.deferred) await ci.deferUpdate().catch(() => {}); + await ci + .followUp({ + embeds: [errorEmbed('Error', msg)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (_, reason) => { + if (reason === 'time') { + await InteractionHelper.safeEditReply(interaction, { components: [] }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in embedbuilder:', error); + throw new TitanBotError( + `embedbuilder failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the embed builder.', + ); + } + }, +};