From 71ab692094197b15fbf6361f3475e9efddb68673 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Sat, 21 Feb 2026 14:34:46 +1100 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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.', + ); + } + }, +}; From 5f411518a7df53d384d0028b5157b907e3799130 Mon Sep 17 00:00:00 2001 From: codebymitch Date: Wed, 15 Apr 2026 20:59:46 +1000 Subject: [PATCH 11/11] Refactor user setup/configuration with new dashboard commands Large refactor and feature additions: reorganized command modules (add dashboards for Logging, Ticket, Verification, Leveling, Community, Core overview), added ServerStats commands/services (renamed from Counter), new Reaction Roles and Utility report modules, and new verification/auto-verify commands. Removed several legacy Config and Welcome/goodbye modules and obsolete interaction handlers. --- README.md | 61 +- package-lock.json | 351 ++--- package.json | 4 - roadmap.txt | 171 ++- src/app.js | 7 +- src/commands/Birthday/birthday.js | 17 +- .../Birthday/modules/birthday_setchannel.js | 44 + .../Birthday/modules/next_birthdays.js | 2 +- src/commands/Community/app-admin.js | 882 +++++------- src/commands/Community/apply.js | 37 +- .../Community/modules/app_dashboard.js | 1278 +++++++++++++++++ src/commands/Config/config.js | 225 --- .../Config/modules/config_birthday_toggle.js | 58 - .../Config/modules/config_logging_filter.js | 145 -- .../modules/config_logging_setchannel.js | 189 --- .../Config/modules/config_logging_status.js | 184 --- .../Config/modules/config_premium_setrole.js | 53 - .../modules/config_reports_setchannel.js | 41 - src/commands/Core/overview.js | 115 ++ src/commands/Economy/modules/shop_browse.js | 90 ++ .../Economy/modules/shop_config_setrole.js | 36 + src/commands/Economy/shop.js | 153 +- src/commands/JoinToCreate/jointocreate.js | 10 +- .../JoinToCreate/modules/config_setup.js | 8 +- src/commands/Leveling/leaderboard.js | 4 +- src/commands/Leveling/level.js | 179 +++ src/commands/Leveling/levelconfig.js | 216 --- .../Leveling/modules/level_dashboard.js | 562 ++++++++ src/commands/Logging/logging.js | 118 ++ .../Logging/modules/logging_dashboard.js | 135 ++ .../Logging/modules/logging_filter.js | 99 ++ .../Logging/modules/logging_setchannel.js | 88 ++ src/commands/Reaction_roles/rdelete.js | 134 -- src/commands/Reaction_roles/reactroles.js | 1096 ++++++++++++++ src/commands/Reaction_roles/rlist.js | 91 -- src/commands/Reaction_roles/rsetup.js | 267 ---- .../modules/serverstats_create.js} | 2 +- .../modules/serverstats_delete.js} | 2 +- .../modules/serverstats_list.js} | 2 +- .../modules/serverstats_update.js} | 2 +- .../counter.js => ServerStats/serverstats.js} | 34 +- .../Ticket/modules/ticket_dashboard.js | 1036 +++++++++++++ .../Ticket/modules/ticket_limits_check.js | 64 - .../Ticket/modules/ticket_limits_set.js | 61 - .../Ticket/modules/ticket_limits_toggle_dm.js | 64 - src/commands/Ticket/ticket.js | 81 +- src/commands/Utility/modules/report.js | 69 + .../Utility/modules/report_setchannel.js | 36 + src/commands/Utility/report.js | 174 +-- .../Verification/modules/autoVerify.js | 118 +- .../modules/autoVerifyDashboard.js | 544 +++++++ .../modules/verification_dashboard.js | 699 +++++++++ src/commands/Verification/verification.js | 158 +- src/commands/Verification/verify.js | 50 + src/commands/Welcome/goodbye.js | 70 +- src/commands/Welcome/greet.js | 51 + src/commands/Welcome/modules/goodbyeConfig.js | 182 --- .../Welcome/modules/greet_dashboard.js | 744 ++++++++++ src/commands/Welcome/modules/welcomeConfig.js | 172 --- src/commands/Welcome/welcome.js | 70 +- src/events/channelDelete.js | 21 +- src/events/guildMemberAdd.js | 2 +- src/events/guildMemberRemove.js | 26 +- src/events/interactionCreate.js | 149 +- src/handlers/counterButtons.js | 2 +- .../goodbyeConfigButton.js | 59 - .../interactionHandlers/goodbyeConfigModal.js | 119 -- .../welcomeConfigButton.js | 59 - .../interactionHandlers/welcomeConfigModal.js | 104 -- src/handlers/loggingButtons.js | 62 +- src/handlers/ticketButtons.js | 525 ++++--- src/interactions/buttons/goodbyeConfig.js | 8 - src/interactions/buttons/logging.js | 8 + src/interactions/buttons/ticket.js | 4 +- src/interactions/buttons/welcomeConfig.js | 8 - src/interactions/modals/goodbyeConfigModal.js | 8 - src/interactions/modals/welcomeConfigModal.js | 8 - .../selectMenus/ticketFeedback.js | 144 ++ src/services/joinToCreateService.js | 2 +- src/services/leveling.js | 25 +- src/services/reactionRoleService.js | 14 +- ...ounterService.js => serverstatsService.js} | 0 src/services/ticket.js | 293 +++- src/utils/abuseProtection.js | 2 +- src/utils/database.js | 55 +- src/utils/interactionValidator.js | 158 ++ src/utils/loggingUi.js | 55 + src/utils/ticketLogging.js | 34 +- 88 files changed, 9258 insertions(+), 4331 deletions(-) create mode 100644 src/commands/Birthday/modules/birthday_setchannel.js create mode 100644 src/commands/Community/modules/app_dashboard.js delete mode 100644 src/commands/Config/config.js delete mode 100644 src/commands/Config/modules/config_birthday_toggle.js delete mode 100644 src/commands/Config/modules/config_logging_filter.js delete mode 100644 src/commands/Config/modules/config_logging_setchannel.js delete mode 100644 src/commands/Config/modules/config_logging_status.js delete mode 100644 src/commands/Config/modules/config_premium_setrole.js delete mode 100644 src/commands/Config/modules/config_reports_setchannel.js create mode 100644 src/commands/Core/overview.js create mode 100644 src/commands/Economy/modules/shop_browse.js create mode 100644 src/commands/Economy/modules/shop_config_setrole.js create mode 100644 src/commands/Leveling/level.js delete mode 100644 src/commands/Leveling/levelconfig.js create mode 100644 src/commands/Leveling/modules/level_dashboard.js create mode 100644 src/commands/Logging/logging.js create mode 100644 src/commands/Logging/modules/logging_dashboard.js create mode 100644 src/commands/Logging/modules/logging_filter.js create mode 100644 src/commands/Logging/modules/logging_setchannel.js delete mode 100644 src/commands/Reaction_roles/rdelete.js create mode 100644 src/commands/Reaction_roles/reactroles.js delete mode 100644 src/commands/Reaction_roles/rlist.js delete mode 100644 src/commands/Reaction_roles/rsetup.js rename src/commands/{Counter/modules/counter_create.js => ServerStats/modules/serverstats_create.js} (99%) rename src/commands/{Counter/modules/counter_delete.js => ServerStats/modules/serverstats_delete.js} (98%) rename src/commands/{Counter/modules/counter_list.js => ServerStats/modules/serverstats_list.js} (99%) rename src/commands/{Counter/modules/counter_update.js => ServerStats/modules/serverstats_update.js} (98%) rename src/commands/{Counter/counter.js => ServerStats/serverstats.js} (80%) create mode 100644 src/commands/Ticket/modules/ticket_dashboard.js delete mode 100644 src/commands/Ticket/modules/ticket_limits_check.js delete mode 100644 src/commands/Ticket/modules/ticket_limits_set.js delete mode 100644 src/commands/Ticket/modules/ticket_limits_toggle_dm.js create mode 100644 src/commands/Utility/modules/report.js create mode 100644 src/commands/Utility/modules/report_setchannel.js create mode 100644 src/commands/Verification/modules/autoVerifyDashboard.js create mode 100644 src/commands/Verification/modules/verification_dashboard.js create mode 100644 src/commands/Verification/verify.js create mode 100644 src/commands/Welcome/greet.js delete mode 100644 src/commands/Welcome/modules/goodbyeConfig.js create mode 100644 src/commands/Welcome/modules/greet_dashboard.js delete mode 100644 src/commands/Welcome/modules/welcomeConfig.js delete mode 100644 src/handlers/interactionHandlers/goodbyeConfigButton.js delete mode 100644 src/handlers/interactionHandlers/goodbyeConfigModal.js delete mode 100644 src/handlers/interactionHandlers/welcomeConfigButton.js delete mode 100644 src/handlers/interactionHandlers/welcomeConfigModal.js delete mode 100644 src/interactions/buttons/goodbyeConfig.js delete mode 100644 src/interactions/buttons/welcomeConfig.js delete mode 100644 src/interactions/modals/goodbyeConfigModal.js delete mode 100644 src/interactions/modals/welcomeConfigModal.js create mode 100644 src/interactions/selectMenus/ticketFeedback.js rename src/services/{counterService.js => serverstatsService.js} (100%) create mode 100644 src/utils/interactionValidator.js diff --git a/README.md b/README.md index 85d28b5f5c..d9fe773990 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ ## ๐Ÿ“š Table of Contents -- [๐ŸŒŸ Features Overview](#-features-overview) -- [๐Ÿš€ Quick Setup](#-video-tutorial) -- [๐Ÿ’ก Manual Installation Steps](#installation-steps) -- [๐Ÿ—„๏ธ Database System](#๏ธ-database-system) -- [๐ŸŒ Support Server](#-support-server) -- [๐Ÿ—๏ธ Bot Architecture](#-bot-architecture) -- [๐Ÿค Contributing](#-contributing) - +- [๐ŸŒŸ Features Overview](#features-overview) +- [๐Ÿš€ Quick Setup](#quick-setup) +- [โš™๏ธ Manual Installation Steps](#manual-installation-steps) +- [๐Ÿ—„๏ธ Database System](#database-system) +- [๐ŸŒ Support Server](https://discord.gg/QnWNz2dKCE) +- [๐Ÿ—๏ธ Bot Architecture](#bot-architecture) +- [๐Ÿค Contributing](#contributing) + + ## ๐ŸŒŸ Features Overview TitanBot offers a complete suite of tools for Discord server management and community engagement: @@ -44,7 +45,7 @@ TitanBot offers a complete suite of tools for Discord server management and comm - **Ticket Limits** - Prevent spam - **Transcript System** - Save ticket history -### ๐Ÿ”ข Server Counters +### ๏ฟฝ Server Stats - **Member Counter** - Live member count channels - **Voice Counters** - Track voice stats - **Dynamic Updates** - Real-time channel updates @@ -86,19 +87,21 @@ TitanBot offers a complete suite of tools for Discord server management and comm + ## ๐Ÿš€ Quick Setup (recommend) ### ๐Ÿ“น Video Tutorial For a detailed step-by-step setup guide, watch our comprehensive video tutorial: -[**TitanBot Setup Tutorial**](https://www.youtube.com/watch?v=YOUR_TUTORIAL_ID) +[**TitanBot Setup Tutorial**](https://www.youtube.com/@TouchDisc) + + +## โš™๏ธ Manual Installation Steps ### Prerequisites - Node.js 18.0.0 or higher - PostgreSQL server (recommended) or memory storage fallback - Discord bot application with proper intents -### Installation Steps - 1. **Clone the Repository** ```bash git clone https://github.com/yourusername/TitanBot.git @@ -114,38 +117,24 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: ```bash cp .env.example .env ``` - Edit `.env` with your configuration: + Edit `.env` with your configuration (only the following variables require configuration, leave remaining variables as default): ```env # Discord Bot Configuration DISCORD_TOKEN=your_discord_bot_token_here CLIENT_ID=your_discord_client_id_here GUILD_ID=your_discord_guild_id_here - # PostgreSQL Configuration (Recommended) - POSTGRES_URL=postgresql://titanbot:yourpassword@localhost:5432/titanbot + # PostgreSQL Configuration (Primary Database) + 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 - # 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=production - LOG_LEVEL=warn ``` 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) @@ -155,7 +144,7 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: - `LOG_LEVEL`: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly` - Accepted aliases for `LOG_LEVEL` in this bot: `warns`, `warning`, `warnings` โ†’ `warn` - Recommended production `.env` (easy mode): + Recommended production `.env` (easy mode + default mode): ```env NODE_ENV=production LOG_LEVEL=warn @@ -190,6 +179,7 @@ For a detailed step-by-step setup guide, watch our comprehensive video tutorial: - `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: @@ -210,6 +200,7 @@ TitanBot uses **PostgreSQL** as its primary database with intelligent fallback t - **Backward Compatibility**: Maintains existing API structure - **Zero Downtime**: Seamless switching between database and memory + ## ๐Ÿ—๏ธ Bot Architecture ### Technology Stack @@ -229,7 +220,6 @@ TitanBot requires the following Discord intents: - Guild Message Reactions - Guild Voice States - Direct Messages -- And more... ### Required Permissions - **Send Messages** @@ -243,6 +233,7 @@ TitanBot requires the following Discord intents: - **Kick Members** - **Manage Messages** + ## ๐Ÿค Contributing We welcome contributions to TitanBot! Here's how you can help: @@ -267,8 +258,4 @@ TitanBot is released under the MIT License. See [LICENSE](LICENSE) for details. Thank you for choosing TitanBot for your Discord server! We're constantly working to improve and add new features based on community feedback. -**Made with โค๏ธ** - ---- - -*Last updated: January 2026* +**Made with โค๏ธ** \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2a62909de1..9f14dea824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "web-streams-polyfill": "^3.3.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" @@ -43,14 +42,14 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -85,19 +84,19 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -117,6 +116,15 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", @@ -204,11 +212,11 @@ } }, "node_modules/@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/triple-beam": { @@ -256,13 +264,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/body-parser": { @@ -377,9 +385,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "engines": { "node": ">=18" }, @@ -445,28 +453,28 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.39", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", - "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==" + "version": "0.38.46", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.46.tgz", + "integrity": "sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==" }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -476,9 +484,9 @@ } }, "node_modules/dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "engines": { "node": ">=12" }, @@ -657,9 +665,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -884,9 +892,9 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "node_modules/lodash.snakecase": { "version": "4.1.1", @@ -1048,22 +1056,22 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" } }, "node_modules/pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "dependencies": { - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1089,9 +1097,9 @@ "optional": true }, "node_modules/pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" }, "node_modules/pg-int8": { "version": "1.0.1", @@ -1102,17 +1110,17 @@ } }, "node_modules/pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" }, "node_modules/pg-types": { "version": "2.2.0", @@ -1185,14 +1193,17 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "engines": { + "node": ">=10" + } }, "node_modules/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "dependencies": { "side-channel": "^1.1.0" }, @@ -1352,12 +1363,12 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1478,17 +1489,17 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" }, "node_modules/unpipe": { "version": "1.0.0", @@ -1511,14 +1522,6 @@ "node": ">= 0.8" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "engines": { - "node": ">= 8" - } - }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -1576,9 +1579,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "engines": { "node": ">=10.0.0" }, @@ -1629,14 +1632,14 @@ } }, "@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "requires": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -1656,25 +1659,30 @@ } }, "@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "requires": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "^6.23.0" + "undici": "6.24.1" }, "dependencies": { "@discordjs/collection": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==" + }, + "@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==" } } }, @@ -1738,11 +1746,11 @@ } }, "@types/node": { - "version": "25.2.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", - "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "requires": { - "undici-types": "~7.16.0" + "undici-types": "~7.19.0" } }, "@types/triple-beam": { @@ -1783,13 +1791,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "requires": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "body-parser": { @@ -1870,9 +1878,9 @@ } }, "content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==" }, "content-type": { "version": "1.0.5", @@ -1908,34 +1916,34 @@ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" }, "discord-api-types": { - "version": "0.38.39", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.39.tgz", - "integrity": "sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==" + "version": "0.38.46", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.46.tgz", + "integrity": "sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==" }, "discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "requires": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "^6.23.0" + "undici": "6.24.1" } }, "dotenv": { - "version": "17.3.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", - "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==" + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==" }, "dunder-proto": { "version": "1.0.1", @@ -2073,9 +2081,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==" + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" }, "form-data": { "version": "4.0.5", @@ -2217,9 +2225,9 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, "lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==" }, "lodash.snakecase": { "version": "4.1.1", @@ -2332,19 +2340,19 @@ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, "path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==" }, "pg": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", - "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "requires": { "pg-cloudflare": "^1.3.0", - "pg-connection-string": "^2.11.0", - "pg-pool": "^3.11.0", - "pg-protocol": "^1.11.0", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" } @@ -2356,9 +2364,9 @@ "optional": true }, "pg-connection-string": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", - "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==" + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==" }, "pg-int8": { "version": "1.0.1", @@ -2366,15 +2374,15 @@ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==" }, "pg-pool": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", - "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "requires": {} }, "pg-protocol": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", - "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==" + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==" }, "pg-types": { "version": "2.2.0", @@ -2429,14 +2437,14 @@ } }, "proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, "qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "requires": { "side-channel": "^1.1.0" } @@ -2541,12 +2549,12 @@ } }, "side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "requires": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" } }, "side-channel-map": { @@ -2631,14 +2639,14 @@ } }, "undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==" + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==" }, "undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==" }, "unpipe": { "version": "1.0.0", @@ -2655,11 +2663,6 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==" - }, "winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -2705,9 +2708,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "requires": {} }, "xtend": { diff --git a/package.json b/package.json index 720a37903b..689c5394f5 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,11 @@ "express": "^5.1.0", "node-cron": "^4.2.1", "pg": "^8.11.3", - "web-streams-polyfill": "^3.3.3", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", "zod": "^3.25.76" }, "devDependencies": {}, - "overrides": { - "undici": "^6.23.0" - }, "engines": { "node": ">=18.0.0" } diff --git a/roadmap.txt b/roadmap.txt index 207e7b101d..d1ebec84b1 100644 --- a/roadmap.txt +++ b/roadmap.txt @@ -3,69 +3,146 @@ TitanBot Roadmap / Structure Overview Goal - Keep this as a practical map of the codebase. - Show main folders and important files only (not every file). +- Document all implemented systems and features. Root - index.js -> process entry point -- package.json -> scripts, dependencies, project metadata +- package.json -> scripts, dependencies, project metadata (v2.0.0) - README.md -> setup and usage docs - SECURITY.md -> security policy -- scripts/ -> utility/maintenance scripts -- database/ -> database model definitions +- scripts/ -> utility/maintenance scripts (backup, restore, migrate) - logs/ -> runtime log output - src/ -> main bot application code +- tests/ -> test suite (17 tests for core systems) src/ -- app.js -> creates client, initializes systems +- app.js -> creates client, initializes systems, starts web server -- commands/ -> slash command categories - - Core/ -> essential bot commands - - help.js, ping.js, stats.js, uptime.js, invite.js - - Community/ -> application/community commands - - Config/ -> server/bot configuration commands - - Counter/ -> counter channel commands - - Economy/ -> economy system commands - - Moderation/ -> moderation/admin commands - - Ticket/ -> ticket system commands - - Verification/ -> verification commands - - JoinToCreate/ -> dynamic voice channel commands - - Reaction_roles/ -> role assignment commands - - (Also includes: Birthday, Fun, Giveaway, Leveling, Search, Tools, Utility, Voice, Welcome) +- commands/ -> slash command categories (19 feature modules) + - Core/ -> essential bot commands (help, ping, stats, uptime, invite) + - Community/ -> application management system + dashboards + - modules/app_dashboard.js -> interactive applications management UI + - Logging/ -> audit logging + event management + - modules/logging_dashboard.js -> logging configuration dashboard + - modules/logging_filter.js -> ignore list management + - modules/logging_setchannel.js -> log channel configuration + - Ticket/ -> support ticket system + management + - modules/ticket_dashboard.js -> ticket system configuration dashboard + - ServerStats/ -> server stats channel system + - Economy/ -> economy system (currency, shop, transactions) + - Moderation/ -> moderation/admin commands (ban, warn, kick, mute) + - Verification/ -> member verification flow + - JoinToCreate/ -> dynamic voice channel creation + - Reaction_roles/ -> automatic role assignment + - Birthday/ -> birthday tracking and announcements + - Fun/ -> entertainment/game commands + - Giveaway/ -> giveaway management system + - Leveling/ -> xp and level system + - Search/ -> search functionality + - Tools/ -> utility tools + - Utility/ -> general utility commands + - Voice/ -> voice channel features + - Welcome/ -> welcome message system -- config/ -> centralized configuration - - index.js -> config loader/exports - - bot.js -> bot settings, embed theme/colors, feature flags - - postgres.js -> PostgreSQL config - - shop/ -> shop config entries +- config/ -> centralized configuration modules + - application.js -> main application config loader + - bot.js -> bot settings, embed colors/theme, presence, feature flags + - postgres.js -> PostgreSQL connection config and pool settings + - schemaVersion.js -> database schema version tracking + - shop/ -> shop item definitions and economy config -- events/ -> Discord event listeners - - ready.js, interactionCreate.js, messageCreate.js - - guildMemberAdd.js, guildMemberRemove.js, voiceStateUpdate.js +- events/ -> Discord event listeners (13 event handlers) + - ready.js -> bot startup initialization + - interactionCreate.js -> slash command/button/modal interaction handler + - messageCreate.js -> message processing and prefix commands + - messageDelete.js -> message deletion logging + - messageUpdate.js -> message edit logging + - guildMemberAdd.js -> new member join handling + - guildMemberRemove.js -> member leave handling + - guildMemberUpdate.js -> member profile change handling + - voiceStateUpdate.js -> voice channel join/leave handling + - roleCreate.js -> role creation logging + - roleDelete.js -> role deletion logging + - userUpdate.js -> user profile change logging + - channelDelete.js -> channel deletion logging -- handlers/ -> loader/router layer for commands/interactions - - commandLoader.js, commands.js, events.js, interactions.js - - helpSelectMenuLoader.js, helpButtons.js - - ticketButtons.js, verificationButtonLoader.js +- handlers/ -> interaction routing and component handlers + - commandLoader.js -> loads all slash commands at startup + - commands.js -> slash command execution router + - events.js -> Discord event listener registration + - interactions.js -> button/select menu/modal interaction router + - interactionHandlers/ -> specific interaction handler modules + - calculateButtons.js -> calculator button interactions + - calculateModals.js -> calculator modal interactions + - countdownButtons.js -> countdown timer button handlers + - counterButtons.js -> counter system button handlers + - giveawayButtons.js -> giveaway participation handlers + - helpButtons.js -> help command buttons + - helpSelectMenus.js -> help command select menus + - loggingButtons.js -> logging dashboard buttons + - reactionRoles.js -> reaction role assignment handlers + - ticketButtons.js -> ticket system button handlers + - todoButtons.js -> todo system button handlers + - verificationButtons.js -> verification button handlers + - wipedataButtons.js -> wipe data confirmation handlers -- interactions/ -> interaction-specific handlers - - verificationButtonHandler.js - - selectMenus/ -> select menu handlers +- interactions/ -> Discord interaction-specific handlers + - buttons/ -> button component response handlers + - modals/ -> modal form submission handlers + - selectMenus/ -> select menu choice handlers -- services/ -> business logic + data operations - - database.js -> storage abstraction - - guildConfig.js -> guild-level settings service - - ticket.js, giveawayService.js, reactionRoleService.js - - leveling.js, xpSystem.js, economy.js, counterService.js +- services/ -> business logic + data operations (22 service modules) + - database.js -> storage abstraction layer + - guildConfig.js -> guild-level settings persistence + - applicationService.js -> application role management + - birthdayService.js -> birthday tracking and notifications + - configService.js -> configuration management + - economy.js -> economy system operations + - economyService.js -> economy service layer + - giveawayService.js -> giveaway management and scheduling + - joinToCreateService.js -> dynamic voice channel creation + - leveling.js -> user leveling system + - loggingService.js -> audit logging service + - moderationService.js -> moderation operations (warnings, bans, etc) + - reactionRoleService.js -> reaction role assignment + - serverstatsService.js -> server statistics tracking + - shopService.js -> economy shop management + - ticket.js -> ticket system operations + - utilityService.js -> utility helper functions + - verificationService.js -> member verification + - voiceService.js -> voice channel operations + - warningService.js -> user warning system + - welcomeService.js -> welcome message system + - xpSystem.js -> experience points calculation -- utils/ -> shared helpers/utilities - - embeds.js -> standardized embed creation - - components.js -> Discord UI component builders - - logger.js -> logging utility - - postgresDatabase.js, memoryStorage.js, schemas.js - - errorHandler.js, permissionGuard.js, interactionHelper.js +- utils/ -> shared helpers and utilities (20+ utility modules) + - logger.js -> logging system with file rotation + - embeds.js -> standardized embed creation helpers + - components.js -> Discord button/select menu/modal builders + - helpers.js -> general purpose helper functions + - constants.js -> application constants and enums + - errorHandler.js -> custom error handling and error registry + - errorRegistry.js -> error message registry + - permissionGuard.js -> permission checking utilities + - interactionHelper.js -> safe interaction response utilities + - interactionValidator.js -> interaction input validation + - database.js -> database utility functions + - postgresDatabase.js -> PostgreSQL-specific operations + - memoryStorage.js -> in-memory fallback storage + - dateUtils.js -> date/time helper functions + - messageTemplates.js -> message template definitions + - abuseProtection.js -> rate limiting and abuse detection + - commandInputValidation.js -> Zod schema validation + - moderation.js -> moderation utility functions + - economy.js -> economy calculation helpers + - giveaways.js -> giveaway scheduling helpers + - loggingUi.js -> logging dashboard UI builders + - rateLimiter.js -> cooldown and rate limit management -Database models -- database/models/welcomeSystem.js -> welcome system schema/model +Database Abstraction Layer +- Supports PostgreSQL (primary) with in-memory fallback for development +- Graceful degradation: if PostgreSQL unavailable, bot switches to in-memory mode Notes -- This project is modular by feature area: commands -> handlers -> services -> utils. -- bot.js is the single source of truth for embed theming/colors and core bot config. +- This project is modular by feature area: commands -> handlers -> services -> utils +- bot.js is the single source of truth for embed theming/colors and core bot config diff --git a/src/app.js b/src/app.js index 8f0e2b4dc7..9de27d1f94 100644 --- a/src/app.js +++ b/src/app.js @@ -4,15 +4,10 @@ import { REST } from '@discordjs/rest'; import express from 'express'; import cron from 'node-cron'; -import { ReadableStream } from 'web-streams-polyfill'; -if (typeof global.ReadableStream === 'undefined') { - global.ReadableStream = ReadableStream; -} - import config from './config/application.js'; import { initializeDatabase } from './utils/database.js'; import { getGuildConfig } from './services/guildConfig.js'; -import { getServerCounters, saveServerCounters, updateCounter } from './services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter } from './services/serverstatsService.js'; import { logger, startupLog, shutdownLog } from './utils/logger.js'; import { checkBirthdays } from './services/birthdayService.js'; import { checkGiveaways } from './services/giveawayService.js'; diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js index 92bab80fe5..fcd9fafd5f 100644 --- a/src/commands/Birthday/birthday.js +++ b/src/commands/Birthday/birthday.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; @@ -8,6 +8,7 @@ import birthdayInfo from './modules/birthday_info.js'; import birthdayList from './modules/birthday_list.js'; import birthdayRemove from './modules/birthday_remove.js'; import nextBirthdays from './modules/next_birthdays.js'; +import birthdaySetchannel from './modules/birthday_setchannel.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { @@ -60,6 +61,18 @@ export default { subcommand .setName('next') .setDescription('Show upcoming birthdays') + ) + .addSubcommand(subcommand => + subcommand + .setName('setchannel') + .setDescription('Set or disable the channel for birthday announcements. (Manage Server required)') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The text channel for announcements. Leave empty to disable.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false) + ) ), async execute(interaction, config, client) { @@ -77,6 +90,8 @@ export default { return await birthdayRemove.execute(interaction, config, client); case 'next': return await nextBirthdays.execute(interaction, config, client); + case 'setchannel': + return await birthdaySetchannel.execute(interaction, config, client); default: return InteractionHelper.safeReply(interaction, { embeds: [errorEmbed('Error', 'Unknown subcommand')], diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js new file mode 100644 index 0000000000..2da4b9409a --- /dev/null +++ b/src/commands/Birthday/modules/birthday_setchannel.js @@ -0,0 +1,44 @@ +import { PermissionsBitField, MessageFlags } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to configure the birthday channel.')], + flags: MessageFlags.Ephemeral, + }); + } + + try { + const channel = interaction.options.getChannel('channel'); + const guildId = interaction.guildId; + const guildConfig = await getGuildConfig(client, guildId); + + if (channel) { + guildConfig.birthdayChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('๐ŸŽ‚ Birthday Announcements Enabled', `Birthday announcements will now be posted in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + } else { + guildConfig.birthdayChannelId = null; + await setGuildConfig(client, guildId, guildConfig); + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('๐ŸŽ‚ Birthday Announcements Disabled', 'No channel provided โ€” birthday announcements have been disabled.')], + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + logger.error('birthday_setchannel error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Configuration Error', 'Could not save the birthday channel configuration.')], + flags: MessageFlags.Ephemeral, + }); + } + }, +}; diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js index 778d392e22..955026736e 100644 --- a/src/commands/Birthday/modules/next_birthdays.js +++ b/src/commands/Birthday/modules/next_birthdays.js @@ -51,7 +51,7 @@ export default { } embed.addFields({ - name: `${displayIndex}. ${member.displayName}`, + name: `${displayIndex}.`, value: `<@${birthday.userId}>\n๐Ÿ“… **Date:** ${birthday.monthName} ${birthday.day}\nโฐ **Time:** ${timeUntil}`, inline: false }); diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js index 898c1ec4f8..06ce3d2da0 100644 --- a/src/commands/Community/app-admin.js +++ b/src/commands/Community/app-admin.js @@ -1,4 +1,4 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { getColor } from '../../config/bot.js'; import { logger } from '../../utils/logger.js'; @@ -11,9 +11,13 @@ import { getApplications, updateApplication, getApplicationRoles, - saveApplicationRoles + saveApplicationRoles, + getApplicationRoleSettings, + saveApplicationRoleSettings, + deleteApplication } from '../../utils/database.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; +import appDashboard from './modules/app_dashboard.js'; function getApplicationStatusPresentation(statusValue) { const normalized = typeof statusValue === 'string' ? statusValue.trim().toLowerCase() : 'unknown'; @@ -39,40 +43,7 @@ export default { .addSubcommand((subcommand) => subcommand .setName("setup") - .setDescription("Configure application settings") - .addChannelOption((option) => - option - .setName("log-channel") - .setDescription( - "Channel where new applications will be logged", - ) - .setRequired(false), - ) - .addRoleOption((option) => - option - .setName("manager-role") - .setDescription( - "Role that can manage applications (can be used multiple times)", - ) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("enabled") - .setDescription("Enable or disable applications") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("view") - .setDescription("View a specific application") - .addStringOption((option) => - option - .setName("id") - .setDescription("The application ID") - .setRequired(true), - ), + .setDescription("Set up a new application") ) .addSubcommand((subcommand) => subcommand @@ -83,22 +54,6 @@ export default { .setName("id") .setDescription("The application ID") .setRequired(true), - ) - .addStringOption((option) => - option - .setName("action") - .setDescription("Approve or deny the application") - .setRequired(true) - .addChoices( - { name: "Approve", value: "approve" }, - { name: "Deny", value: "deny" }, - ), - ) - .addStringOption((option) => - option - .setName("reason") - .setDescription("Reason for approval/denial") - .setRequired(false), ), ) .addSubcommand((subcommand) => @@ -133,37 +88,15 @@ export default { ) .addSubcommand((subcommand) => subcommand - .setName("roles") - .setDescription("Manage application roles") - .addStringOption((option) => - option - .setName("action") - .setDescription("Action to perform") - .setRequired(true) - .addChoices( - { name: "Add Role", value: "add" }, - { name: "Remove Role", value: "remove" }, - { name: "List Roles", value: "list" } - ) - ) - .addRoleOption((option) => - option - .setName("role") - .setDescription("The role to add/remove") - .setRequired(false) - ) + .setName("dashboard") + .setDescription("Open the applications configuration dashboard") .addStringOption((option) => option - .setName("name") - .setDescription("Custom name for the application") + .setName("application") + .setDescription("Select an application to configure") .setRequired(false) - .setMaxLength(50) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("questions") - .setDescription("Configure application questions") + .setAutocomplete(true), + ), ), category: "Community", @@ -179,8 +112,8 @@ export default { const { options, guild, member } = interaction; const subcommand = options.getSubcommand(); - if (subcommand !== "questions") { - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + if (subcommand !== 'dashboard' && subcommand !== 'setup') { + await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); } logger.info(`App-admin command executed: ${subcommand}`, { @@ -189,206 +122,172 @@ export default { subcommand }); - + // โœ“ Permission check: User must have ManageGuild permission or a configured manager role + // This prevents unauthorized users from accessing admin functions await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); if (subcommand === "setup") { await handleSetup(interaction); - } else if (subcommand === "view") { - await handleView(interaction); } else if (subcommand === "review") { await handleReview(interaction); } else if (subcommand === "list") { await handleList(interaction); - } else if (subcommand === "roles") { - await handleRoles(interaction); - } else if (subcommand === "questions") { - await handleQuestions(interaction); + } else if (subcommand === "dashboard") { + const selectedAppName = interaction.options.getString("application"); + await appDashboard.execute(interaction, null, interaction.client, selectedAppName); } }, { type: 'command', commandName: 'app-admin' }) }; async function handleSetup(interaction) { - const logChannel = interaction.options.getChannel("log-channel"); - const managerRole = interaction.options.getRole("manager-role"); - const enabled = interaction.options.getBoolean("enabled"); - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const updates = {}; - - if (logChannel) { - if (!logChannel.isTextBased()) { - throw createError( - 'Invalid channel type', - ErrorTypes.VALIDATION, - 'The log channel must be a text channel.', - { channelId: logChannel.id } - ); - } - updates.logChannelId = logChannel.id; - } - - if (managerRole) { - const managerRoles = new Set(settings.managerRoles || []); - if (managerRoles.has(managerRole.id)) { - managerRoles.delete(managerRole.id); - updates.managerRoles = Array.from(managerRoles); - } else { - managerRoles.add(managerRole.id); - updates.managerRoles = Array.from(managerRoles); - } - } - - if (enabled !== null) { - updates.enabled = enabled; - } - - if (Object.keys(updates).length === 0) { - return showCurrentSettings(interaction, settings); + // Ensure interaction hasn't been deferred/replied yet (safety check) + if (interaction.deferred || interaction.replied) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed("This interaction has already been processed. Please try the command again.")], + flags: ["Ephemeral"], + }); } - const updatedSettings = await ApplicationService.updateSettings( - interaction.client, - interaction.guild.id, - updates - ); - - await showCurrentSettings(interaction, updatedSettings); -} + // Show modal for setting up a new application + const modal = new ModalBuilder() + .setCustomId('app_setup_modal') + .setTitle('Set Up New Application'); + + const rows = [ + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_name') + .setLabel('Application Name') + .setStyle(TextInputStyle.Short) + .setPlaceholder('e.g., Moderator, Helper, Developer') + .setMaxLength(50) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('role_id') + .setLabel('Role ID') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Right-click a role and copy its ID') + .setMaxLength(20) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_1') + .setLabel('Question 1 (required)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Why do you want this role?') + .setMaxLength(100) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_2') + .setLabel('Question 2 (optional)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('What experience do you have?') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('app_question_3') + .setLabel('Question 3 (optional)') + .setStyle(TextInputStyle.Short) + .setMaxLength(100) + .setRequired(false), + ), + ]; + + modal.addComponents(...rows); + + await interaction.showModal(modal); -async function showCurrentSettings(interaction, settings) { - const embed = createEmbed({ title: "Application Settings", description: "Current configuration for the application system.", }); + try { + const submitted = await interaction.awaitModalSubmit({ + time: 15 * 60 * 1000, // 15 minutes + filter: (i) => + i.customId === 'app_setup_modal' && + i.user.id === interaction.user.id, + }); - embed.addFields( - { - name: "Status", - value: settings.enabled ? "โœ… Enabled" : "โŒ Disabled", - inline: true, - }, - { - name: "Log Channel", - value: settings.logChannelId - ? `<#${settings.logChannelId}>` - : "Not set", - inline: true, - }, - { - name: "Manager Roles", - value: - settings.managerRoles?.length > 0 - ? settings.managerRoles.map((id) => `<@&${id}>`).join(", ") - : "Server Admins only", - inline: false, - }, - { - name: "Default Questions", - value: - `There are ${settings.questions?.length || 0} default questions configured.\n` + - `Use \`/app-admin questions\` to edit them.`, - inline: false, - }, - ); + const appName = submitted.fields.getTextInputValue('app_name').trim(); + const roleId = submitted.fields.getTextInputValue('role_id').trim(); + const questions = [ + submitted.fields.getTextInputValue('app_question_1').trim(), + submitted.fields.getTextInputValue('app_question_2').trim(), + submitted.fields.getTextInputValue('app_question_3').trim(), + ].filter(q => q.length > 0); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); -} + // Get the role to verify it exists + let role; + try { + role = await interaction.guild.roles.fetch(roleId); + } catch (error) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Role', 'The role ID you provided does not exist.')], + flags: ['Ephemeral'], + }); + return; + } -async function handleView(interaction) { - const appId = interaction.options.getString("id"); - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); + // Check if this role is already an application + const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); + if (existingRoles.some(r => r.roleId === roleId)) { + await submitted.reply({ + embeds: [errorEmbed('Already Configured', `The role ${role} is already configured as an application.`)], + flags: ['Ephemeral'], + }); + return; + } - if (!application) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Application not found.")], - flags: ["Ephemeral"], + // Add the role to applications with enabled status + existingRoles.push({ + roleId: roleId, + name: appName, + enabled: true, // New applications start enabled }); - } - 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:** ${submittedAtDisplay}`, - }).setColor(statusColor); - - if (application.avatar) { - embed.setThumbnail(application.avatar); - } + await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - 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: question, - value: - response.length > 1000 - ? response.substring(0, 997) + "..." - : response, - inline: false, - }); - }); + // Enable the system + const settings = await getApplicationSettings(interaction.client, interaction.guild.id); + if (!settings.enabled) { + await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); + } - if (answers.length === 0) { - embed.addFields({ - name: 'Application Answers', - value: 'No answers were stored for this application.', - inline: false, + // Save the questions for this specific role + await saveApplicationRoleSettings(interaction.client, interaction.guild.id, roleId, { questions }); + + await submitted.reply({ + embeds: [successEmbed( + 'โœ… Application Created', + `**${appName}** application has been created for ${role}.\n\nYou can customize the log channel, manager roles, questions, and retention period in the dashboard.`, + )], + flags: ['Ephemeral'], }); - } - if (application.status === "pending") { - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`app_approve_${application.id}`) - .setLabel("Approve") - .setStyle(ButtonStyle.Success) - .setEmoji("โœ…"), - new ButtonBuilder() - .setCustomId(`app_deny_${application.id}`) - .setLabel("Deny") - .setStyle(ButtonStyle.Danger) - .setEmoji("โŒ"), - ); + // Auto-open dashboard with this app selected + setTimeout(() => { + appDashboard.execute(submitted, null, interaction.client, appName); + }, 500); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - flags: ["Ephemeral"], - }); - } else { - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); + } catch (error) { + if (error.message.includes('timeout')) { + logger.info('App setup modal timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); + return; + } + throw error; } } + async function handleReview(interaction) { const appId = interaction.options.getString("id"); - const action = interaction.options.getString("action"); - const reason = - interaction.options.getString("reason") || "No reason provided."; const application = await getApplication( interaction.client, @@ -411,102 +310,208 @@ async function handleReview(interaction) { }); } - const status = action === "approve" ? "approved" : "denied"; + // Show application details with approve/deny buttons + const appEmbed = createEmbed({ + title: `๐Ÿ“‹ Review Application`, + description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, + color: 'info', + }); - const updatedApplication = await ApplicationService.reviewApplication( - interaction.client, - interaction.guild.id, - appId, - { - action, - reason, - reviewerId: interaction.user.id - } + // Add application answers to the embed + if (application.answers && application.answers.length > 0) { + application.answers.forEach((item, index) => { + appEmbed.addFields({ + name: `Q${index + 1}: ${item.question}`, + value: item.answer || '*No answer provided*', + inline: false + }); + }); + } + + const buttonRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_review_approve_${appId}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`app_review_deny_${appId}`) + .setLabel('Deny') + .setStyle(ButtonStyle.Danger), ); - - 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( - `${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.` - ).setColor(statusColor); + await InteractionHelper.safeEditReply(interaction, { + embeds: [appEmbed], + components: [buttonRow], + flags: ["Ephemeral"], + }); - await user.send({ embeds: [dmEmbed] }); - } catch (error) { - logger.warn('Failed to send DM to user for application review', { - error: error.message, - userId: application.userId, - applicationId: appId - }); - } + // Setup button collector + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId.startsWith(`app_review_approve_${appId}`) || + i.customId.startsWith(`app_review_deny_${appId}`)), + time: 300_000, // 5 minutes + max: 1, + }); + + collector.on('collect', async buttonInteraction => { + const isApprove = buttonInteraction.customId.includes('approve'); + + // Show modal for reason + const reasonModal = new ModalBuilder() + .setCustomId(`app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}`) + .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application - Reason`); + + reasonModal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('review_reason') + .setLabel('Reason (optional)') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Provide a reason for this decision...') + .setMaxLength(500) + .setRequired(false), + ), + ); + + await buttonInteraction.showModal(reasonModal); - - if (application.logMessageId && application.logChannelId) { try { - const statusColor = status === "approved" ? getColor('success') : getColor('error'); - const logChannel = interaction.guild.channels.cache.get( - application.logChannelId, + const reasonSubmit = await buttonInteraction.awaitModalSubmit({ + time: 5 * 60 * 1000, // 5 minutes + filter: i => + i.customId === `app_review_reason_${appId}_${isApprove ? 'approve' : 'deny'}` && + i.user.id === buttonInteraction.user.id, + }).catch(() => null); + + if (!reasonSubmit) return; + + const reason = reasonSubmit.fields.getTextInputValue('review_reason').trim() || "No reason provided."; + const action = isApprove ? 'approve' : 'deny'; + const status = isApprove ? 'approved' : 'denied'; + + const updatedApplication = await ApplicationService.reviewApplication( + reasonSubmit.client, + interaction.guild.id, + appId, + { + action, + reason, + reviewerId: reasonSubmit.user.id + } ); - if (logChannel) { - const logMessage = await logChannel.messages.fetch( - application.logMessageId, - ); - 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: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, - }); - await logMessage.edit({ - embeds: [newEmbed], -components: [], - }); + // Send DM to user + try { + const user = await reasonSubmit.client.users.fetch(application.userId); + const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const reviewStatus = getApplicationStatusPresentation(status); + const dmEmbed = createEmbed( + `${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.` + ).setColor(statusColor); + + await user.send({ embeds: [dmEmbed] }); + } catch (error) { + logger.warn('Failed to send DM to user for application review', { + error: error.message, + userId: application.userId, + applicationId: appId + }); + } + + // Update log message + if (application.logMessageId && application.logChannelId) { + try { + const statusColor = status === "approved" ? getColor('success') : getColor('error'); + const logChannel = interaction.guild.channels.cache.get( + application.logChannelId, + ); + if (logChannel) { + const logMessage = await logChannel.messages.fetch( + application.logMessageId, + ); + 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: `${reviewStatus.statusEmoji} ${reviewStatus.statusLabel}`, + }); + + await logMessage.edit({ + embeds: [newEmbed], + components: [], + }); + } + } } + } catch (error) { + logger.warn('Failed to update log message for application', { + error: error.message, + applicationId: appId, + logMessageId: application.logMessageId + }); } } - } catch (error) { - logger.warn('Failed to update log message for application', { - error: error.message, - applicationId: appId, - logMessageId: application.logMessageId + + // Assign role if approved + if (isApprove) { + try { + const member = await interaction.guild.members.fetch( + application.userId, + ); + await member.roles.add(application.roleId); + } catch (error) { + logger.error('Failed to assign role to approved applicant', { + error: error.message, + userId: application.userId, + roleId: application.roleId, + applicationId: appId + }); + } + } + + // Respond to modal submission + await reasonSubmit.reply({ + embeds: [ + successEmbed( + `Application ${status}`, + `The application has been **${status}**.`, + ), + ], + flags: ["Ephemeral"], }); - } - } - if (action === "approve") { - try { - const member = await interaction.guild.members.fetch( - application.userId, - ); - await member.roles.add(application.role); } catch (error) { - logger.error('Failed to assign role to approved applicant', { - error: error.message, - userId: application.userId, - roleId: application.roleId, - applicationId: appId + logger.error('Error reviewing application:', error); + await buttonInteraction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while reviewing the application.')], + flags: ["Ephemeral"], }); } - } + }); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `Application ${status}`, - `The application has been ${status}.`, - ), - ], - flags: ["Ephemeral"], + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = createEmbed({ + title: 'โฑ๏ธ Review Timeout', + description: 'The review buttons have timed out.', + color: 'warning', + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } }); } @@ -516,13 +521,34 @@ async function handleList(interaction) { const limit = interaction.options.getNumber("limit") || 10; const filters = {}; - if (status) filters.status = status; + // Default to showing only pending applications if no status specified + if (status) { + filters.status = status; + } else { + filters.status = 'pending'; + } let applications = await getApplications( interaction.client, interaction.guild.id, filters, ); + + // Filter out applications from users who are no longer in the guild (except if filtering by specific user) + if (!user) { + applications = await Promise.all( + applications.map(async (app) => { + try { + await interaction.guild.members.fetch(app.userId); + return app; // User still in guild + } catch { + // User no longer in guild, delete the application + await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); + return null; // Mark for removal + } + }) + ).then(results => results.filter(Boolean)); // Remove nulls + } if (user) { applications = applications.filter((app) => app.userId === user.id); @@ -595,202 +621,6 @@ async function handleList(interaction) { }); } -async function handleQuestions(interaction) { - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const modal = new ModalBuilder() - .setCustomId("edit_questions") - .setTitle("Edit Application Questions"); - - const questions = - settings.questions || ["Question 1", "Question 2"]; - - for (let i = 0; i < 5; i++) { - const input = new TextInputBuilder() - .setCustomId(`q${i}`) - .setLabel(`Question ${i + 1}`) - .setStyle(TextInputStyle.Short) -.setRequired(i === 0) - .setValue(questions[i] || ""); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - } - - await interaction.showModal(modal); - - try { - const modalResponse = await interaction.awaitModalSubmit({ -time: 30 * 60 * 1000, - filter: (i) => - i.customId === "edit_questions" && - i.user.id === interaction.user.id, - }); - - const newQuestions = []; - for (let i = 0; i < 5; i++) { - const question = modalResponse.fields - .getTextInputValue(`q${i}`) - .trim(); - if (question) { - newQuestions.push(question); - } - } - - if (newQuestions.length === 0) { - return modalResponse.reply({ - embeds: [errorEmbed("You must provide at least one question.")], - flags: ["Ephemeral"], - }); - } - - await ApplicationService.updateSettings( - interaction.client, - interaction.guild.id, - { questions: newQuestions } - ); - - await modalResponse.reply({ - embeds: [ - successEmbed( - "Questions Updated", - `The application questions have been updated.\n\n` + - newQuestions.map((q, i) => `${i + 1}. ${q}`).join("\n"), - ), - ], - flags: ["Ephemeral"], - }); - } catch (error) { - if (error.message.includes("timeout")) { - return; - } - logger.error('Error processing questions modal', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack - }); - } -} - -async function handleRoles(interaction) { - const action = interaction.options.getString("action"); - const role = interaction.options.getRole("role"); - const name = interaction.options.getString("name"); - - const currentRoles = await ApplicationService.manageApplicationRoles( - interaction.client, - interaction.guild.id, - { action, roleId: role?.id, name } - ); - - if (action === "list") { - if (currentRoles.length === 0) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("No application roles have been configured.")], - flags: ["Ephemeral"], - }); - } - - const embed = createEmbed({ - title: "Application Roles", - description: "Here are the configured application roles:" - }); - - currentRoles.forEach((appRole, index) => { - const roleObj = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${roleObj ? `<@&${appRole.roleId}>` : 'Role not found'}\n**ID:** \`${appRole.roleId}\``, - inline: false - }); - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } - - if (action === "add") { - const customName = name || role.name; - 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\`.` - )], - flags: ["Ephemeral"], - }); - } - - if (action === "remove") { - const removedRole = currentRoles.find(r => r.roleId === role.id); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Role Removed", - `**${removedRole?.name || 'Role'}** has been removed from the application system.` - )], - flags: ["Ephemeral"], - }); - } -} - -export async function handleApplicationButton(interaction) { - if (!interaction.isButton()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_approve_') && !customId.startsWith('app_deny_')) return; - - const [, action, appId] = customId.split('_'); - const isApprove = action === 'approve'; - - try { - const application = await getApplication(interaction.client, interaction.guild.id, appId); - if (!application) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('Application not found.')], - flags: ["Ephemeral"] - }); - } - - if (application.status !== 'pending') { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('This application has already been processed.')], - flags: ["Ephemeral"] - }); - } - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const isManager = interaction.member.permissions.has(PermissionFlagsBits.ManageGuild) || - (settings.managerRoles && settings.managerRoles.some(roleId => interaction.member.roles.cache.has(roleId))); - - if (!isManager) { - return InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed('You do not have permission to manage applications.')], - flags: ["Ephemeral"] - }); - } - - const modal = new ModalBuilder() - .setCustomId(`app_review_${appId}_${isApprove ? 'approve' : 'deny'}`) - .setTitle(`${isApprove ? 'Approve' : 'Deny'} Application`) - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('reason') - .setLabel('Reason (optional)') - .setStyle(TextInputStyle.Paragraph) - .setRequired(false) - .setMaxLength(500) - ) - ); - - await interaction.showModal(modal); - - } catch (error) { - logger.error('Error handling application button:', error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed('An error occurred while processing the application.')], - flags: ["Ephemeral"] - }); - } -} - export async function handleApplicationReviewModal(interaction) { if (!interaction.isModalSubmit()) return; diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js index ef617c1b59..f92bd14197 100644 --- a/src/commands/Community/apply.js +++ b/src/commands/Community/apply.js @@ -11,7 +11,8 @@ import { createApplication, getApplication, getApplicationRoles, - updateApplication + updateApplication, + getApplicationRoleSettings } from '../../utils/database.js'; function getApplicationStatusPresentation(statusValue) { @@ -43,6 +44,7 @@ export default { .setName("application") .setDescription("The application you want to submit") .setRequired(true) + .setAutocomplete(true), ), ) .addSubcommand((subcommand) => @@ -76,7 +78,8 @@ export default { const subcommand = options.getSubcommand(); if (subcommand !== "submit") { - await InteractionHelper.safeDefer(interaction, { flags: ["Ephemeral"] }); + const isListCommand = subcommand === "list"; + await InteractionHelper.safeDefer(interaction, { flags: isListCommand ? [] : ["Ephemeral"] }); } logger.info(`Apply command executed: ${subcommand}`, { @@ -138,7 +141,13 @@ export async function handleApplicationModal(interaction) { const answers = []; const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + + // Get questions - use per-application questions if they exist, otherwise use global + let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); + if (roleSettings.questions && roleSettings.questions.length > 0) { + questions = roleSettings.questions; + } for (let i = 0; i < questions.length; i++) { const answer = interaction.fields.getTextInputValue(`q${i}`); @@ -169,8 +178,13 @@ export async function handleApplicationModal(interaction) { await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (settings.logChannelId) { - const logChannel = interaction.guild.channels.cache.get(settings.logChannelId); + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); + + // Use per-application log channel if exists, otherwise use global + const logChannelId = roleSettings.logChannelId || settings.logChannelId; + + if (logChannelId) { + const logChannel = interaction.guild.channels.cache.get(logChannelId); if (logChannel) { const logEmbed = createEmbed({ title: '๐Ÿ“ New Application', @@ -185,7 +199,7 @@ export async function handleApplicationModal(interaction) { await updateApplication(interaction.client, interaction.guild.id, application.id, { logMessageId: logMessage.id, - logChannelId: settings.logChannelId + logChannelId: logChannelId }); } } @@ -213,7 +227,6 @@ async function handleList(interaction) { if (applicationRoles.length === 0) { return InteractionHelper.safeEditReply(interaction, { embeds: [errorEmbed("No applications are currently available.")], - flags: ["Ephemeral"], }); } @@ -236,7 +249,7 @@ async function handleList(interaction) { text: "Use /apply submit application: to apply for any of these roles." }); - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); + return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); } catch (error) { logger.error('Error listing applications:', { error: error.message, @@ -305,8 +318,12 @@ async function handleSubmit(interaction, settings) { .setCustomId(`app_modal_${applicationRole.roleId}`) .setTitle(`Application for ${applicationRole.name}`); - const questions = - settings.questions || ["Why do you want this role?", "What is your experience?"]; + // Get questions - use per-application questions if they exist, otherwise use global + let questions = settings.questions || ["Why do you want this role?", "What is your experience?"]; + const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, applicationRole.roleId); + if (roleSettings.questions && roleSettings.questions.length > 0) { + questions = roleSettings.questions; + } questions.forEach((question, index) => { const input = new TextInputBuilder() diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js new file mode 100644 index 0000000000..692ce1d273 --- /dev/null +++ b/src/commands/Community/modules/app_dashboard.js @@ -0,0 +1,1278 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + 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 { safeDeferInteraction } from '../../../utils/interactionValidator.js'; +import { + getApplicationSettings, + saveApplicationSettings, + getApplicationRoles, + saveApplicationRoles, + getApplicationRoleSettings, + saveApplicationRoleSettings, + deleteApplicationRoleSettings, + getApplications, + deleteApplication, +} from '../../../utils/database.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(settings, roles, guild) { + const logChannel = settings.logChannelId ? `<#${settings.logChannelId}>` : '`Not set`'; + const managerRoleList = + settings.managerRoles?.length > 0 + ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') + : '`None configured`'; + const roleList = + roles.length > 0 + ? roles.map(r => `<@&${r.roleId}> โ€” ${r.name}`).join('\n') + : '`No application roles configured`'; + const questionCount = settings.questions?.length ?? 0; + const firstQ = + settings.questions?.[0] + ? `\`${settings.questions[0].length > 55 ? settings.questions[0].substring(0, 55) + 'โ€ฆ' : settings.questions[0]}\`` + : '`Not set`'; + + return new EmbedBuilder() + .setTitle('๐Ÿ“‹ Applications Dashboard') + .setDescription(`Manage application settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: 'โš™๏ธ Application Status', value: settings.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ“ข Log Channel', value: logChannel, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ›ก๏ธ Manager Roles', value: managerRoleList, inline: false }, + { name: '๐Ÿ“ Questions', value: `${questionCount} configured โ€” first: ${firstQ}`, inline: false }, + { name: '๐ŸŽญ Application Roles', value: roleList, inline: false }, + { + name: '๐Ÿ—‘๏ธ Retention', + value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + inline: false, + }, + ) + .setFooter({ text: 'Dashboard closes after 15 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`app_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Log Channel') + .setDescription('Set the channel where new applications are logged') + .setValue('log_channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Manager Roles') + .setDescription('Add or remove a role that can manage applications') + .setValue('manager_role') + .setEmoji('๐Ÿ›ก๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Questions') + .setDescription('Customise the questions shown on the application form') + .setValue('questions') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Add Application Role') + .setDescription('Add a role that members can apply for') + .setValue('role_add') + .setEmoji('โž•'), + new StringSelectMenuOptionBuilder() + .setLabel('Remove Application Role') + .setDescription('Remove a role from the applications list') + .setValue('role_remove') + .setEmoji('โž–'), + new StringSelectMenuOptionBuilder() + .setLabel('Retention Period') + .setDescription('Set how long pending and reviewed applications are kept') + .setValue('retention') + .setEmoji('๐Ÿ—‘๏ธ'), + ); +} + +function buildButtonRow(settings, guildId, disabled = false) { + const systemOn = settings.enabled === true; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_cfg_toggle_${guildId}`) + .setLabel('Applications') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, settings, roles, guildId) { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(settings, roles, rootInteraction.guild)], + components: [ + buildButtonRow(settings, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }).catch(() => {}); +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client, selectedAppName = null) { + try { + const guildId = interaction.guild.id; + + // Defer immediately to prevent Discord interaction timeout + await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); + + const [settings, roles] = await Promise.all([ + getApplicationSettings(client, guildId), + getApplicationRoles(client, guildId), + ]); + + // Check if application system is completely unconfigured + const isCompletelyUnconfigured = + !settings.logChannelId && + !settings.enabled && + (settings.managerRoles?.length ?? 0) === 0 && + roles.length === 0; + + if (isCompletelyUnconfigured) { + throw new TitanBotError( + 'Applications system not set up', + ErrorTypes.CONFIGURATION, + 'The applications system has not been configured yet. Please run `/app-admin setup` to create your first application.', + ); + } + + // If no application roles exist, show global settings to add one + if (roles.length === 0) { + await showGlobalDashboard(interaction, settings, roles, guildId, client); + return; + } + + // If a specific app was selected via autocomplete, show its dashboard directly + if (selectedAppName) { + const selectedRole = roles.find(r => r.name.toLowerCase() === selectedAppName.toLowerCase()); + if (selectedRole) { + await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); + return; + } + // If name doesn't match, fall through + } + + // Default: Show first application if no selection made + const defaultRole = roles[0]; + await showApplicationDashboard(interaction, defaultRole, settings, roles, guildId, client); + + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in app_dashboard:', error); + throw new TitanBotError( + `Applications dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the applications dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Application Selector (for multiple applications) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showApplicationSelector(interaction, roles, settings, guildId, client) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`app_select_${guildId}`) + .setPlaceholder('Select an application to configure...') + .addOptions( + roles.map(role => + new StringSelectMenuOptionBuilder() + .setLabel(role.name) + .setDescription(`Configure the ${role.name} application`) + .setValue(role.roleId) + .setEmoji('๐Ÿ“‹'), + ), + ); + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽฏ Select Application') + .setDescription('Choose which application role you want to configure.') + .setColor(getColor('info')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [new ActionRowBuilder().addComponents(selectMenu)], + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `app_select_${guildId}`, + time: 600_000, + max: 1, + }); + + collector.on('collect', async selectInteraction => { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const selectedRoleId = selectInteraction.values[0]; + const selectedRole = roles.find(r => r.roleId === selectedRoleId); + + if (selectedRole) { + await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); + } + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Timed Out', 'No selection was made. The dashboard has closed.')], + components: [], + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Global Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showGlobalDashboard(interaction, settings, roles, guildId, client) { + const selectMenu = buildSelectMenu(guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(settings, roles, interaction.guild)], + components: [ + buildButtonRow(settings, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }); + + setupCollectors(interaction, settings, roles, guildId, client, null); +} + +// โ”€โ”€โ”€ Application-Specific Dashboard โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { + const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); + + // Get application-specific settings + const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); + const questions = appSettings.questions || settings.questions || []; + const appLogChannelId = appSettings.logChannelId || settings.logChannelId; + const isEnabled = selectedRole.enabled !== false; // Default to true if not specified + + // Build comprehensive embed + const logChannelDisplay = appLogChannelId + ? `<#${appLogChannelId}>` + : '`Inherits global log channel`'; + + const questionsDisplay = questions.length > 0 + ? questions.map((q, i) => `${i + 1}. \`${q.length > 60 ? q.substring(0, 60) + 'โ€ฆ' : q}\``).join('\n') + : '`Inherits global questions`'; + + const managerRolesDisplay = settings.managerRoles && settings.managerRoles.length > 0 + ? settings.managerRoles.map(id => `<@&${id}>`).join(', ') + : '`None configured`'; + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽญ Application Dashboard') + .setDescription(`Configuration for **${selectedRole.name}**`) + .setColor(isEnabled ? getColor('success') : getColor('error')) + .addFields( + { + name: '๐ŸŽญ Role', + value: roleObj ? roleObj.toString() : `<@&${selectedRole.roleId}>`, + inline: true + }, + { + name: 'โš™๏ธ Application Status', + value: isEnabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**', + inline: true + }, + { name: '\u200B', value: '\u200B', inline: true }, + { + name: '๐Ÿ“ Questions', + value: questionsDisplay, + inline: false + }, + { + name: '๐Ÿ“ข Log Channel', + value: logChannelDisplay, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Manager Roles', + value: managerRolesDisplay, + inline: true + }, + { + name: '๐Ÿ—‘๏ธ Retention Period', + value: `Pending: **${settings.pendingApplicationRetentionDays ?? 30}d** ยท Reviewed: **${settings.reviewedApplicationRetentionDays ?? 14}d**`, + inline: false + }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); + + // Create dropdown button with customization options + const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); + + // Create control buttons + const controlButtons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`app_toggle_${selectedRole.roleId}`) + .setLabel(isEnabled ? 'Disable Application' : 'Enable Application') + .setStyle(isEnabled ? ButtonStyle.Danger : ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`app_delete_${selectedRole.roleId}`) + .setLabel('Delete Application') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ'), + ); + + const menuRow = new ActionRowBuilder().addComponents(configMenu); + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [embed], + components: [menuRow, controlButtons], + }); + + setupCollectors(rootInteraction, settings, roles, guildId, client, selectedRole.roleId); +} + +// โ”€โ”€โ”€ Collector Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function setupCollectors(interaction, settings, roles, guildId, client, selectedRoleId) { + const customIdPrefix = selectedRoleId ? `app_cfg_${selectedRoleId}` : `app_cfg_${guildId}`; + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && + (selectedRoleId + ? i.customId === customIdPrefix + : (i.customId === `app_cfg_${guildId}` || i.customId === `app_select_${guildId}`)), + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + // Catch expired interactions + if (!selectInteraction.isStringSelectMenu()) { + return; + } + switch (selectedOption) { + case 'log_channel': + await handleLogChannel(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'manager_role': + await handleManagerRole(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'questions': + await handleQuestions(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + case 'role_add': + await handleRoleAdd(selectInteraction, interaction, settings, roles, guildId, client); + break; + case 'role_remove': + await handleRoleRemove(selectInteraction, interaction, settings, roles, guildId, client); + break; + case 'retention': + await handleRetention(selectInteraction, interaction, settings, roles, guildId, client, selectedRoleId); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Applications config validation error: ${error.message}`); + } else { + logger.error('Unexpected applications dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await safeDeferInteraction(selectInteraction); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('\u23f0 Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + // โ”€โ”€ Global Toggle Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (!selectedRoleId) { + const globalToggleCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_cfg_toggle_${guildId}`, + time: 600_000, + }); + + globalToggleCollector.on('collect', async toggleInteraction => { + const deferred = await safeDeferInteraction(toggleInteraction); + if (!deferred) return; + + try { + const wasEnabled = settings.enabled === true; + settings.enabled = !wasEnabled; + + // Save the updated settings + await saveApplicationSettings(interaction.client, guildId, settings); + + // Refresh dashboard to show new status + const updatedSettings = await getApplicationSettings(interaction.client, guildId); + const updatedRoles = await getApplicationRoles(interaction.client, guildId); + await showGlobalDashboard(interaction, updatedSettings, updatedRoles, guildId, interaction.client); + + await toggleInteraction.followUp({ + embeds: [successEmbed( + wasEnabled ? '๐Ÿ”ด Applications Disabled' : '๐ŸŸข Applications Enabled', + `The applications system is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled + ? 'Members will no longer be able to apply for roles.' + : 'Members can now start applying for roles.' + }`, + )], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error toggling global application status:', error); + await toggleInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + globalToggleCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } + + // โ”€โ”€ Delete Button Collector (for application-specific dashboard) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + if (selectedRoleId) { + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_delete_${selectedRoleId}`, + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + // Defer the interaction to prevent timeout errors + const deferred = await safeDeferInteraction(btnInteraction); + if (!deferred) return; + + // Show confirmation modal + const confirmModal = new ModalBuilder() + .setCustomId('app_delete_confirm') + .setTitle('Confirm Application Deletion'); + + confirmModal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('confirm_text') + .setLabel(`Type "DELETE" to confirm deletion`) + .setStyle(TextInputStyle.Short) + .setPlaceholder('Type DELETE here') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true), + ), + ); + + try { + await btnInteraction.showModal(confirmModal); + } catch (error) { + logger.error('Error showing delete confirmation modal:', error); + await btnInteraction.followUp({ + embeds: [errorEmbed('Error', 'Failed to show confirmation modal. Please try again.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + return; + } + + try { + const confirmSubmit = await btnInteraction.awaitModalSubmit({ + time: 60_000, + filter: i => + i.customId === 'app_delete_confirm' && i.user.id === btnInteraction.user.id, + }).catch(() => null); + + if (!confirmSubmit) { + await btnInteraction.followUp({ + embeds: [errorEmbed('Cancelled', 'Application deletion was cancelled.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const confirmText = confirmSubmit.fields.getTextInputValue('confirm_text').trim(); + if (confirmText !== 'DELETE') { + await confirmSubmit.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type exactly "DELETE" to confirm.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Delete the application + await handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client); + collector.stop(); + btnCollector.stop(); + + } catch (error) { + logger.error('Error confirming application deletion:', error); + await btnInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the application.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + btnCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + // โ”€โ”€ Toggle Enable/Disable Button Collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const toggleCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `app_toggle_${selectedRoleId}`, + time: 900_000, + }); + + toggleCollector.on('collect', async toggleInteraction => { + const deferred = await safeDeferInteraction(toggleInteraction); + if (!deferred) return; + + try { + // Find and toggle the role + const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); + if (roleIndex === -1) { + await toggleInteraction.followUp({ + embeds: [errorEmbed('Not Found', 'Application role not found.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const wasEnabled = roles[roleIndex].enabled !== false; + roles[roleIndex].enabled = !wasEnabled; + + // Save the updated roles + await saveApplicationRoles(interaction.client, guildId, roles); + + // Refresh dashboard to show new status + const updatedRole = roles[roleIndex]; + const updatedSettings = await getApplicationSettings(interaction.client, guildId); + await showApplicationDashboard(interaction, updatedRole, updatedSettings, roles, guildId, interaction.client); + + await toggleInteraction.followUp({ + embeds: [successEmbed( + wasEnabled ? '๐Ÿ”ด Application Disabled' : '๐ŸŸข Application Enabled', + `The **${updatedRole.name}** application is now **${wasEnabled ? 'disabled' : 'enabled'}**.\n\n${ + wasEnabled + ? 'This application will no longer appear in `/apply submit` options.' + : 'This application will now appear in `/apply submit` options.' + }`, + )], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error toggling application status:', error); + await toggleInteraction.followUp({ + embeds: [errorEmbed('Error', 'An error occurred while toggling the application status.')], + flags: MessageFlags.Ephemeral, + }); + } + }); + + toggleCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Configuration Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue configuring your applications, please run the command again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } +} + +// โ”€โ”€โ”€ Build Select Menus โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildApplicationSelectMenu(guildId, roleId) { + return new StringSelectMenuBuilder() + .setCustomId(`app_cfg_${roleId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Log Channel') + .setDescription('Set the channel where applications are logged') + .setValue('log_channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Manager Roles') + .setDescription('Add or remove a role that can manage applications') + .setValue('manager_role') + .setEmoji('๐Ÿ›ก๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Questions') + .setDescription('Customise the questions shown on the application form') + .setValue('questions') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Retention Period') + .setDescription('Set how long pending and reviewed applications are kept') + .setValue('retention') + .setEmoji('๐Ÿ—‘๏ธ'), + ); +} + +// โ”€โ”€โ”€ Log Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('app_cfg_log_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + let currentChannel = settings.logChannelId; + if (selectedRoleId) { + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + currentChannel = roleSettings.logChannelId || settings.logChannelId; + } + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Log Channel') + .setDescription( + `**Current:** ${currentChannel ? `<#${currentChannel}>` : '`Not set`'}\n\nSelect the channel where new application submissions will be logged.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_log_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + const deferred = await safeDeferInteraction(chanInteraction); + if (!deferred) return; + + const channel = chanInteraction.channels.first(); + + if (!channel.isTextBased()) { + await chanInteraction.followUp({ + embeds: [errorEmbed('Invalid Channel', 'Please select a text channel.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (selectedRoleId) { + // Save per-application log channel + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + roleSettings.logChannelId = channel.id; + await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); + } else { + // Save global log channel + settings.logChannelId = channel.id; + await saveApplicationSettings(client, guildId, settings); + } + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Log Channel Updated', `Application submissions will now be logged in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Manager Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const currentRoles = settings.managerRoles ?? []; + const currentList = + currentRoles.length > 0 ? currentRoles.map(id => `<@&${id}>`).join(', ') : '`None`'; + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_manager_role') + .setPlaceholder('Select a role to add or remove...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ›ก๏ธ Manager Roles') + .setDescription( + `**Current:** ${currentList}\n\nSelect a role to **toggle** it โ€” selecting an existing manager role will remove it, selecting a new one will add it.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_manager_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + const role = roleInteraction.roles.first(); + const roleSet = new Set(settings.managerRoles ?? []); + const wasPresent = roleSet.has(role.id); + + if (wasPresent) { + roleSet.delete(role.id); + } else { + roleSet.add(role.id); + } + + settings.managerRoles = Array.from(roleSet); + await saveApplicationSettings(client, guildId, settings); + + await roleInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Manager Role Updated', + `${role} has been **${wasPresent ? 'removed from' : 'added to'}** the manager roles list.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Questions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleQuestions(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { + let currentQuestions = settings.questions ?? []; + + if (selectedRoleId) { + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + currentQuestions = roleSettings.questions ?? currentQuestions; + } + + const modal = new ModalBuilder() + .setCustomId('app_cfg_questions') + .setTitle('Edit Application Questions') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q1') + .setLabel('Question 1 (required)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[0] ?? '') + .setMaxLength(100) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q2') + .setLabel('Question 2 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[1] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q3') + .setLabel('Question 3 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[2] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q4') + .setLabel('Question 4 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[3] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('q5') + .setLabel('Question 5 (optional)') + .setStyle(TextInputStyle.Short) + .setValue(currentQuestions[4] ?? '') + .setMaxLength(100) + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_questions' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newQuestions = ['q1', 'q2', 'q3', 'q4', 'q5'] + .map(key => submitted.fields.getTextInputValue(key).trim()) + .filter(Boolean); + + if (newQuestions.length === 0) { + await submitted.reply({ + embeds: [errorEmbed('No Questions', 'At least one question is required.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (selectedRoleId) { + // Save per-application questions + const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); + roleSettings.questions = newQuestions; + await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); + } else { + // Save global questions + settings.questions = newQuestions; + await saveApplicationSettings(client, guildId, settings); + } + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Questions Updated', + `${newQuestions.length} question${newQuestions.length !== 1 ? 's' : ''} saved.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); +} + +// โ”€โ”€โ”€ Add Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_role_add_pick') + .setPlaceholder('Select the Discord role to add...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž• Add Application Role') + .setDescription( + 'Select a role that members can apply for. You can optionally set a custom display name after selecting.', + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_add_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const role = roleInteraction.roles.first(); + + // Check for duplicate + if (roles.some(r => r.roleId === role.id)) { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + await roleInteraction.followUp({ + embeds: [errorEmbed('Already Added', `${role} is already an application role.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Show modal for optional custom name + const nameModal = new ModalBuilder() + .setCustomId('app_cfg_role_add_name') + .setTitle('Application Role Name') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('role_name') + .setLabel('Display name (leave blank to use role name)') + .setStyle(TextInputStyle.Short) + .setValue(role.name) + .setMaxLength(50) + .setRequired(false), + ), + ); + + await roleInteraction.showModal(nameModal); + + const nameSubmit = await roleInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_role_add_name' && i.user.id === roleInteraction.user.id, + time: 60_000, + }) + .catch(() => null); + + if (!nameSubmit) return; + + const customName = nameSubmit.fields.getTextInputValue('role_name').trim() || role.name; + + roles.push({ roleId: role.id, name: customName }); + await saveApplicationRoles(client, guildId, roles); + + await nameSubmit.reply({ + embeds: [ + successEmbed( + 'โœ… Role Added', + `${role} has been added as an application role with name **${customName}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was added.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Remove Application Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const deferred = await safeDeferInteraction(selectInteraction); + if (!deferred) return; + + if (roles.length === 0) { + await selectInteraction.followUp({ + embeds: [errorEmbed('No Roles', 'There are no application roles configured to remove.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('app_cfg_role_remove_pick') + .setPlaceholder('Select the role to remove...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Application Role') + .setDescription( + `**Current roles:** ${roles.map(r => `<@&${r.roleId}> (${r.name})`).join(', ')}\n\nSelect the role to remove from the applications list.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'app_cfg_role_remove_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + const deferred = await safeDeferInteraction(roleInteraction); + if (!deferred) return; + + const role = roleInteraction.roles.first(); + const index = roles.findIndex(r => r.roleId === role.id); + + if (index === -1) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Not Found', `${role} is not in the application roles list.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + roles.splice(index, 1); + await saveApplicationRoles(client, guildId, roles); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Removed', `${role} has been removed from the application roles.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. Nothing was removed.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Retention Period โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('app_cfg_retention') + .setTitle('Application Retention Periods') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('pending_days') + .setLabel('Pending retention (days, 1โ€“3650)') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('reviewed_days') + .setLabel('Reviewed retention (days, 1โ€“3650)') + .setStyle(TextInputStyle.Short) + .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'app_cfg_retention' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const pendingDays = parseInt(submitted.fields.getTextInputValue('pending_days').trim(), 10); + const reviewedDays = parseInt(submitted.fields.getTextInputValue('reviewed_days').trim(), 10); + + if (isNaN(pendingDays) || pendingDays < 1 || pendingDays > 3650) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Pending retention must be a whole number between **1** and **3650** days.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Reviewed retention must be a whole number between **1** and **3650** days.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + settings.pendingApplicationRetentionDays = pendingDays; + settings.reviewedApplicationRetentionDays = reviewedDays; + await saveApplicationSettings(client, guildId, settings); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Retention Updated', + `Pending applications will be kept for **${pendingDays} days**.\nReviewed applications will be kept for **${reviewedDays} days**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, settings, roles, guildId); +} + +// โ”€โ”€โ”€ Delete Application โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { + try { + // Find the application in the roles array + const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); + if (roleIndex === -1) { + await confirmSubmit.reply({ + embeds: [errorEmbed('Not Found', 'Application role not found.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const deletedRole = roles[roleIndex]; + + // Remove from roles array + roles.splice(roleIndex, 1); + + // Save updated roles list + await saveApplicationRoles(client, guildId, roles); + + // Delete per-application settings + await deleteApplicationRoleSettings(client, guildId, selectedRoleId); + + // Get all applications for this guild and find ones with this roleId + const allApplications = await getApplications(client, guildId); + const applicationsToDelete = allApplications.filter(app => app.roleId === selectedRoleId); + + // Delete each application + for (const app of applicationsToDelete) { + await deleteApplication(client, guildId, app.id, app.userId); + } + + // Send success message + await confirmSubmit.reply({ + embeds: [ + successEmbed( + '๐Ÿ—‘๏ธ Application Deleted', + `The application for <@&${selectedRoleId}> (**${deletedRole.name}**) has been permanently deleted.\n\n` + + `Deleted: **${applicationsToDelete.length}** application${applicationsToDelete.length !== 1 ? 's' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + } catch (error) { + logger.error('Error in handleDeleteApplication:', error); + await confirmSubmit.reply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the application. Please try again.')], + flags: MessageFlags.Ephemeral, + }); + } +} diff --git a/src/commands/Config/config.js b/src/commands/Config/config.js deleted file mode 100644 index 81f45f7c85..0000000000 --- a/src/commands/Config/config.js +++ /dev/null @@ -1,225 +0,0 @@ -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'; -import loggingSetchannel from './modules/config_logging_setchannel.js'; -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") - .setDescription("Configuration commands for the bot.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommandGroup((group) => - group - .setName("birthday") - .setDescription("Manage birthday announcement settings.") - .addSubcommand((subcommand) => - subcommand - .setName("toggle") - .setDescription( - "Enable or disable birthday announcements by selecting a channel.", - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription( - "The text channel for birthday announcements (leave empty to disable).", - ) - .setRequired(false) - .addChannelTypes(ChannelType.GuildText), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("logging") - .setDescription("Manage logging configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("status") - .setDescription("Display current logging configuration."), - ) - .addSubcommand((subcommand) => - subcommand - .setName("setchannel") - .setDescription("Sets the channel where bot moderation and audit logs are sent.") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The text channel to use for logging.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("ticket_lifecycle") - .setDescription("The channel for ticket lifecycle events (open, close, delete, claim, etc.).") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("ticket_transcript") - .setDescription("The channel for ticket transcript logs.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("disable") - .setDescription("Set to True to disable logging completely.") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("add") - .setDescription("Adds a user or channel to the ignore list.") - .addStringOption((option) => - option - .setName("type") - .setDescription("The type of entity to ignore.") - .setRequired(true) - .addChoices( - { name: "User", value: "user" }, - { name: "Channel", value: "channel" }, - ), - ) - .addStringOption((option) => - option - .setName("id") - .setDescription("The ID of the User or Channel to ignore.") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("remove") - .setDescription("Removes a user or channel from the ignore list.") - .addStringOption((option) => - option - .setName("type") - .setDescription("The type of entity to stop ignoring.") - .setRequired(true) - .addChoices( - { name: "User", value: "user" }, - { name: "Channel", value: "channel" }, - ), - ) - .addStringOption((option) => - option - .setName("id") - .setDescription("The ID of the User or Channel to remove from the ignore list.") - .setRequired(true), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("reports") - .setDescription("Manage report configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("setchannel") - .setDescription("Sets the channel where user reports will be sent.") - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The text channel to send reports to.") - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ), - ), - ) - .addSubcommandGroup((group) => - group - .setName("premium") - .setDescription("Manage premium role configuration.") - .addSubcommand((subcommand) => - subcommand - .setName("setrole") - .setDescription("Sets the Discord role granted when the Premium Role shop item is purchased.") - .addRoleOption((option) => - option - .setName("role") - .setDescription("The role to be designated as the Premium Shop Role.") - .setRequired(true), - ), - ), - ), - - async execute(interaction, config, client) { - if (!interaction.memberPermissions.has(PermissionFlagsBits.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need the `Manage Server` permission to use this command.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const subcommandGroup = interaction.options.getSubcommandGroup(); - const subcommand = interaction.options.getSubcommand(); - - try { - if (subcommandGroup === "birthday") { - if (subcommand === "toggle") { - return birthdayToggle.execute(interaction, config, client); - } - } else if (subcommandGroup === "logging") { - if (subcommand === "status") { - return loggingStatus.execute(interaction, config, client); - } else if (subcommand === "setchannel") { - return loggingSetchannel.execute(interaction, config, client); - } else if (subcommand === "add" || subcommand === "remove") { - return loggingFilter.execute(interaction, config, client); - } - } else if (subcommandGroup === "reports") { - if (subcommand === "setchannel") { - return reportsSetchannel.execute(interaction, config, client); - } - } else if (subcommandGroup === "premium") { - if (subcommand === "setrole") { - return premiumSetrole.execute(interaction, config, client); - } - } - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Error", "Unknown subcommand or subcommand group."), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error("Config command error:", error); - - const errorMessage = { - embeds: [ - errorEmbed( - "Configuration Failed", - "Could not save the configuration to the database.", - ), - ], - flags: MessageFlags.Ephemeral, - }; - - if (interaction.deferred || interaction.replied) { - return InteractionHelper.safeEditReply(interaction, errorMessage); - } else { - 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 deleted file mode 100644 index b0b6e185c5..0000000000 --- a/src/commands/Config/modules/config_birthday_toggle.js +++ /dev/null @@ -1,58 +0,0 @@ -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 { - async execute(interaction, config, client) { -try { - const channel = interaction.options.getChannel("channel"); - const guildId = interaction.guildId; - - let guildConfig = await getGuildConfig(client, guildId); - - if (channel) { - guildConfig.birthdayChannelId = channel.id; - await setGuildConfig(client, guildId, guildConfig); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽ‚ Birthday Announcements Enabled", - `Birthday announcements will now be posted in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } else { - guildConfig.birthdayChannelId = null; - await setGuildConfig(client, guildId, guildConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽ‚ Birthday Announcements Disabled", - "Birthday announcements have been disabled. No channel selected.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - logger.error("config_birthday_toggle error:", error); - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Could not save the birthday configuration.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_logging_filter.js b/src/commands/Config/modules/config_logging_filter.js deleted file mode 100644 index 8970a20d2d..0000000000 --- a/src/commands/Config/modules/config_logging_filter.js +++ /dev/null @@ -1,145 +0,0 @@ -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 { - async execute(interaction, config, client) { -if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Administrator permissions to manage log filters.", - ), - ], - }); - } - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Database Error", "Database not initialized."), - ], - }); - } - - const subcommand = interaction.options.getSubcommand(); - const type = interaction.options.getString("type"); - const entityId = interaction.options.getString("id"); - const guildId = interaction.guildId; - - const currentConfig = config; - if (!currentConfig.logIgnore) { - currentConfig.logIgnore = { users: [], channels: [] }; - } - - let targetArray; - let entityName = ""; - let entityType = ""; - - if (type === "user") { - targetArray = currentConfig.logIgnore.users; - entityType = "User"; - const member = await interaction.guild.members - .fetch(entityId) - .catch(() => null); - entityName = member ? member.user.tag : `ID: ${entityId}`; - } else if (type === "channel") { - targetArray = currentConfig.logIgnore.channels; - entityType = "Channel"; - const channel = interaction.guild.channels.cache.get(entityId); - entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; - } else { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Type", - "Please choose 'user' or 'channel'.", - ), - ], - }); - } - - let successMessage = ""; - - if (subcommand === "add") { - if (targetArray.includes(entityId)) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Already Filtered", - `${entityType} **${entityName}** is already on the ignore list.`, - ), - ], - }); - } - targetArray.push(entityId); - successMessage = `${entityType} **${entityName}** has been added to the log ignore list. Events originating from them will not be logged.`; - } else if (subcommand === "remove") { - const index = targetArray.indexOf(entityId); - if (index === -1) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Not Filtered", - `${entityType} **${entityName}** was not found on the ignore list.`, - ), - ], - }); - } - targetArray.splice(index, 1); - successMessage = `${entityType} **${entityName}** has been removed from the log ignore list. Events will now be logged.`; - } - - try { - const { getGuildConfigKey } = await import('../../../utils/database.js'); - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, currentConfig); - - const logEmbed = successEmbed( - "Log Filter Updated", - successMessage, - ).addFields( - { - name: "Action", - value: - subcommand.charAt(0).toUpperCase() + - subcommand.slice(1), - inline: true, - }, - { name: "Entity Type", value: entityType, inline: true }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Log Filter Updated", - target: `Filter ${subcommand}`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - entityType, - loggingEnabled: currentConfig.enableLogging - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed("Success!", successMessage)], - }); - } catch (error) { - logger.error("Error saving log filter:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Database Error", - "Failed to save configuration change.", - ), - ], - }); - } - } -}; - - diff --git a/src/commands/Config/modules/config_logging_setchannel.js b/src/commands/Config/modules/config_logging_setchannel.js deleted file mode 100644 index 428428a55f..0000000000 --- a/src/commands/Config/modules/config_logging_setchannel.js +++ /dev/null @@ -1,189 +0,0 @@ -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 { 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 { - async execute(interaction, config, client) { -if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Administrator permissions.", - ), - ], - }); - } - - if (!client.db) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed("Database Error", "Database not initialized."), - ], - }); - } - - const guildId = interaction.guildId; - - const currentConfig = await getGuildConfig(client, guildId); - - const logChannel = interaction.options.getChannel("channel"); - const disableLogging = interaction.options.getBoolean("disable"); - const ticketLifecycle = interaction.options.getChannel("ticket_lifecycle"); - const ticketTranscript = interaction.options.getChannel("ticket_transcript"); - - try { - if (ticketLifecycle) { - const validation = validateLogChannel(ticketLifecycle, interaction.guild.members.me); - if (!validation.valid) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Invalid Channel", validation.error)], - }); - } - - if (!currentConfig.ticketLogging) { - currentConfig.ticketLogging = {}; - } - currentConfig.ticketLogging.lifecycleChannelId = ticketLifecycle.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐ŸŽซ Ticket Lifecycle Channel Set", - `**Channel:** ${ticketLifecycle}\n**Logs:** Ticket open, close, delete, claim, unclaim, and priority events\n\n**Updated by:** ${interaction.user.tag}` - ), - ], - }); - } - - if (ticketTranscript) { - const validation = validateLogChannel(ticketTranscript, interaction.guild.members.me); - if (!validation.valid) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed("Invalid Channel", validation.error)], - }); - } - - if (!currentConfig.ticketLogging) { - currentConfig.ticketLogging = {}; - } - currentConfig.ticketLogging.transcriptChannelId = ticketTranscript.id; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "๐Ÿ“œ Ticket Transcript Channel Set", - `**Channel:** ${ticketTranscript}\n**Logs:** Ticket transcript generation\n\n**Updated by:** ${interaction.user.tag}` - ), - ], - }); - } - - if (disableLogging) { - currentConfig.logChannelId = null; - currentConfig.enableLogging = false; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: false, - channelId: null - }; - await setGuildConfig(client, guildId, currentConfig); - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Logging Disabled ๐Ÿšซ", - "Server logging has been disabled.", - ), - ], - }); - } - - if (logChannel) { - const permissionsInChannel = logChannel.permissionsFor( - interaction.guild.members.me, - ); - if ( - !permissionsInChannel.has( - PermissionsBitField.Flags.SendMessages, - ) || - !permissionsInChannel.has(PermissionsBitField.Flags.EmbedLinks) - ) { - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Bot Permission Error", - `I need Send Messages and Embed Links permissions in ${logChannel}.`, - ), - ], - }); - } - - currentConfig.logChannelId = logChannel.id; - currentConfig.enableLogging = true; - currentConfig.logging = { - ...(currentConfig.logging || {}), - enabled: true, - channelId: logChannel.id - }; - - await setGuildConfig(client, guildId, currentConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Log Channel Set ๐Ÿ“", - `Logs will be sent to ${logChannel}.`, - ), - ], - }); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Log Channel Activated", - target: logChannel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Logging set by ${interaction.user}`, - metadata: { - channelId: logChannel.id, - moderatorId: interaction.user.id, - loggingEnabled: true - } - } - }); - return; - } - - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "No Option Provided", - "Please provide one of the following: channel, ticket_lifecycle, ticket_transcript, or disable." - ), - ], - }); - - } catch (error) { - logger.error("Error setting log channel:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Could not save configuration.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_logging_status.js b/src/commands/Config/modules/config_logging_status.js deleted file mode 100644 index 095e665381..0000000000 --- a/src/commands/Config/modules/config_logging_status.js +++ /dev/null @@ -1,184 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { PermissionsBitField, EmbedBuilder } from 'discord.js'; -import { errorEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.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'; -import { logger } from '../../../utils/logger.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)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Manage Server permissions.", - ), - ], - }); - } - - await InteractionHelper.safeDefer(interaction); - const { embed, components } = await buildLoggingStatusView(interaction, client); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components - }); - } catch (error) { - logger.error("config_logging_status error:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Configuration Error", - "Failed to fetch or display the configuration status.", - ), - ], - }); - } - } -}; - - - - - diff --git a/src/commands/Config/modules/config_premium_setrole.js b/src/commands/Config/modules/config_premium_setrole.js deleted file mode 100644 index 562859faee..0000000000 --- a/src/commands/Config/modules/config_premium_setrole.js +++ /dev/null @@ -1,53 +0,0 @@ -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) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - errorEmbed( - "Permission Denied", - "You need Manage Server permissions to set the premium role.", - ), - ], - }); - } - - const role = interaction.options.getRole("role"); - const guildId = interaction.guildId; - - try { - const currentConfig = await getGuildConfig(client, guildId); - - currentConfig.premiumRoleId = role.id; - - await setGuildConfig(client, guildId, currentConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "โœ… Configuration Saved", - `The **Premium Shop Role** has been successfully set to ${role.toString()}.`, - ), - ], - }); - } catch (error) { - logger.error("SetPremiumRole command error:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "System Error", - "Could not save the guild configuration.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Config/modules/config_reports_setchannel.js b/src/commands/Config/modules/config_reports_setchannel.js deleted file mode 100644 index 625850322a..0000000000 --- a/src/commands/Config/modules/config_reports_setchannel.js +++ /dev/null @@ -1,41 +0,0 @@ -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) { - const channel = interaction.options.getChannel("channel"); - const guildId = interaction.guildId; - - try { - let guildConfig = await getGuildConfig(client, guildId); - - guildConfig.reportChannelId = channel.id; - - await setGuildConfig(client, guildId, guildConfig); - - await InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "โœ… Report Channel Set!", - `All new reports will now be sent to ${channel}.`, - ), - ], - }); - } catch (error) { - logger.error("Error setting report channel:", error); - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Database Error", - "Could not save the channel configuration. Check bot permissions.", - ), - ], - }); - } - } -}; - - - diff --git a/src/commands/Core/overview.js b/src/commands/Core/overview.js new file mode 100644 index 0000000000..4c06839a80 --- /dev/null +++ b/src/commands/Core/overview.js @@ -0,0 +1,115 @@ +import { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; +import { getColor } from '../../config/bot.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; +import { getLoggingStatus } from '../../services/loggingService.js'; +import { getLevelingConfig } from '../../services/leveling.js'; +import { getConfiguration as getJoinToCreateConfiguration } from '../../services/joinToCreateService.js'; +import { getWelcomeConfig, getApplicationSettings } from '../../utils/database.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; + +function pill(enabled) { + return enabled ? 'โœ… On' : 'โŒ Off'; +} + +async function formatChannelMention(guild, id) { + if (!id) return '`Not configured`'; + 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 configured`'; + const role = guild.roles.cache.get(id); + return role ? role.toString() : `โš ๏ธ Missing (${id})`; +} + +export default { + data: new SlashCommandBuilder() + .setName('overview') + .setDescription('Read-only snapshot of all server system statuses.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false), + + async execute(interaction, config, client) { + try { + await InteractionHelper.safeDefer(interaction); + + const [guildConfig, loggingStatus, levelingConfig, welcomeConfig, applicationConfig, joinToCreateConfig] = + await Promise.all([ + getGuildConfig(client, interaction.guildId), + getLoggingStatus(client, interaction.guildId), + getLevelingConfig(client, interaction.guildId), + getWelcomeConfig(client, interaction.guildId), + getApplicationSettings(client, interaction.guildId), + getJoinToCreateConfiguration(client, interaction.guildId), + ]); + + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleId = guildConfig.autoRole || welcomeConfig?.roleIds?.[0]; + + // โ”€โ”€ Channels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [auditChannel, lifecycleChannel, transcriptChannel, reportChannel, birthdayChannel] = + await Promise.all([ + formatChannelMention(interaction.guild, loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId), + formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId), + formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId), + formatChannelMention(interaction.guild, guildConfig.reportChannelId), + formatChannelMention(interaction.guild, guildConfig.birthdayChannelId), + ]); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ–ฅ๏ธ System Overview') + .setDescription(`Read-only snapshot for **${interaction.guild.name}**. Use the relevant command's dashboard to make changes.`) + .setColor(getColor('primary')) + .addFields( + // โ”€โ”€ Core systems โ”€โ”€ + { + name: 'โš™๏ธ Core Systems', + value: [ + `๐Ÿงพ **Audit Logging** โ€” ${pill(Boolean(loggingStatus.enabled))}`, + `๐Ÿ“ˆ **Leveling** โ€” ${pill(Boolean(levelingConfig?.enabled))}`, + `๐Ÿ‘‹ **Welcome** โ€” ${pill(Boolean(welcomeConfig?.enabled))}`, + `๐Ÿ‘‹ **Goodbye** โ€” ${pill(Boolean(welcomeConfig?.goodbyeEnabled))}`, + `๐ŸŽ‚ **Birthdays** โ€” ${pill(Boolean(guildConfig.birthdayChannelId))}`, + `๐Ÿ“‹ **Applications** โ€” ${pill(Boolean(applicationConfig?.enabled))}`, + `โœ… **Verification** โ€” ${pill(verificationEnabled)}`, + `๐Ÿค– **Auto-Verify** โ€” ${pill(autoVerifyEnabled)}`, + `๐ŸŽง **Join to Create** โ€” ${pill(Boolean(joinToCreateConfig?.enabled))}`, + `๐Ÿ›ก๏ธ **Auto Role** โ€” ${autoRoleId ? `โœ… ${formatRoleMention(interaction.guild, autoRoleId)}` : 'โŒ Off'}`, + ].join('\n'), + inline: false, + }, + // โ”€โ”€ Channels โ”€โ”€ + { + name: '๐Ÿ“ก Configured Channels', + value: [ + `**Audit Log:** ${auditChannel}`, + `**Ticket Lifecycle:** ${lifecycleChannel}`, + `**Ticket Transcripts:** ${transcriptChannel}`, + `**Reports:** ${reportChannel}`, + `**Birthdays:** ${birthdayChannel}`, + ].join('\n'), + inline: false, + }, + // โ”€โ”€ Refresh stamp โ”€โ”€ + { + name: '๐Ÿ•’ Snapshot Taken', + value: ``, + inline: true, + }, + ) + .setFooter({ text: 'Read-only โ€” run /logging dashboard to manage audit settings' }) + .setTimestamp(); + + await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); + } catch (error) { + logger.error('overview command error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Overview Error', 'Failed to load the system overview.')], + }); + } + }, +}; diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js new file mode 100644 index 0000000000..53ae62d4e5 --- /dev/null +++ b/src/commands/Economy/modules/shop_browse.js @@ -0,0 +1,90 @@ +import { 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 { + async execute(interaction, config, client) { + try { + 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; + + const createShopEmbed = (page) => { + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const pageItems = shopItems.slice(startIndex, startIndex + ITEMS_PER_PAGE); + const embed = new EmbedBuilder() + .setTitle('๐Ÿ›’ Store') + .setColor(getColor('primary')) + .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); + pageItems.forEach(item => { + embed.addFields({ + name: `${item.name} (${item.id})`, + value: `๐Ÿท๏ธ **Type:** ${item.type}\n๐Ÿ’š **Price:** $${item.price.toLocaleString()}\n${item.description}`, + inline: false, + }); + }); + embed.setFooter({ text: `Page ${page}/${totalPages}` }); + return embed; + }; + + const createShopComponents = (page) => { + if (totalPages <= 1) return []; + return [ + new ActionRowBuilder().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), + ), + ]; + }; + + const message = await interaction.reply({ + embeds: [createShopEmbed(currentPage)], + components: createShopComponents(currentPage), + flags: 0, + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 300000, + }); + + collector.on('collect', async (buttonInteraction) => { + if (buttonInteraction.user.id !== interaction.user.id) { + await buttonInteraction.reply({ content: 'โŒ You cannot use these buttons. Run `/shop browse` to get your own shop view.', flags: 64 }); + return; + } + const { customId } = buttonInteraction; + if (customId === 'shop_prev' || customId === 'shop_next') { + await buttonInteraction.deferUpdate(); + if (customId === 'shop_prev' && currentPage > 1) currentPage--; + else if (customId === 'shop_next' && currentPage < totalPages) currentPage++; + await buttonInteraction.editReply({ + embeds: [createShopEmbed(currentPage)], + components: createShopComponents(currentPage), + }); + } + }); + + collector.on('end', async () => { + try { + const disabledComponents = createShopComponents(currentPage); + disabledComponents.forEach(row => row.components.forEach(btn => btn.setDisabled(true))); + await message.edit({ components: disabledComponents }); + } catch (_) {} + }); + } catch (error) { + logger.error('shop_browse error:', error); + await interaction.reply({ content: 'โŒ An error occurred while loading the shop.', flags: MessageFlags.Ephemeral }); + } + }, +}; diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js new file mode 100644 index 0000000000..aaf52f33fa --- /dev/null +++ b/src/commands/Economy/modules/shop_config_setrole.js @@ -0,0 +1,36 @@ +import { PermissionsBitField } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the premium role.')], + ephemeral: true, + }); + } + + const role = interaction.options.getRole('role'); + const guildId = interaction.guildId; + + try { + const currentConfig = await getGuildConfig(client, guildId); + currentConfig.premiumRoleId = role.id; + await setGuildConfig(client, guildId, currentConfig); + + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('โœ… Premium Role Set', `The **Premium Shop Role** has been set to ${role.toString()}. Members who purchase the Premium Role item will be granted this role.`)], + ephemeral: true, + }); + } catch (error) { + logger.error('shop_config_setrole error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('System Error', 'Could not save the guild configuration.')], + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js index 9fd4272d16..6ebdfe4585 100644 --- a/src/commands/Economy/shop.js +++ b/src/commands/Economy/shop.js @@ -1,123 +1,60 @@ -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 { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import shopBrowse from './modules/shop_browse.js'; +import shopConfigSetrole from './modules/shop_config_setrole.js'; export default { data: new SlashCommandBuilder() .setName('shop') - .setDescription('View the economy shop with pagination'), + .setDescription('Economy shop commands.') + .addSubcommand(subcommand => + subcommand + .setName('browse') + .setDescription('Browse the economy shop.'), + ) + .addSubcommandGroup(group => + group + .setName('config') + .setDescription('Configure shop settings. (Manage Server required)') + .addSubcommand(subcommand => + subcommand + .setName('setrole') + .setDescription('Set the Discord role granted when the Premium Role shop item is purchased.') + .addRoleOption(option => + option + .setName('role') + .setDescription('The role to grant for Premium Role purchases.') + .setRequired(true), + ), + ), + ), async execute(interaction, config, client) { try { - 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; - - const createShopEmbed = (page) => { - const startIndex = (page - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const pageItems = shopItems.slice(startIndex, endIndex); - - const embed = new EmbedBuilder() - .setTitle("๐Ÿ›’ Store") - .setColor(getColor('primary')) - .setDescription('Use `/buy item_id: quantity:` to purchase an item.'); - - pageItems.forEach(item => { - embed.addFields({ - name: `${item.name} (${item.id})`, - value: `๐Ÿท๏ธ **Type:** ${item.type}\n๐Ÿ’š **Price:** $${item.price.toLocaleString()}\n${item.description}`, - inline: false, - }); - }); - - embed.setFooter({ text: `Page ${page}/${totalPages}` }); - return embed; - }; + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(); - const createShopComponents = (page) => { - if (totalPages <= 1) { - return []; - } + if (subcommand === 'browse') { + return await shopBrowse.execute(interaction, config, client); + } - return [ - new ActionRowBuilder().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) - ) - ]; - }; + if (subcommandGroup === 'config' && subcommand === 'setrole') { + return await shopConfigSetrole.execute(interaction, config, client); + } - const message = await interaction.reply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage), - flags: 0 - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000 - }); - - collector.on('collect', async (buttonInteraction) => { - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.reply({ - content: 'โŒ You cannot use these buttons. Run `/shop` to get your own shop view.', - flags: 64 - }); - return; - } - - const { customId } = buttonInteraction; - - if (customId === 'shop_prev' || customId === 'shop_next') { - await buttonInteraction.deferUpdate(); - if (customId === 'shop_prev' && currentPage > 1) { - currentPage--; - } else if (customId === 'shop_next' && currentPage < totalPages) { - currentPage++; - } - await buttonInteraction.editReply({ - embeds: [createShopEmbed(currentPage)], - components: createShopComponents(currentPage) - }); - } - }); - - collector.on('end', async () => { - try { - const disabledComponents = createShopComponents(currentPage); - disabledComponents.forEach(row => { - row.components.forEach(button => button.setDisabled(true)); - }); - - await message.edit({ - components: disabledComponents - }); - } catch (error) { - - } + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'Unknown subcommand.')], + flags: MessageFlags.Ephemeral, }); } catch (error) { - logger.error('Shop command error:', error); - await interaction.reply({ - content: 'โŒ An error occurred while loading the shop.', - flags: MessageFlags.Ephemeral - }); + logger.error('shop command error:', error); + await InteractionHelper.safeReply(interaction, { + content: 'โŒ An error occurred while running the shop command.', + flags: MessageFlags.Ephemeral, + }).catch(() => {}); } }, -}; - - - - +}; \ No newline at end of file diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js index d51401940f..bec14aeaa7 100644 --- a/src/commands/JoinToCreate/jointocreate.js +++ b/src/commands/JoinToCreate/jointocreate.js @@ -61,7 +61,7 @@ export default { ) .addSubcommand((subcommand) => subcommand - .setName("config") + .setName("dashboard") .setDescription("Configure an existing Join to Create system.") .addChannelOption((option) => option @@ -92,7 +92,7 @@ export default { if (subcommand === "setup") { await handleSetupSubcommand(interaction, client); return; - } else if (subcommand === "config") { + } else if (subcommand === "dashboard") { await handleConfigSubcommand(interaction, client); return; } @@ -158,7 +158,7 @@ async function handleSetupSubcommand(interaction, client) { 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.`; + const errorMessage = `This server already has a Join to Create channel set up: ${primaryTrigger}\n\nUse \`/jointocreate dashboard\` to modify it, or remove it first before creating a new one.`; throw new TitanBotError( 'Guild already has a Join to Create channel', @@ -302,7 +302,7 @@ async function handleConfigSubcommand(interaction, client) { throw new TitanBotError( 'Failed to fetch interaction reply for collector setup', ErrorTypes.DISCORD_API, - 'Failed to open configuration controls. Please run `/jointocreate config` again.' + 'Failed to open configuration controls. Please run `/jointocreate dashboard` again.' ); } @@ -608,7 +608,7 @@ async function handleChannelDeletion(interaction, triggerChannel, currentConfig, filter: (i) => i.user.id === interaction.user.id && (i.customId === `jtc_delete_confirm_${triggerChannel.id}` || i.customId === `jtc_delete_cancel_${triggerChannel.id}`), - time: 30000, + time: 600_000, max: 1 }); diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js index 93fa30ed50..53c5b534bd 100644 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ b/src/commands/JoinToCreate/modules/config_setup.js @@ -191,7 +191,7 @@ async function handleNameTemplateChange(interaction, triggerChannel, currentConf const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id, -time: 30000, +time: 600_000, max: 1 }); @@ -269,7 +269,7 @@ async function handleUserLimitChange(interaction, triggerChannel, currentConfig, const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 30000, + time: 600_000, max: 1 }); @@ -352,7 +352,7 @@ async function handleBitrateChange(interaction, triggerChannel, currentConfig, c const collector = interaction.channel.createMessageCollector({ filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 30000, + time: 600_000, max: 1 }); @@ -440,7 +440,7 @@ async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, c componentType: ComponentType.Button, filter: (i) => i.user.id === interaction.user.id && (i.customId === `confirm_remove_${triggerChannel.id}` || i.customId === `cancel_remove_${triggerChannel.id}`), - time: 30000, + time: 600_000, max: 1 }); diff --git a/src/commands/Leveling/leaderboard.js b/src/commands/Leveling/leaderboard.js index 659b606af8..a0b0e6afa0 100644 --- a/src/commands/Leveling/leaderboard.js +++ b/src/commands/Leveling/leaderboard.js @@ -60,7 +60,7 @@ export default { leaderboard.map(async (user, index) => { try { const member = await interaction.guild.members.fetch(user.userId).catch(() => null); - const username = member?.user?.tag || `Unknown User (${user.userId})`; + const userMention = member?.user.toString() || `<@${user.userId}>`; const xpForNextLevel = getXpForLevel(user.level + 1); let rankPrefix = `${index + 1}.`; @@ -69,7 +69,7 @@ export default { else if (index === 2) rankPrefix = '๐Ÿฅ‰'; else rankPrefix = `**${index + 1}.**`; - return `${rankPrefix} ${username} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; + return `${rankPrefix} ${userMention} - Level ${user.level} (${user.xp}/${xpForNextLevel} XP)`; } catch { return `**${index + 1}.** Error loading user ${user.userId}`; } diff --git a/src/commands/Leveling/level.js b/src/commands/Leveling/level.js new file mode 100644 index 0000000000..df7235b1bd --- /dev/null +++ b/src/commands/Leveling/level.js @@ -0,0 +1,179 @@ +import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; +import { createEmbed, errorEmbed } from '../../utils/embeds.js'; +import { getLevelingConfig, saveLevelingConfig } from '../../services/leveling.js'; +import { botHasPermission } from '../../utils/permissionGuard.js'; +import { TitanBotError, ErrorTypes, handleInteractionError } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import levelDashboard from './modules/level_dashboard.js'; + +export default { + data: new SlashCommandBuilder() + .setName('level') + .setDescription('Manage the leveling system') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('setup') + .setDescription('Set up the leveling system โ€” this also enables it') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('Channel to send level-up notifications in') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('xp_min') + .setDescription('Minimum XP awarded per message (default: 15)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_max') + .setDescription('Maximum XP awarded per message (default: 25)') + .setMinValue(1) + .setMaxValue(500) + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('message') + .setDescription( + 'Level-up message. Use {user} and {level} as placeholders (default provided)', + ) + .setMaxLength(500) + .setRequired(false), + ) + .addIntegerOption((option) => + option + .setName('xp_cooldown') + .setDescription('Seconds between XP grants per user (default: 60)') + .setMinValue(0) + .setMaxValue(3600) + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('dashboard') + .setDescription('Open the interactive leveling configuration dashboard'), + ), + category: 'Leveling', + + async execute(interaction, config, client) { + try { + const deferred = await InteractionHelper.safeDefer(interaction, { + flags: MessageFlags.Ephemeral, + }); + if (!deferred) return; + + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Missing Permissions', + 'You need the **Manage Server** permission to use this command.', + ), + ], + }); + } + + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + return levelDashboard.execute(interaction, config, client); + } + + if (subcommand === 'setup') { + const channel = interaction.options.getChannel('channel'); + const xpMin = interaction.options.getInteger('xp_min') ?? 15; + const xpMax = interaction.options.getInteger('xp_max') ?? 25; + const message = + interaction.options.getString('message') ?? + '{user} has leveled up to level {level}!'; + const xpCooldown = interaction.options.getInteger('xp_cooldown') ?? 60; + + if (xpMin > xpMax) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Invalid XP Range', + `Minimum XP (**${xpMin}**) cannot be greater than maximum XP (**${xpMax}**).`, + ), + ], + }); + } + + if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + throw new TitanBotError( + 'Bot missing permissions in the specified channel', + ErrorTypes.PERMISSION, + `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + ); + } + + const existingConfig = await getLevelingConfig(client, interaction.guildId); + + if (existingConfig.configured) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Leveling System Already Active', + `The leveling system is already set up on this server (level-up notifications go to <#${existingConfig.levelUpChannel}>).\n\nUse \`/level dashboard\` to adjust any settings.`, + ), + ], + }); + } + + const newConfig = { + ...existingConfig, + configured: true, + enabled: true, + levelUpChannel: channel.id, + xpRange: { min: xpMin, max: xpMax }, + xpCooldown: xpCooldown, + levelUpMessage: message, + announceLevelUp: true, + }; + + await saveLevelingConfig(client, interaction.guildId, newConfig); + + logger.info(`Leveling system set up in guild ${interaction.guildId}`, { + channelId: channel.id, + xpMin, + xpMax, + xpCooldown, + userId: interaction.user.id, + }); + + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + createEmbed({ + title: 'โœ… Leveling System Set Up', + description: + `The leveling system is now **enabled** and ready to go.\n\n` + + `**Level-up Channel:** ${channel}\n` + + `**XP per Message:** ${xpMin} โ€“ ${xpMax}\n` + + `**XP Cooldown:** ${xpCooldown}s\n` + + `**Level-up Message:** \`${message}\`\n\n` + + `Use \`/level dashboard\` to adjust any of these settings at any time.`, + color: 'success', + }), + ], + }); + } + } catch (error) { + logger.error('Level command error:', error); + await handleInteractionError(interaction, error, { + type: 'command', + commandName: 'level', + }); + } + }, +}; diff --git a/src/commands/Leveling/levelconfig.js b/src/commands/Leveling/levelconfig.js deleted file mode 100644 index 8e345f5ceb..0000000000 --- a/src/commands/Leveling/levelconfig.js +++ /dev/null @@ -1,216 +0,0 @@ - - - - - -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { checkUserPermissions, botHasPermission } from '../../utils/permissionGuard.js'; -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') - .setDescription('Configure the leveling system') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand((subcommand) => - subcommand - .setName('toggle') - .setDescription('Enable or disable the leveling system') - .addBooleanOption((option) => - option - .setName('enabled') - .setDescription('Whether to enable or disable the leveling system') - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('channel') - .setDescription('Set the level up notification channel') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('The channel to send level up notifications to') - .addChannelTypes(ChannelType.GuildText) - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('xp') - .setDescription('Set the XP range per message') - .addIntegerOption((option) => - option - .setName('min') - .setDescription('Minimum XP per message') - .setRequired(true) - .setMinValue(1) - ) - .addIntegerOption((option) => - option - .setName('max') - .setDescription('Maximum XP per message') - .setRequired(true) - .setMinValue(1) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName('message') - .setDescription('Set the level up message') - .addStringOption((option) => - option - .setName('text') - .setDescription('Use {user} for the username and {level} for the level') - .setRequired(true) - ) - ) - .setDMPermission(false), - category: 'Leveling', - - - - - - - - async execute(interaction, config, client) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`LevelConfig defer failed for interaction ${interaction.id}`); - return; - } - - - const hasPermission = await checkUserPermissions( - interaction, - PermissionFlagsBits.ManageGuild, - 'You need ManageGuild permission to use this command.' - ); - if (!hasPermission) return; - - const subcommand = interaction.options.getSubcommand(); - const levelingConfig = await getLevelingConfig(client, interaction.guildId); - - switch (subcommand) { - case 'toggle': { - const enabled = interaction.options.getBoolean('enabled'); - levelingConfig.enabled = enabled; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Leveling System Updated', - description: `The leveling system has been **${enabled ? 'enabled' : 'disabled'}**.`, - color: 'success' - }) - ] - }); - - logger.info(`Leveling system ${enabled ? 'enabled' : 'disabled'} in guild ${interaction.guildId}`); - break; - } - - case 'channel': { - const channel = interaction.options.getChannel('channel'); - - - if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { - throw new TitanBotError( - 'Bot missing permissions in the specified channel', - ErrorTypes.PERMISSION, - 'I need SendMessages and EmbedLinks permissions in that channel.' - ); - } - - levelingConfig.levelUpChannel = channel.id; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Level Up Channel Set', - description: `Level up notifications will now be sent in ${channel}.`, - color: 'success' - }) - ] - }); - - logger.info(`Level up channel set to ${channel.id} in guild ${interaction.guildId}`); - break; - } - - case 'xp': { - const min = interaction.options.getInteger('min'); - const max = interaction.options.getInteger('max'); - - if (min > max) { - throw new TitanBotError( - 'Invalid XP range configuration', - ErrorTypes.VALIDATION, - 'The minimum XP cannot be greater than the maximum XP.' - ); - } - - levelingConfig.xpRange = { min, max }; - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… XP Range Updated', - description: `XP per message is now between **${min}** and **${max}**.`, - color: 'success' - }) - ] - }); - - logger.info(`XP range set to ${min}-${max} in guild ${interaction.guildId}`); - break; - } - - case 'message': { - const text = interaction.options.getString('text'); - - - if (!text.includes('{user}') && !text.includes('{level}')) { - logger.warn(`Message template missing placeholders in guild ${interaction.guildId}`); - } - - levelingConfig.levelUpMessage = text; - - await saveLevelingConfig(client, interaction.guildId, levelingConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'โœ… Level Up Message Updated', - description: `The level up message has been updated.\n\n**Preview:** ${text.replace('{user}', '@user').replace('{level}', '5')}`, - color: 'success' - }) - ] - }); - - logger.info(`Level up message updated in guild ${interaction.guildId}`); - break; - } - } - } catch (error) { - logger.error('LevelConfig command error:', error); - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'levelconfig' - }); - } - } -}; - - diff --git a/src/commands/Leveling/modules/level_dashboard.js b/src/commands/Leveling/modules/level_dashboard.js new file mode 100644 index 0000000000..30b58c2c2e --- /dev/null +++ b/src/commands/Leveling/modules/level_dashboard.js @@ -0,0 +1,562 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + 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 { getLevelingConfig, saveLevelingConfig } from '../../../services/leveling.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild) { + const channel = cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'; + const xpMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; + const xpMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; + const cooldown = cfg.xpCooldown ?? 60; + const rawMsg = cfg.levelUpMessage || '{user} has leveled up to level {level}!'; + const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + + return new EmbedBuilder() + .setTitle('๐Ÿ“Š Leveling System Dashboard') + .setDescription(`Manage leveling settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Level-up Channel', value: channel, inline: true }, + { name: 'โš™๏ธ System Status', value: cfg.enabled ? 'โœ… **Enabled**' : 'โŒ **Disabled**', inline: true }, + { name: '๐Ÿ“ฃ Announcements', value: cfg.announceLevelUp !== false ? 'โœ… **Enabled**' : 'โŒ **Disabled**', inline: true }, + { name: '๐ŸŽฒ XP per Message', value: `\`${xpMin} โ€“ ${xpMax}\``, inline: true }, + { name: 'โฑ๏ธ XP Cooldown', value: `\`${cooldown}s\``, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ’ฌ Level-up Message', value: msgPreview, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`level_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Level-up Channel') + .setDescription('Set the channel where level-up notifications are sent') + .setValue('channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Level-up Message') + .setDescription('Customise the message shown when a user levels up') + .setValue('message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set XP Range') + .setDescription('Set the minimum and maximum XP rewarded per message') + .setValue('xp_range') + .setEmoji('๐ŸŽฒ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set XP Cooldown') + .setDescription('Seconds between XP grants for the same user') + .setValue('xp_cooldown') + .setEmoji('โฑ๏ธ'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const announceOn = cfg.announceLevelUp !== false; + const systemOn = cfg.enabled !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`level_cfg_toggle_announce_${guildId}`) + .setLabel('Announcements') + .setStyle(announceOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ“ฃ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`level_cfg_toggle_system_${guildId}`) + .setLabel('Leveling') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('โšก') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId) { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + }).catch(() => {}); +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const cfg = await getLevelingConfig(client, guildId); + + if (!cfg.configured) { + throw new TitanBotError( + 'Leveling system not configured', + ErrorTypes.CONFIGURATION, + 'The leveling system has not been set up yet. Run `/level setup` first to configure it.', + ); + } + + const selectMenu = buildSelectMenu(guildId); + const selectRow = new ActionRowBuilder().addComponents(selectMenu); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild)], + components: [buildButtonRow(cfg, guildId), selectRow], + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `level_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'channel': + await handleChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'message': + await handleMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'xp_range': + await handleXpRange(selectInteraction, interaction, cfg, guildId, client); + break; + case 'xp_cooldown': + await handleXpCooldown(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Leveling config validation error: ${error.message}`); + } else { + logger.error('Unexpected leveling dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for the two toggle buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `level_cfg_toggle_announce_${guildId}` || + i.customId === `level_cfg_toggle_system_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + const isAnnounce = btnInteraction.customId === `level_cfg_toggle_announce_${guildId}`; + + if (isAnnounce) { + cfg.announceLevelUp = cfg.announceLevelUp === false; + await saveLevelingConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Announcements Updated', + `Level-up announcements are now **${cfg.announceLevelUp ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else { + const wasEnabled = cfg.enabled !== false; + cfg.enabled = !wasEnabled; + await saveLevelingConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… System Updated', + `The leveling system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.${!cfg.enabled ? '\nUsers will not earn XP until the system is re-enabled.' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await refreshDashboard(interaction, cfg, guildId); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + + btnCollector.on('end', async (collected, reason) => { + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in level_dashboard:', error); + throw new TitanBotError( + `Level dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the leveling dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Change Level-up Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('level_cfg_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Change Level-up Channel') + .setDescription( + `**Current:** ${cfg.levelUpChannel ? `<#${cfg.levelUpChannel}>` : '`Not set`'}\n\nSelect the channel where level-up notifications will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'level_cfg_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **SendMessages** and **EmbedLinks** permissions in ${channel} to send level-up notifications.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.levelUpChannel = channel.id; + await saveLevelingConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Channel Updated', + `Level-up notifications will now be sent in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Level-up Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('level_cfg_message') + .setTitle('Edit Level-up Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message ({user} and {level} are available)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.levelUpMessage || '{user} has leveled up to level {level}!') + .setMaxLength(500) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('{user} has leveled up to level {level}!'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newMessage = submitted.fields.getTextInputValue('message_input').trim(); + + if (!newMessage.includes('{user}') && !newMessage.includes('{level}')) { + logger.warn( + `Level-up message set without {user} or {level} placeholders in guild ${guildId}`, + ); + } + + cfg.levelUpMessage = newMessage; + await saveLevelingConfig(client, guildId, cfg); + + const preview = newMessage.replace('{user}', '@User').replace('{level}', '5'); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Message Updated', + `Level-up message saved.\n**Preview:** ${preview}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Set XP Range โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleXpRange(selectInteraction, rootInteraction, cfg, guildId, client) { + const currentMin = cfg.xpRange?.min ?? cfg.xpPerMessage?.min ?? 15; + const currentMax = cfg.xpRange?.max ?? cfg.xpPerMessage?.max ?? 25; + + const modal = new ModalBuilder() + .setCustomId('level_cfg_xp_range') + .setTitle('Set XP Range per Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('xp_min_input') + .setLabel('Minimum XP (1โ€“500)') + .setStyle(TextInputStyle.Short) + .setValue(String(currentMin)) + .setMaxLength(3) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('15'), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('xp_max_input') + .setLabel('Maximum XP (1โ€“500)') + .setStyle(TextInputStyle.Short) + .setValue(String(currentMax)) + .setMaxLength(3) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('25'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_xp_range' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const rawMin = submitted.fields.getTextInputValue('xp_min_input').trim(); + const rawMax = submitted.fields.getTextInputValue('xp_max_input').trim(); + const newMin = parseInt(rawMin, 10); + const newMax = parseInt(rawMax, 10); + + if (isNaN(newMin) || isNaN(newMax) || newMin < 1 || newMax < 1 || newMin > 500 || newMax > 500) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid Values', 'Both XP values must be whole numbers between **1** and **500**.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (newMin > newMax) { + await submitted.reply({ + embeds: [ + errorEmbed('Invalid Range', 'Minimum XP cannot be greater than maximum XP.'), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.xpRange = { min: newMin, max: newMax }; + await saveLevelingConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… XP Range Updated', + `Users will now earn between **${newMin}** and **${newMax}** XP per message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Set XP Cooldown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleXpCooldown(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('level_cfg_cooldown') + .setTitle('Set XP Cooldown') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('cooldown_input') + .setLabel('Cooldown in seconds (0โ€“3600)') + .setStyle(TextInputStyle.Short) + .setValue(String(cfg.xpCooldown ?? 60)) + .setMaxLength(4) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('60'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'level_cfg_cooldown' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const raw = submitted.fields.getTextInputValue('cooldown_input').trim(); + const newCooldown = parseInt(raw, 10); + + if (isNaN(newCooldown) || newCooldown < 0 || newCooldown > 3600) { + await submitted.reply({ + embeds: [ + errorEmbed( + 'Invalid Value', + 'Cooldown must be a whole number between **0** and **3600** seconds.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.xpCooldown = newCooldown; + await saveLevelingConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Cooldown Updated', + `XP cooldown set to **${newCooldown} second${newCooldown !== 1 ? 's' : ''}**.${newCooldown === 0 ? '\n> โš ๏ธ A cooldown of 0 means XP is granted on every message.' : ''}`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + diff --git a/src/commands/Logging/logging.js b/src/commands/Logging/logging.js new file mode 100644 index 0000000000..6009f6b8e7 --- /dev/null +++ b/src/commands/Logging/logging.js @@ -0,0 +1,118 @@ +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import dashboard from './modules/logging_dashboard.js'; +import setchannel from './modules/logging_setchannel.js'; +import filter from './modules/logging_filter.js'; + +export default { + data: new SlashCommandBuilder() + .setName('logging') + .setDescription('Manage audit logging for this server.') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('dashboard') + .setDescription('Open the interactive logging dashboard โ€” view status and toggle event categories.'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('setchannel') + .setDescription('Set the audit log channel for this server.') + .addChannelOption((option) => + option + .setName('channel') + .setDescription('The text channel for audit logs.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(false), + ) + .addBooleanOption((option) => + option + .setName('disable') + .setDescription('Set to True to disable audit logging entirely.') + .setRequired(false), + ), + ) + .addSubcommandGroup((group) => + group + .setName('filter') + .setDescription('Manage the log ignore list (users and channels to skip).') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add a user or channel to the log ignore list.') + .addStringOption((option) => + option + .setName('type') + .setDescription('Whether to ignore a user or channel.') + .setRequired(true) + .addChoices( + { name: 'User', value: 'user' }, + { name: 'Channel', value: 'channel' }, + ), + ) + .addStringOption((option) => + option + .setName('id') + .setDescription('The ID of the user or channel to ignore.') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove a user or channel from the log ignore list.') + .addStringOption((option) => + option + .setName('type') + .setDescription('Whether this is a user or channel.') + .setRequired(true) + .addChoices( + { name: 'User', value: 'user' }, + { name: 'Channel', value: 'channel' }, + ), + ) + .addStringOption((option) => + option + .setName('id') + .setDescription('The ID of the user or channel to remove from the ignore list.') + .setRequired(true), + ), + ), + ), + + async execute(interaction, config, client) { + try { + // setchannel and filter both need a reply deferred before their logic runs + const subcommandGroup = interaction.options.getSubcommandGroup(false); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'dashboard') { + return await dashboard.execute(interaction, config, client); + } + + await InteractionHelper.safeDefer(interaction); + + if (subcommand === 'setchannel') { + return await setchannel.execute(interaction, config, client); + } + + if (subcommandGroup === 'filter') { + return await filter.execute(interaction, config, client); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Unknown Subcommand', 'This subcommand is not recognised.')], + }); + } catch (error) { + logger.error('logging command error:', error); + await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'An unexpected error occurred.')], + ephemeral: true, + }).catch(() => {}); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_dashboard.js b/src/commands/Logging/modules/logging_dashboard.js new file mode 100644 index 0000000000..a2d8e18dc1 --- /dev/null +++ b/src/commands/Logging/modules/logging_dashboard.js @@ -0,0 +1,135 @@ +import { EmbedBuilder, PermissionsBitField } from 'discord.js'; +import { getColor } from '../../../config/bot.js'; +import { getGuildConfig } from '../../../services/guildConfig.js'; +import { getLoggingStatus, EVENT_TYPES } from '../../../services/loggingService.js'; +import { createLoggingDashboardComponents } from '../../../utils/loggingUi.js'; +import { errorEmbed } from '../../../utils/embeds.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((acc, eventType) => { + const [category] = eventType.split('.'); + if (!acc[category]) acc[category] = []; + acc[category].push(eventType); + return acc; +}, {}); + +const CATEGORY_MAP = [ + ['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'], +]; + +function getCategoryStatus(enabledEvents, category, auditEnabled) { + 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); +} + +async function formatChannelMention(guild, id) { + if (!id) return '`Not configured`'; + const channel = guild.channels.cache.get(id) ?? await guild.channels.fetch(id).catch(() => null); + return channel ? channel.toString() : `โš ๏ธ Missing (${id})`; +} + +export async function buildLoggingDashboardView(interaction, client) { + const guildConfig = await getGuildConfig(client, interaction.guildId); + const loggingStatus = await getLoggingStatus(client, interaction.guildId); + + const auditEnabled = Boolean(loggingStatus.enabled); + const auditChannel = await formatChannelMention( + interaction.guild, + loggingStatus.channelId || guildConfig.logging?.channelId || guildConfig.logChannelId, + ); + const lifecycleChannel = await formatChannelMention(interaction.guild, guildConfig.ticketLogsChannelId); + const transcriptChannel = await formatChannelMention(interaction.guild, guildConfig.ticketTranscriptChannelId); + + const ignoredUsers = guildConfig.logIgnore?.users || []; + const ignoredChannels = guildConfig.logIgnore?.channels || []; + + const categoryLines = CATEGORY_MAP.map(([key, label]) => { + const on = getCategoryStatus(loggingStatus.enabledEvents, key, auditEnabled); + return `${on ? 'โœ…' : 'โŒ'} ${label}`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ“‹ Logging Dashboard') + .setDescription(`Manage audit logging for **${interaction.guild.name}**. Category buttons toggle logging instantly.`) + .setColor(auditEnabled ? getColor('success') : getColor('warning')) + .addFields( + { + name: '๐Ÿงพ Audit Logging', + value: auditEnabled ? 'โœ… Enabled' : 'โŒ Disabled', + inline: true, + }, + { + name: '\u200B', + value: '\u200B', + inline: true, + }, + { + name: '\u200B', + value: '\u200B', + inline: true, + }, + { + name: '๐Ÿ“ก Log Channels', + value: [ + `**Audit:** ${auditChannel}`, + `**Ticket Logs:** ${lifecycleChannel}`, + `**Ticket Transcripts:** ${transcriptChannel}`, + ].join('\n'), + inline: false, + }, + { + name: '๐Ÿ“‹ Event Categories', + value: categoryLines, + inline: false, + }, + { + name: '๐Ÿงน Ignore Filters', + value: `Users: **${ignoredUsers.length}**\nChannels: **${ignoredChannels.length}**`, + inline: true, + }, + { + name: '๐Ÿ•’ Last Refresh', + value: ``, + inline: true, + }, + ) + .setFooter({ text: 'Use /logging setchannel to configure the audit channel โ€ข /ticket setup or /ticket dashboard to configure ticket channels' }) + .setTimestamp(); + + const components = createLoggingDashboardComponents(loggingStatus.enabledEvents, auditEnabled); + return { embed, components }; +} + +export default { + async execute(interaction, config, client) { + try { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to view the logging dashboard.')], + }); + } + + await InteractionHelper.safeDefer(interaction); + const { embed, components } = await buildLoggingDashboardView(interaction, client); + await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); + } catch (error) { + logger.error('logging_dashboard error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Dashboard Error', 'Failed to load the logging dashboard.')], + }); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_filter.js b/src/commands/Logging/modules/logging_filter.js new file mode 100644 index 0000000000..a75aa9c9bf --- /dev/null +++ b/src/commands/Logging/modules/logging_filter.js @@ -0,0 +1,99 @@ +import { PermissionsBitField } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to manage log filters.')], + }); + } + + if (!client.db) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Database not initialized.')], + }); + } + + const subcommand = interaction.options.getSubcommand(); + const type = interaction.options.getString('type'); + const entityId = interaction.options.getString('id'); + const guildId = interaction.guildId; + + const currentConfig = await getGuildConfig(client, guildId); + if (!currentConfig.logIgnore) { + currentConfig.logIgnore = { users: [], channels: [] }; + } + + let targetArray; + let entityType; + let entityName; + + if (type === 'user') { + targetArray = currentConfig.logIgnore.users; + entityType = 'User'; + const member = await interaction.guild.members.fetch(entityId).catch(() => null); + entityName = member ? member.user.tag : `ID: ${entityId}`; + } else if (type === 'channel') { + targetArray = currentConfig.logIgnore.channels; + entityType = 'Channel'; + const channel = interaction.guild.channels.cache.get(entityId); + entityName = channel ? `#${channel.name}` : `ID: ${entityId}`; + } else { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Invalid Type', "Choose `user` or `channel`.")], + }); + } + + let successMessage; + + if (subcommand === 'add') { + if (targetArray.includes(entityId)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Already Filtered', `${entityType} **${entityName}** is already on the ignore list.`)], + }); + } + targetArray.push(entityId); + successMessage = `${entityType} **${entityName}** added to the log ignore list. Events from them will not be logged.`; + } else if (subcommand === 'remove') { + const index = targetArray.indexOf(entityId); + if (index === -1) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Not Filtered', `${entityType} **${entityName}** was not on the ignore list.`)], + }); + } + targetArray.splice(index, 1); + successMessage = `${entityType} **${entityName}** removed from the log ignore list. Events will now be logged.`; + } else { + return; + } + + try { + await setGuildConfig(client, guildId, currentConfig); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Log Filter Updated', + target: `Filter ${subcommand}`, + executor: `${interaction.user.tag} (${interaction.user.id})`, + metadata: { entityType, loggingEnabled: currentConfig.enableLogging }, + }, + }); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Filter Updated', successMessage)], + }); + } catch (error) { + logger.error('logging filter error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Failed to save the filter change.')], + }); + } + }, +}; diff --git a/src/commands/Logging/modules/logging_setchannel.js b/src/commands/Logging/modules/logging_setchannel.js new file mode 100644 index 0000000000..5ce85c97d7 --- /dev/null +++ b/src/commands/Logging/modules/logging_setchannel.js @@ -0,0 +1,88 @@ +import { PermissionsBitField, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { logEvent } from '../../../utils/moderation.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Administrator** permissions to change log channels.')], + }); + } + + if (!client.db) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Database Error', 'Database not initialized.')], + }); + } + + const guildId = interaction.guildId; + const currentConfig = await getGuildConfig(client, guildId); + + const logChannel = interaction.options.getChannel('channel'); + const disableLogging = interaction.options.getBoolean('disable'); + + try { + if (disableLogging) { + currentConfig.logChannelId = null; + currentConfig.enableLogging = false; + currentConfig.logging = { + ...(currentConfig.logging || {}), + enabled: false, + channelId: null, + }; + await setGuildConfig(client, guildId, currentConfig); + return InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Logging Disabled ๐Ÿšซ', 'Audit logging has been disabled for this server.')], + }); + } + + if (logChannel) { + const perms = logChannel.permissionsFor(interaction.guild.members.me); + if (!perms.has(PermissionsBitField.Flags.SendMessages) || !perms.has(PermissionsBitField.Flags.EmbedLinks)) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Bot Permission Error', `I need **Send Messages** and **Embed Links** permissions in ${logChannel}.`)], + }); + } + + currentConfig.logChannelId = logChannel.id; + currentConfig.enableLogging = true; + currentConfig.logging = { + ...(currentConfig.logging || {}), + enabled: true, + channelId: logChannel.id, + }; + await setGuildConfig(client, guildId, currentConfig); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Log Channel Set ๐Ÿ“', `Audit logs will be sent to ${logChannel}.`)], + }); + + await logEvent({ + client, + guild: interaction.guild, + event: { + action: 'Log Channel Activated', + target: logChannel.toString(), + executor: `${interaction.user.tag} (${interaction.user.id})`, + reason: `Logging channel set by ${interaction.user}`, + metadata: { channelId: logChannel.id, moderatorId: interaction.user.id, loggingEnabled: true }, + }, + }); + return; + } + + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('No Option Provided', 'Provide one of: `channel` or `disable: True`.\n\n> Ticket transcript and logs channels are managed via `/ticket setup` or `/ticket dashboard`.')], + }); + } catch (error) { + logger.error('logging setchannel error:', error); + await InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Configuration Error', 'Could not save the configuration.')], + }); + } + }, +}; diff --git a/src/commands/Reaction_roles/rdelete.js b/src/commands/Reaction_roles/rdelete.js deleted file mode 100644 index 29659d5ab0..0000000000 --- a/src/commands/Reaction_roles/rdelete.js +++ /dev/null @@ -1,134 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { deleteReactionRoleMessage, getReactionRoleMessage } from '../../services/reactionRoleService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rdelete') - .setDescription('Delete a reaction role message') - .addStringOption(option => - option.setName('message_id') - .setDescription('The ID of the reaction role message to delete') - .setRequired(true) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - const messageId = interaction.options.getString('message_id'); - - logger.info(`Reaction role deletion requested by ${interaction.user.tag} for message ${messageId} in guild ${interaction.guild.name}`); - - - if (!/^\d{17,19}$/.test(messageId)) { - throw createError( - `Invalid message ID format: ${messageId}`, - ErrorTypes.VALIDATION, - 'Please provide a valid message ID (17-19 digits).', - { messageId } - ); - } - - - const reactionRoleData = await getReactionRoleMessage(interaction.client, interaction.guildId, messageId); - - if (!reactionRoleData) { - throw createError( - `Reaction role message not found: ${messageId}`, - ErrorTypes.CONFIGURATION, - 'No reaction role message found with that ID in this server.', - { messageId, guildId: interaction.guildId } - ); - } - - - let messageDeleted = false; - try { - const channel = await interaction.guild.channels.fetch(reactionRoleData.channelId).catch(() => null); - - if (channel) { - const message = await channel.messages.fetch(messageId).catch(() => null); - if (message) { - await message.delete(); - messageDeleted = true; - logger.info(`Deleted reaction role Discord message ${messageId} from channel ${channel.name}`); - } else { - logger.warn(`Discord message ${messageId} not found in channel ${channel.name}, will only remove from database`); - } - } else { - logger.warn(`Channel ${reactionRoleData.channelId} not found, will only remove from database`); - } - } catch (deleteError) { - logger.warn(`Failed to delete Discord message ${messageId}:`, deleteError); - - } - - - await deleteReactionRoleMessage(interaction.client, interaction.guildId, messageId); - - logger.info(`Reaction role message ${messageId} deleted from database by ${interaction.user.tag}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_DELETE, - data: { - description: `Reaction role message deleted by ${interaction.user.tag}`, - userId: interaction.user.id, - fields: [ - { - name: '๐Ÿ—‘๏ธ Message ID', - value: messageId, - inline: true - }, - { - name: '๐Ÿ“ Channel', - value: `<#${reactionRoleData.channelId}>`, - inline: true - }, - { - name: '๐Ÿ“Š Status', - value: messageDeleted ? 'โœ… Message deleted' : 'โš ๏ธ Database only', - inline: true - }, - { - name: '๐Ÿท๏ธ Roles', - value: `${Array.isArray(reactionRoleData.roles) ? reactionRoleData.roles.length : Object.keys(reactionRoleData.roles || {}).length} roles removed`, - inline: false - } - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role deletion:', logError); - } - - - const responseMessage = messageDeleted - ? 'โœ… 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 InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', responseMessage)] - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rdelete' - }); - } - } -}; - - diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js new file mode 100644 index 0000000000..cf84511a24 --- /dev/null +++ b/src/commands/Reaction_roles/reactroles.js @@ -0,0 +1,1096 @@ +import { getColor } from '../../config/bot.js'; +import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder } from 'discord.js'; +import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError, createError, TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { createReactionRoleMessage, hasDangerousPermissions, getAllReactionRoleMessages, deleteReactionRoleMessage } from '../../services/reactionRoleService.js'; +import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; + +export default { + data: new SlashCommandBuilder() + .setName('reactroles') + .setDescription('Manage reaction role assignments') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator) + .addSubcommand(subcommand => + subcommand + .setName('setup') + .setDescription('Set up a new reaction role panel') + .addChannelOption(option => + option.setName('channel') + .setDescription('The channel to send the reaction role message to') + .setRequired(true) + ) + .addStringOption(option => + option.setName('title') + .setDescription('Title for the reaction role panel') + .setRequired(true) + ) + .addStringOption(option => + option.setName('description') + .setDescription('Description for the reaction role panel') + .setRequired(true) + ) + .addRoleOption(option => + option.setName('role1') + .setDescription('First role to add') + .setRequired(true) + ) + .addRoleOption(option => + option.setName('role2') + .setDescription('Second role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role3') + .setDescription('Third role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role4') + .setDescription('Fourth role to add') + .setRequired(false) + ) + .addRoleOption(option => + option.setName('role5') + .setDescription('Fifth role to add') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('dashboard') + .setDescription('Manage and configure your reaction role panels') + .addStringOption(option => + option + .setName('panel') + .setDescription('Select a reaction role panel to manage') + .setRequired(false) + .setAutocomplete(true) + ) + ), + + async execute(interaction) { + const subcommand = interaction.options.getSubcommand(); + + try { + if (subcommand === 'setup') { + await handleSetup(interaction); + } else if (subcommand === 'dashboard') { + const selectedPanelId = interaction.options.getString('panel'); + await handleDashboard(interaction, selectedPanelId); + } + } catch (error) { + await handleInteractionError(interaction, error, { + type: 'command', + commandName: 'reactroles', + subcommand: subcommand + }); + } + }, + + async autocomplete(interaction) { + if (interaction.commandName !== 'reactroles') return; + if (interaction.options.getSubcommand() !== 'dashboard') return; + + try { + const guildId = interaction.guild.id; + const client = interaction.client; + + let panels; + try { + panels = await getAllReactionRoleMessages(client, guildId); + } catch (dbError) { + // If database query fails, just respond with empty + await interaction.respond([]).catch(() => {}); + return; + } + + if (!panels || panels.length === 0) { + await interaction.respond([]).catch(() => {}); + return; + } + + const guild = interaction.guild; + + // Filter out panels whose messages no longer exist and clean up stale data + const validPanels = []; + for (const panel of panels) { + // Validate panel structure + if (!panel.messageId || !panel.channelId) { + continue; + } + + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + await interaction.respond([]).catch(() => {}); + return; + } + + const choices = await Promise.all( + validPanels.slice(0, 25).map(async panel => { + try { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) return null; + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) return null; + + const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const channelName = channel?.name ?? 'unknown'; + + return { + name: `${title} (${channelName})`.substring(0, 100), + value: panel.messageId + }; + } catch (e) { + return null; + } + }) + ); + + const validChoices = choices.filter(c => c !== null); + await interaction.respond(validChoices).catch(() => {}); + } catch (error) { + await interaction.respond([]).catch(() => {}); + } + } +}; + +// โ”€โ”€โ”€ Setup Subcommand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleSetup(interaction) { + const deferSuccess = await InteractionHelper.safeDefer(interaction); + if (!deferSuccess) return; + + logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); + + const channel = interaction.options.getChannel('channel'); + const title = interaction.options.getString('title'); + const description = interaction.options.getString('description'); + + // Validate channel type + if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { + throw createError( + `Invalid channel type: ${channel.type}`, + ErrorTypes.VALIDATION, + 'Please select a text or announcement channel.', + { channelType: channel.type } + ); + } + + // Check bot permissions + if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { + throw createError( + 'Bot missing ManageRoles permission', + ErrorTypes.PERMISSION, + 'I need the "Manage Roles" permission to set up reaction roles.', + { permission: 'ManageRoles' } + ); + } + + if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { + throw createError( + `Bot cannot send messages in ${channel.name}`, + ErrorTypes.PERMISSION, + `I don't have permission to send messages in ${channel}.`, + { channelId: channel.id } + ); + } + + // Check if guild has reached max of 5 panels + const existingPanels = await getAllReactionRoleMessages(interaction.client, interaction.guildId); + if (existingPanels && existingPanels.length >= 5) { + throw createError( + 'Panel limit reached', + ErrorTypes.VALIDATION, + 'Your guild has reached the maximum of 5 reaction role panels. Delete an existing panel to create a new one.', + { maxPanels: 5, currentPanels: existingPanels.length } + ); + } + + // Collect and validate roles + const roles = []; + const roleValidationErrors = []; + + for (let i = 1; i <= 5; i++) { + const role = interaction.options.getRole(`role${i}`); + if (role) { + if (role.position >= interaction.guild.members.me.roles.highest.position) { + roleValidationErrors.push(`**${role.name}** - My bot's role is positioned lower than this role in your server's role hierarchy and cannot assign it`); + continue; + } + + if (hasDangerousPermissions(role)) { + roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); + continue; + } + + if (role.managed) { + roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); + continue; + } + + if (role.id === interaction.guild.id) { + roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); + continue; + } + + roles.push(role); + } + } + + if (roleValidationErrors.length > 0) { + const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; + + if (roles.length === 0) { + throw createError( + 'No valid roles provided', + ErrorTypes.VALIDATION, + errorMsg, + { errors: roleValidationErrors } + ); + } + + await interaction.followUp({ + embeds: [warningEmbed('Role Validation Warning', errorMsg)], + ephemeral: true + }); + } + + if (roles.length < 1) { + throw createError( + 'No roles provided', + ErrorTypes.VALIDATION, + 'You must provide at least one valid role.', + {} + ); + } + + // Create the reaction role message + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('reaction_roles') + .setPlaceholder('Select your roles') + .setMinValues(0) + .setMaxValues(roles.length) + .addOptions( + roles.map(role => ({ + label: role.name, + description: `Add/remove the ${role.name} role`, + value: role.id, + emoji: '๐ŸŽญ' + })) + ) + ); + + const panelEmbed = new EmbedBuilder() + .setTitle(title) + .setDescription(description) + .setColor(getColor('info')) + .addFields({ + name: 'Available Roles', + value: roles.map(role => `โ€ข ${role}`).join('\n') + }) + .setFooter({ text: 'Select roles from the dropdown menu below' }); + + const message = await channel.send({ + embeds: [panelEmbed], + components: [row] + }); + + const roleIds = roles.map(role => role.id); + await createReactionRoleMessage( + interaction.client, + interaction.guildId, + channel.id, + message.id, + roleIds + ); + + logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); + + try { + await logEvent({ + client: interaction.client, + guildId: interaction.guildId, + eventType: EVENT_TYPES.REACTION_ROLE_CREATE, + data: { + description: `Reaction role panel created by ${interaction.user.tag}`, + userId: interaction.user.id, + channelId: channel.id, + fields: [ + { + name: '๐Ÿ“ Title', + value: title, + inline: false + }, + { + name: '๐Ÿ“ Channel', + value: channel.toString(), + inline: true + }, + { + name: '๐Ÿ“Š Roles', + value: `${roles.length} roles`, + inline: true + }, + { + name: '๐Ÿท๏ธ Role List', + value: roles.map(r => r.toString()).join(', '), + inline: false + }, + { + name: '๐Ÿ”— Message Link', + value: message.url, + inline: false + } + ] + } + }); + } catch (logError) { + logger.warn('Failed to log reaction role creation:', logError); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [successEmbed('Success', `โœ… Reaction role panel created in ${channel}!\n\n${message.url}`)] + }); +} + +// โ”€โ”€โ”€ Dashboard Subcommand โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDashboard(interaction, selectedPanelId) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); + if (!deferSuccess) return; + + const guildId = interaction.guild.id; + const guild = interaction.guild; + const client = interaction.client; + + let panels = await getAllReactionRoleMessages(client, guildId); + + if (!panels || panels.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'No Panels Found', + 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', + ), + ], + }); + } + + // Filter out panels whose messages no longer exist + const validPanels = []; + for (const panel of panels) { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'No Valid Panels Found', + 'No reaction role panels exist yet. Use `/reactroles setup` to create one.', + ), + ], + }); + } + + // If a panel was selected, use it. Otherwise, pick a random one. + let activePanelData = null; + if (selectedPanelId) { + activePanelData = validPanels.find(p => p.messageId === selectedPanelId); + if (!activePanelData) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Panel Not Found', + 'That panel no longer exists or has been deleted.', + ), + ], + }); + } + } else { + // Pick a random panel from valid panels + activePanelData = validPanels[Math.floor(Math.random() * validPanels.length)]; + } + + const discordMsg = await fetchPanelDiscordMessage(guild, activePanelData); + await showPanelDashboard(interaction, activePanelData, discordMsg, guildId, guild); + + let rootInteraction = interaction; + const collector = interaction.channel.createMessageComponentCollector({ + filter: i => + i.user.id === interaction.user.id && + (i.customId === `rr_opts_${guildId}`), + time: 600_000, + }); + + const buttonCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `rr_edit_text_${guildId}` || + i.customId === `rr_delete_${guildId}`), + time: 600_000, + }); + + collector.on('collect', async ci => { + try { + if (ci.customId === `rr_opts_${guildId}`) { + const option = ci.values[0]; + switch (option) { + case 'add_role': + await handleAddRole(ci, rootInteraction, activePanelData, guildId, guild, client); + break; + case 'remove_role': + await handleRemoveRole(ci, rootInteraction, activePanelData, validPanels, guildId, guild, client); + break; + } + } + } catch (error) { + logger.error('Error in reactroles dashboard 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(() => {}); + } + }); + + buttonCollector.on('collect', async btnInteraction => { + try { + if (btnInteraction.customId === `rr_edit_text_${guildId}`) { + await handleEditText(btnInteraction, rootInteraction, activePanelData, guildId, guild, client); + } else if (btnInteraction.customId === `rr_delete_${guildId}`) { + await handleDeletePanel(btnInteraction, rootInteraction, activePanelData, validPanels, guildId, guild, client, collector, buttonCollector); + } + } catch (error) { + logger.error('Error in reactroles button collector:', error); + const msg = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred.' + : 'An unexpected error occurred.'; + if (!btnInteraction.replied && !btnInteraction.deferred) await btnInteraction.deferUpdate().catch(() => {}); + await btnInteraction + .followUp({ embeds: [errorEmbed('Error', msg)], flags: MessageFlags.Ephemeral }) + .catch(() => {}); + } + }); + + collector.on('end', async (_, reason) => { + buttonCollector.stop(); + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฑ๏ธ Dashboard Timeout') + .setDescription('This dashboard session has timed out due to inactivity (10 minutes).\n\nTo continue managing your reaction roles, please run `/reactroles dashboard` again.') + .setColor(getColor('warning')); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [] + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Discord Message Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function fetchPanelDiscordMessage(guild, panelData) { + try { + const channel = guild.channels.cache.get(panelData.channelId); + if (!channel) return null; + return await channel.messages.fetch(panelData.messageId).catch(() => null); + } catch { + return null; + } +} + +async function rebuildLivePanelMessage(guild, panelData) { + try { + const channel = guild.channels.cache.get(panelData.channelId); + if (!channel) return; + const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); + if (!msg || !msg.embeds[0]) return; + + const roleObjects = panelData.roles + .map(id => guild.roles.cache.get(id)) + .filter(Boolean); + + if (roleObjects.length === 0) return; + + const currentEmbed = msg.embeds[0]; + const updatedEmbed = EmbedBuilder.from(currentEmbed); + const fields = currentEmbed.fields.map(f => ({ name: f.name, value: f.value, inline: f.inline })); + const roleFieldIdx = fields.findIndex(f => f.name === 'Available Roles'); + const newRoleValue = roleObjects.map(r => `โ€ข ${r}`).join('\n'); + if (roleFieldIdx !== -1) { + fields[roleFieldIdx] = { name: 'Available Roles', value: newRoleValue, inline: false }; + } else { + fields.push({ name: 'Available Roles', value: newRoleValue, inline: false }); + } + updatedEmbed.setFields(fields); + + const selectRow = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('reaction_roles') + .setPlaceholder('Select your roles') + .setMinValues(0) + .setMaxValues(roleObjects.length) + .addOptions( + roleObjects.map(r => ({ + label: r.name.substring(0, 100), + description: `Add/remove the ${r.name} role`.substring(0, 100), + value: r.id, + emoji: '๐ŸŽญ', + })), + ), + ); + + await msg.edit({ embeds: [updatedEmbed], components: [selectRow] }); + } catch (error) { + logger.warn('Could not rebuild live reaction role panel:', error.message); + } +} + +// โ”€โ”€โ”€ View Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function showPanelDashboard(interaction, panelData, discordMsg, guildId, guild) { + const channel = guild.channels.cache.get(panelData.channelId); + const title = discordMsg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const roleList = + panelData.roles.length > 0 + ? panelData.roles.map(id => `<@&${id}>`).join(', ') + : '`None`'; + + const embed = new EmbedBuilder() + .setTitle('๐ŸŽญ Reaction Roles Dashboard') + .setDescription( + `**Title:** ${title}\n\nSelect an option below to modify a setting.${discordMsg ? `\n[Click Here to View Panel](${discordMsg.url})` : ''}`, + ) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ Channel', value: channel ? `<#${channel.id}>` : '`Not found`', inline: true }, + { name: '๐ŸŽญ Roles', value: `\`${panelData.roles.length} / 25\``, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿท๏ธ Role List', value: roleList, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); + + const editTextButton = new ButtonBuilder() + .setCustomId(`rr_edit_text_${guildId}`) + .setLabel('Edit Panel Text') + .setStyle(ButtonStyle.Primary) + .setEmoji('โœ๏ธ'); + + const deleteButton = new ButtonBuilder() + .setCustomId(`rr_delete_${guildId}`) + .setLabel('Delete Panel') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ'); + + const optionsSelect = new StringSelectMenuBuilder() + .setCustomId(`rr_opts_${guildId}`) + .setPlaceholder('Select an action...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Add Role') + .setDescription('Add a role to this panel (up to 25 total)') + .setValue('add_role') + .setEmoji('โž•'), + ...(panelData.roles.length > 0 ? [ + new StringSelectMenuOptionBuilder() + .setLabel('Remove Role') + .setDescription('Remove a role from this panel') + .setValue('remove_role') + .setEmoji('โž–') + ] : []) + ); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents(editTextButton, deleteButton), + new ActionRowBuilder().addComponents(optionsSelect), + ], + }); +} + +// โ”€โ”€โ”€ Edit Panel Text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleEditText(buttonInteraction, rootInteraction, panelData, guildId, guild, client) { + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + + const currentTitle = discordMsg?.embeds?.[0]?.title ?? ''; + const currentDesc = discordMsg?.embeds?.[0]?.description ?? ''; + + const modal = new ModalBuilder() + .setCustomId('rr_edit_text') + .setTitle('Edit Panel Text') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_title') + .setLabel('Title') + .setStyle(TextInputStyle.Short) + .setValue(currentTitle) + .setMaxLength(256) + .setMinLength(1) + .setRequired(true), + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_description') + .setLabel('Description') + .setStyle(TextInputStyle.Paragraph) + .setValue(currentDesc) + .setMaxLength(2048) + .setMinLength(1) + .setRequired(true), + ), + ); + + try { + await buttonInteraction.showModal(modal); + } catch (error) { + logger.error('Error showing edit text modal:', error); + await buttonInteraction.followUp({ + embeds: [errorEmbed('Error', 'Failed to show the edit panel text modal. Please try again.')], + flags: MessageFlags.Ephemeral, + }).catch(() => {}); + return; + } + + const submitted = await buttonInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'rr_edit_text' && i.user.id === buttonInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newTitle = submitted.fields.getTextInputValue('panel_title').trim(); + const newDesc = submitted.fields.getTextInputValue('panel_description').trim(); + + if (discordMsg) { + const updatedEmbed = EmbedBuilder.from(discordMsg.embeds[0]).setTitle(newTitle).setDescription(newDesc); + await discordMsg.edit({ embeds: [updatedEmbed] }).catch(err => { + logger.warn('Could not edit live panel message:', err.message); + }); + } + + await submitted.reply({ + embeds: [successEmbed('โœ… Panel Updated', 'The title and description have been updated.')], + flags: MessageFlags.Ephemeral, + }); + + const refreshedMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, refreshedMsg, guildId, guild); +} + +// โ”€โ”€โ”€ Add Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleAddRole(selectInteraction, rootInteraction, panelData, guildId, guild, client) { + await selectInteraction.deferUpdate(); + + if (panelData.roles.length >= 25) { + await selectInteraction.followUp({ + embeds: [errorEmbed('Panel Full', 'This panel already has the maximum of 25 roles.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('rr_add_role_pick') + .setPlaceholder('Select a role to add...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž• Add Role') + .setDescription( + `**Current roles:** ${panelData.roles.length}/25\n\nSelect a role to add to this panel.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'rr_add_role_pick', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + if (panelData.roles.includes(role.id)) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Already Added', `${role} is already in this panel.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.id === guild.id) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Invalid Role', 'You cannot use @everyone.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.managed) { + await roleInteraction.followUp({ + embeds: [errorEmbed('Invalid Role', 'Managed/bot roles cannot be used.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (hasDangerousPermissions(role)) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Dangerous Permissions', + 'That role has sensitive permissions (Administrator, Manage Server, etc.) and cannot be used.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + if (role.position >= guild.members.me.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + "That role is above my highest role in the hierarchy. Move my role above it first.", + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + panelData.roles.push(role.id); + const key = `reaction_roles:${guildId}:${panelData.messageId}`; + await client.db.set(key, panelData); + + await rebuildLivePanelMessage(guild, panelData); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Added', `${role} has been added to the panel.`)], + flags: MessageFlags.Ephemeral, + }); + + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Remove Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRemoveRole(selectInteraction, rootInteraction, panelData, panels, guildId, guild, client) { + await selectInteraction.deferUpdate(); + + const roleOptions = panelData.roles + .map(id => { + const role = guild.roles.cache.get(id); + return role ? { label: role.name.substring(0, 100), value: id } : null; + }) + .filter(Boolean); + + if (roleOptions.length === 0) { + await selectInteraction.followUp({ + embeds: [errorEmbed('No Valid Roles', 'The roles on this panel no longer exist in the server.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const removeSelect = new StringSelectMenuBuilder() + .setCustomId('rr_remove_role_pick') + .setPlaceholder('Select a role to remove...') + .setMaxValues(1) + .addOptions( + roleOptions.map(r => + new StringSelectMenuOptionBuilder().setLabel(r.label).setValue(r.value).setEmoji('๐ŸŽญ'), + ), + ); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('โž– Remove Role') + .setDescription('Select the role you want to remove from this panel.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(removeSelect)], + flags: MessageFlags.Ephemeral, + }); + + const removeCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'rr_remove_role_pick', + time: 60_000, + max: 1, + }); + + removeCollector.on('collect', async removeInteraction => { + await removeInteraction.deferUpdate(); + const roleId = removeInteraction.values[0]; + const role = guild.roles.cache.get(roleId); + + panelData.roles = panelData.roles.filter(id => id !== roleId); + + if (panelData.roles.length === 0) { + const channel = guild.channels.cache.get(panelData.channelId); + if (channel) { + const msg = await channel.messages.fetch(panelData.messageId).catch(() => null); + if (msg) await msg.delete().catch(() => {}); + } + await deleteReactionRoleMessage(client, guildId, panelData.messageId); + + await removeInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Role Removed', + 'That was the last role on the panel. The panel has been deleted.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + + // Remove the deleted panel from the array + const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); + if (panelIndex > -1) { + panels.splice(panelIndex, 1); + } + + if (panels.length === 0) { + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setColor(getColor('info')), + ], + components: [], + }); + } else { + // Dashboard closed after last role removed + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setColor(getColor('success')), + ], + components: [], + }); + } + } else { + const key = `reaction_roles:${guildId}:${panelData.messageId}`; + await client.db.set(key, panelData); + await rebuildLivePanelMessage(guild, panelData); + + await removeInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Role Removed', + `${role ? role.toString() : `<@&${roleId}>`} has been removed from the panel.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + } + }); + + removeCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role selected. Nothing was changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Delete Panel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeletePanel(btnInteraction, rootInteraction, panelData, panels, guildId, guild, client, collector, buttonCollector) { + const channel = guild.channels.cache.get(panelData.channelId); + const discordMsg = channel + ? await channel.messages.fetch(panelData.messageId).catch(() => null) + : null; + const title = discordMsg?.embeds?.[0]?.title ?? 'this panel'; + + const deleteModal = new ModalBuilder() + .setCustomId('rr_delete_confirm_modal') + .setTitle('Delete Reaction Role Panel') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true) + ) + ); + + await btnInteraction.showModal(deleteModal); + + const submitted = await btnInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'rr_delete_confirm_modal' && i.user.id === btnInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) { + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + return; + } + + const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); + + if (confirmation !== 'DELETE') { + await submitted.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], + flags: MessageFlags.Ephemeral, + }); + await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild); + return; + } + + await submitted.deferUpdate(); + + if (discordMsg) { + await discordMsg.delete().catch(() => {}); + } + await deleteReactionRoleMessage(client, guildId, panelData.messageId); + + try { + await logEvent({ + client, + guildId, + eventType: EVENT_TYPES.REACTION_ROLE_DELETE, + data: { + description: `Reaction role panel deleted by ${submitted.user.tag}`, + userId: submitted.user.id, + channelId: panelData.channelId, + fields: [ + { name: '๐Ÿ“‹ Panel', value: title, inline: true }, + { name: '๐Ÿ“ Channel', value: channel ? channel.toString() : 'Unknown', inline: true }, + ], + }, + }); + } catch (logErr) { + logger.warn('Failed to log reaction role deletion:', logErr); + } + + await submitted.followUp({ + embeds: [successEmbed('โœ… Panel Deleted', `**${title}** has been deleted.`)], + flags: MessageFlags.Ephemeral, + }); + + // Remove the deleted panel from the array + const panelIndex = panels.findIndex(p => p.messageId === panelData.messageId); + if (panelIndex > -1) { + panels.splice(panelIndex, 1); + } + + if (panels.length === 0) { + collector.stop(); + buttonCollector.stop(); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('No panels remain. Use `/reactroles setup` to create one.') + .setColor(getColor('info')), + ], + components: [], + }); + } else { + // Close the dashboard after deletion + collector.stop(); + buttonCollector.stop(); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‹ Reaction Roles Dashboard') + .setDescription('Panel deleted. Run `/reactroles dashboard` to manage another panel.') + .setColor(getColor('success')), + ], + components: [], + }); + } +} \ No newline at end of file diff --git a/src/commands/Reaction_roles/rlist.js b/src/commands/Reaction_roles/rlist.js deleted file mode 100644 index d556877189..0000000000 --- a/src/commands/Reaction_roles/rlist.js +++ /dev/null @@ -1,91 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, EmbedBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getAllReactionRoleMessages } from '../../services/reactionRoleService.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rlist') - .setDescription('List all reaction role messages in this server') - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - logger.info(`Reaction role list requested by ${interaction.user.tag} in guild ${interaction.guild.name}`); - - - const guildReactionRoles = await getAllReactionRoleMessages(interaction.client, interaction.guildId); - - if (guildReactionRoles.length === 0) { - logger.debug(`No reaction role messages found in guild ${interaction.guild.name}`); - return InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed('No Reaction Roles', 'There are no reaction role messages in this server.')] - }); - } - - const embed = new EmbedBuilder() - .setTitle('๐ŸŽญ Reaction Role Messages') - .setColor(getColor('info')) - .setDescription(`Found **${guildReactionRoles.length}** active reaction role message${guildReactionRoles.length !== 1 ? 's' : ''}:`) - .setFooter({ text: `Total: ${guildReactionRoles.length} message${guildReactionRoles.length !== 1 ? 's' : ''}` }) - .setTimestamp(); - - for (const rr of guildReactionRoles) { - try { - const channel = await interaction.guild.channels.fetch(rr.channelId).catch(() => null); - const message = channel ? await channel.messages.fetch(rr.messageId).catch(() => null) : null; - - - let roleCount = 0; - if (Array.isArray(rr.roles)) { - roleCount = rr.roles.length; - } else if (typeof rr.roles === 'object') { - roleCount = Object.keys(rr.roles).length; - } - - - let fieldValue = ''; - fieldValue += `๐Ÿ“ **Channel:** ${channel ? channel.toString() : 'โŒ Not found'}\n`; - fieldValue += `๐Ÿ”— **Message:** ${message ? `[Jump to Message](${message.url})` : 'โŒ Message not found'}\n`; - fieldValue += `๐Ÿท๏ธ **Roles:** ${roleCount} role${roleCount !== 1 ? 's' : ''} configured`; - - - if (rr.createdAt) { - fieldValue += `\n๐Ÿ“… **Created:** `; - } - - embed.addFields({ - name: `Message ID: \`${rr.messageId}\``, - value: fieldValue, - inline: false - }); - } catch (fieldError) { - logger.warn(`Error processing reaction role message ${rr.messageId}:`, fieldError); - embed.addFields({ - name: `Message ID: \`${rr.messageId}\``, - value: 'โš ๏ธ Error loading message details', - inline: false - }); - } - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Reaction role list displayed to ${interaction.user.tag}, showing ${guildReactionRoles.length} messages`); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rlist' - }); - } - } -}; - - diff --git a/src/commands/Reaction_roles/rsetup.js b/src/commands/Reaction_roles/rsetup.js deleted file mode 100644 index 056bfa4307..0000000000 --- a/src/commands/Reaction_roles/rsetup.js +++ /dev/null @@ -1,267 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, EmbedBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createReactionRoleMessage, hasDangerousPermissions } from '../../services/reactionRoleService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('rsetup') - .setDescription('Set up a reaction role message') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send the reaction role message to') - .setRequired(true) - ) - .addStringOption(option => - option.setName('title') - .setDescription('Title for the reaction role message') - .setRequired(true) - ) - .addStringOption(option => - option.setName('description') - .setDescription('Description for the reaction role message') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role1') - .setDescription('First role to add') - .setRequired(true) - ) - .addRoleOption(option => - option.setName('role2') - .setDescription('Second role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role3') - .setDescription('Third role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role4') - .setDescription('Fourth role to add') - .setRequired(false) - ) - .addRoleOption(option => - option.setName('role5') - .setDescription('Fifth role to add') - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - async execute(interaction) { - try { - - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) return; - - logger.info(`Reaction role setup initiated by ${interaction.user.tag} in guild ${interaction.guild.name}`); - - const channel = interaction.options.getChannel('channel'); - const title = interaction.options.getString('title'); - const description = interaction.options.getString('description'); - - - if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { - throw createError( - `Invalid channel type: ${channel.type}`, - ErrorTypes.VALIDATION, - 'Please select a text or announcement channel.', - { channelType: channel.type } - ); - } - - - if (!interaction.guild.members.me.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - 'Bot missing ManageRoles permission', - ErrorTypes.PERMISSION, - 'I need the "Manage Roles" permission to set up reaction roles.', - { permission: 'ManageRoles' } - ); - } - - - if (!channel.permissionsFor(interaction.guild.members.me).has(PermissionFlagsBits.SendMessages)) { - throw createError( - `Bot cannot send messages in ${channel.name}`, - ErrorTypes.PERMISSION, - `I don't have permission to send messages in ${channel}.`, - { channelId: channel.id } - ); - } - - - const roles = []; - const roleValidationErrors = []; - - for (let i = 1; i <= 5; i++) { - const role = interaction.options.getRole(`role${i}`); - if (role) { - - if (role.position >= interaction.guild.members.me.roles.highest.position) { - roleValidationErrors.push(`**${role.name}** - My role is not high enough in the hierarchy`); - continue; - } - - - if (hasDangerousPermissions(role)) { - roleValidationErrors.push(`**${role.name}** - This role has dangerous permissions (Administrator, Manage Server, etc.)`); - continue; - } - - - if (role.managed) { - roleValidationErrors.push(`**${role.name}** - This is a managed role (integration/bot role)`); - continue; - } - - - if (role.id === interaction.guild.id) { - roleValidationErrors.push(`**${role.name}** - Cannot use the @everyone role`); - continue; - } - - roles.push(role); - } - } - - - if (roleValidationErrors.length > 0) { - const errorMsg = `The following roles cannot be added:\n${roleValidationErrors.join('\n')}`; - - if (roles.length === 0) { - throw createError( - 'No valid roles provided', - ErrorTypes.VALIDATION, - errorMsg, - { errors: roleValidationErrors } - ); - } - - - await interaction.followUp({ - embeds: [warningEmbed('Role Validation Warning', errorMsg)], - ephemeral: true - }); - } - - if (roles.length < 1) { - throw createError( - 'No roles provided', - ErrorTypes.VALIDATION, - 'You must provide at least one valid role.', - {} - ); - } - - - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') - .setMinValues(0) - .setMaxValues(roles.length) - .addOptions( - roles.map(role => ({ - label: role.name, - description: `Add/remove the ${role.name} role`, - value: role.id, - emoji: '๐ŸŽญ' - })) - ) - ); - - - const message = await channel.send({ - embeds: [{ - title, - description, - color: getColor('info'), - fields: [ - { - name: 'Available Roles', - value: roles.map(role => `โ€ข ${role}`).join('\n') - } - ], - footer: { - text: 'Select roles from the dropdown menu below' - } - }], - components: [row] - }); - - - const roleIds = roles.map(role => role.id); - await createReactionRoleMessage( - interaction.client, - interaction.guildId, - channel.id, - message.id, - roleIds - ); - - logger.info(`Reaction role message created: ${message.id} with ${roles.length} roles by ${interaction.user.tag}`); - - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.REACTION_ROLE_CREATE, - data: { - description: `Reaction role message created by ${interaction.user.tag}`, - userId: interaction.user.id, - channelId: channel.id, - fields: [ - { - name: '๐Ÿ“ Title', - value: title, - inline: false - }, - { - name: '๐Ÿ“ Channel', - value: channel.toString(), - inline: true - }, - { - name: '๐Ÿ“Š Roles', - value: `${roles.length} roles`, - inline: true - }, - { - name: '๐Ÿท๏ธ Role List', - value: roles.map(r => r.toString()).join(', '), - inline: false - }, - { - name: '๐Ÿ”— Message Link', - value: message.url, - inline: false - } - ] - } - }); - } catch (logError) { - logger.warn('Failed to log reaction role creation:', logError); - } - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Success', `โœ… Reaction role message created in ${channel}!\n\n${message.url}`)] - }); - - } catch (error) { - await handleInteractionError(interaction, error, { - type: 'command', - commandName: 'rsetup' - }); - } - } -}; - - - diff --git a/src/commands/Counter/modules/counter_create.js b/src/commands/ServerStats/modules/serverstats_create.js similarity index 99% rename from src/commands/Counter/modules/counter_create.js rename to src/commands/ServerStats/modules/serverstats_create.js index 9a98999ca5..9bd4912507 100644 --- a/src/commands/Counter/modules/counter_create.js +++ b/src/commands/ServerStats/modules/serverstats_create.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits, ChannelType } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterBaseName, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_delete.js b/src/commands/ServerStats/modules/serverstats_delete.js similarity index 98% rename from src/commands/Counter/modules/counter_delete.js rename to src/commands/ServerStats/modules/serverstats_delete.js index 9a466e308a..e0f52f204e 100644 --- a/src/commands/Counter/modules/counter_delete.js +++ b/src/commands/ServerStats/modules/serverstats_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, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_list.js b/src/commands/ServerStats/modules/serverstats_list.js similarity index 99% rename from src/commands/Counter/modules/counter_list.js rename to src/commands/ServerStats/modules/serverstats_list.js index b443ce473b..24c3aefec3 100644 --- a/src/commands/Counter/modules/counter_list.js +++ b/src/commands/ServerStats/modules/serverstats_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, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, getCounterEmoji as getCounterTypeEmoji, getCounterTypeLabel, getGuildCounterStats } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/modules/counter_update.js b/src/commands/ServerStats/modules/serverstats_update.js similarity index 98% rename from src/commands/Counter/modules/counter_update.js rename to src/commands/ServerStats/modules/serverstats_update.js index 18d0ed4e46..cd3d30ece3 100644 --- a/src/commands/Counter/modules/counter_update.js +++ b/src/commands/ServerStats/modules/serverstats_update.js @@ -1,6 +1,6 @@ import { PermissionFlagsBits } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../../../utils/embeds.js'; -import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/counterService.js'; +import { getServerCounters, saveServerCounters, updateCounter, getCounterEmoji, getCounterTypeLabel } from '../../../services/serverstatsService.js'; import { logger } from '../../../utils/logger.js'; diff --git a/src/commands/Counter/counter.js b/src/commands/ServerStats/serverstats.js similarity index 80% rename from src/commands/Counter/counter.js rename to src/commands/ServerStats/serverstats.js index fc576460ab..8da7cc79f8 100644 --- a/src/commands/Counter/counter.js +++ b/src/commands/ServerStats/serverstats.js @@ -3,25 +3,25 @@ import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType } f import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; -import { handleCreate } from './modules/counter_create.js'; -import { handleList } from './modules/counter_list.js'; -import { handleUpdate } from './modules/counter_update.js'; -import { handleDelete } from './modules/counter_delete.js'; +import { handleCreate } from './modules/serverstats_create.js'; +import { handleList } from './modules/serverstats_list.js'; +import { handleUpdate } from './modules/serverstats_update.js'; +import { handleDelete } from './modules/serverstats_delete.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; export default { data: new SlashCommandBuilder() - .setName("counter") - .setDescription("Manage server counters that track statistics in channel names") + .setName("serverstats") + .setDescription("Manage server statistics that track member counts and channel data") .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) .addSubcommand(subcommand => subcommand .setName("create") - .setDescription("Create a new counter channel in a category") + .setDescription("Create a new statistics tracker channel in a category") .addStringOption(option => option .setName("type") - .setDescription("The type of counter to create") + .setDescription("The type of statistics to track") .setRequired(true) .addChoices( { name: "members + bots", value: "members" }, @@ -32,7 +32,7 @@ export default { .addStringOption(option => option .setName("channel_type") - .setDescription("The channel type to create for this counter") + .setDescription("The channel type to create for this tracker") .setRequired(true) .addChoices( { name: "voice channel (recommended)", value: "voice" }, @@ -42,7 +42,7 @@ export default { .addChannelOption(option => option .setName("category") - .setDescription("The category where the counter channel will be created") + .setDescription("The category where the statistics tracker channel will be created") .setRequired(true) .addChannelTypes(ChannelType.GuildCategory) ) @@ -50,22 +50,22 @@ export default { .addSubcommand(subcommand => subcommand .setName("list") - .setDescription("List all counters for this server") + .setDescription("List all statistics trackers for this server") ) .addSubcommand(subcommand => subcommand .setName("update") - .setDescription("Update an existing counter") + .setDescription("Update an existing statistics tracker") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the counter to update") + .setDescription("The ID of the tracker to update") .setRequired(true) ) .addStringOption(option => option .setName("type") - .setDescription("The new counter type") + .setDescription("The new tracker type") .setRequired(false) .addChoices( { name: "members + bots", value: "members" }, @@ -77,11 +77,11 @@ export default { .addSubcommand(subcommand => subcommand .setName("delete") - .setDescription("Delete an existing counter") + .setDescription("Delete an existing statistics tracker") .addStringOption(option => option .setName("counter-id") - .setDescription("The ID of the counter to delete") + .setDescription("The ID of the tracker to delete") .setRequired(true) ) ), @@ -110,7 +110,7 @@ export default { }); } } catch (error) { - logger.error(`Error in counter ${subcommand}:`, error); + logger.error(`Error in serverstats ${subcommand}:`, error); const errorEmbedMsg = createEmbed({ title: "โŒ Error", diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js new file mode 100644 index 0000000000..43ece8d982 --- /dev/null +++ b/src/commands/Ticket/modules/ticket_dashboard.js @@ -0,0 +1,1036 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + RoleSelectMenuBuilder, + ChannelSelectMenuBuilder, + UserSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + 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 { getGuildConfig } from '../../../services/guildConfig.js'; +import { getGuildConfigKey } from '../../../utils/database.js'; +import { getUserTicketCount } from '../../../services/ticket.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(config, guild) { + const panelChannel = config.ticketPanelChannelId ? `<#${config.ticketPanelChannelId}>` : '`Not set`'; + const staffRole = config.ticketStaffRoleId ? `<@&${config.ticketStaffRoleId}>` : '`Not set`'; + const ticketLogsChannel = config.ticketLogsChannelId ? `<#${config.ticketLogsChannelId}>` : '`Not set`'; + const transcriptChannel = config.ticketTranscriptChannelId ? `<#${config.ticketTranscriptChannelId}>` : '`Not set`'; + + // Get category names from guild + const openCategoryChannel = config.ticketCategoryId ? guild.channels.cache.get(config.ticketCategoryId) : null; + const openCategory = openCategoryChannel ? openCategoryChannel.toString() : '`Not set`'; + + const closedCategoryChannel = config.ticketClosedCategoryId ? guild.channels.cache.get(config.ticketClosedCategoryId) : null; + const closedCategory = closedCategoryChannel ? closedCategoryChannel.toString() : '`Not set`'; + + const rawMsg = config.ticketPanelMessage || 'Click the button below to create a support ticket.'; + const panelMsg = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + const btnLabel = `\`${config.ticketButtonLabel || 'Create Ticket'}\``; + + return new EmbedBuilder() + .setTitle('๐ŸŽซ Ticket System Dashboard') + .setDescription(`Manage ticket system settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Panel Channel', value: panelChannel, inline: true }, + { name: '๐Ÿ›ก๏ธ Staff Role', value: staffRole, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ“ Open Tickets Category', value: openCategory, inline: true }, + { name: '๐Ÿ“‚ Closed Tickets Category', value: closedCategory, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ“ Panel Message', value: panelMsg, inline: false }, + { name: '๐Ÿท๏ธ Button Label', value: btnLabel, inline: true }, + { name: '๐Ÿ”ข Max Tickets/User', value: String(config.maxTicketsPerUser || 3), inline: true }, + { name: '๐Ÿ“ฌ DM on Close', value: config.dmOnClose !== false ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐ŸŽซ Ticket Logs Channel', value: ticketLogsChannel, inline: true }, + { name: '๐Ÿ“œ Transcript Channel', value: transcriptChannel, inline: true }, + ) + .setFooter({ text: 'Select an option below โ€ข Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`ticket_config_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Edit Panel Message') + .setDescription('Change the message displayed on the ticket creation panel') + .setValue('panel_message') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Button Label') + .setDescription('Change the label on the Create Ticket button') + .setValue('button_label') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Open Tickets Category') + .setDescription('Category where new tickets are created') + .setValue('open_category') + .setEmoji('๐Ÿ“'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Closed Tickets Category') + .setDescription('Category where closed tickets are moved') + .setValue('closed_category') + .setEmoji('๐Ÿ“‚'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Max Tickets per User') + .setDescription('Limit how many open tickets one user can have at once') + .setValue('max_tickets') + .setEmoji('๐Ÿ”ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Ticket Logs Channel') + .setDescription('Channel to receive ticket feedback, lifecycle events, and logs') + .setValue('logs_channel') + .setEmoji('๐ŸŽซ'), + new StringSelectMenuOptionBuilder() + .setLabel('Set Transcript Channel') + .setDescription('Channel to receive auto-generated transcripts on deletion') + .setValue('transcript_channel') + .setEmoji('๐Ÿ“œ'), + ); +} + +function buildButtonRow(guildConfig, guildId, disabled = false) { + const dmEnabled = guildConfig.dmOnClose !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`ticket_cfg_dm_toggle_${guildId}`) + .setLabel('DM on Close') + .setStyle(dmEnabled ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji(dmEnabled ? '๐Ÿ“ฌ' : '๐Ÿ“ญ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`ticket_cfg_staff_role_btn_${guildId}`) + .setLabel('Staff Role') + .setStyle(ButtonStyle.Secondary) + .setEmoji('๐Ÿ›ก๏ธ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`ticket_cfg_delete_${guildId}`) + .setLabel('Delete System') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ—‘๏ธ') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, guildConfig, guildId) { + const buttonRow = buildButtonRow(guildConfig, guildId); + const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild)], + components: [buttonRow, selectRow], + }).catch(() => {}); +} + +/** + * Attempts to find and edit the live ticket panel message in the panel channel. + * Returns true if the panel was found and updated, false otherwise. + */ +async function updateLivePanel(client, guild, config) { + if (!config.ticketPanelChannelId) return false; + try { + const channel = await guild.channels.fetch(config.ticketPanelChannelId).catch(() => null); + if (!channel) return false; + + const messages = await channel.messages.fetch({ limit: 50 }); + const panelMsg = messages.find( + m => + m.author.id === client.user.id && + m.components?.length > 0 && + m.components[0]?.components?.[0]?.customId === 'create_ticket', + ); + if (!panelMsg) return false; + + const updatedEmbed = new EmbedBuilder() + .setTitle('๐ŸŽซ Support Tickets') + .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') + .setColor(getColor('info')); + + const button = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('create_ticket') + .setLabel(config.ticketButtonLabel || 'Create Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐Ÿ“ฉ'), + ); + + await panelMsg.edit({ embeds: [updatedEmbed], components: [button] }); + return true; + } catch (error) { + logger.warn('Failed to update live ticket panel:', error.message); + return false; + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + + if (!guildConfig.ticketPanelChannelId) { + throw new TitanBotError( + 'Ticket system not configured', + ErrorTypes.CONFIGURATION, + 'The ticket system has not been set up yet. Run `/ticket setup` first to configure it.', + ); + } + + const selectMenu = buildSelectMenu(guildId); + const selectRow = new ActionRowBuilder().addComponents(selectMenu); + const buttonRow = buildButtonRow(guildConfig, guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(guildConfig, interaction.guild)], + components: [buttonRow, selectRow], + }); + + const replyMessage = await interaction.fetchReply().catch(() => null); + const replyMessageId = replyMessage?.id; + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && + i.customId === `ticket_config_${guildId}` && + (!replyMessageId || i.message.id === replyMessageId), + time: 600_000, + }); + + const buttonCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (!replyMessageId || i.message.id === replyMessageId) && + (i.customId === `ticket_cfg_dm_toggle_${guildId}` || + i.customId === `ticket_cfg_staff_role_btn_${guildId}` || + i.customId === `ticket_cfg_delete_${guildId}`), + + time: 600_000, + }); + + collector.on('collect', async (selectInteraction) => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'panel_message': + await handlePanelMessage(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'button_label': + await handleButtonLabel(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'staff_role': + await handleStaffRole(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'open_category': + await handleOpenCategory(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'closed_category': + await handleClosedCategory(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'max_tickets': + await handleMaxTickets(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'logs_channel': + await handleLogsChannel(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'transcript_channel': + await handleTranscriptChannel(selectInteraction, interaction, guildConfig, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Ticket config validation error: ${error.message}`); + } else { + logger.error('Unexpected ticket config menu error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + // Already deferred at the top of the collector + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + buttonCollector.on('collect', async (btnInteraction) => { + try { + if (btnInteraction.customId === `ticket_cfg_dm_toggle_${guildId}`) { + await handleDmOnClose(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `ticket_cfg_staff_role_btn_${guildId}`) { + await handleStaffRole(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `ticket_cfg_delete_${guildId}`) { + await handleDeleteSystem(btnInteraction, interaction, guildConfig, guildId, client); + } + } catch (error) { + if (error.code === 40060) return; + if (error instanceof TitanBotError) { + logger.debug(`Ticket config button error: ${error.message}`); + } else { + logger.error('Unexpected ticket config button error:', error); + } + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + // Already deferred at the top of the collector + await btnInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + collector.on('end', async (collected, reason) => { + buttonCollector.stop(); + if (reason === 'time') { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + }).catch(() => {}); + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in ticket_config:', error); + throw new TitanBotError( + `Ticket config failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the ticket configuration dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Panel Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handlePanelMessage(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_panel_msg') + .setTitle('Edit Panel Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('panel_msg_input') + .setLabel('Panel Message') + .setStyle(TextInputStyle.Paragraph) + .setValue( + guildConfig.ticketPanelMessage || + 'Click the button below to create a support ticket.', + ) + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('Click the button below to create a support ticket.'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_panel_msg' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newMessage = submitted.fields.getTextInputValue('panel_msg_input').trim(); + guildConfig.ticketPanelMessage = newMessage; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Panel Message Updated', + `The panel message has been updated.${ + panelUpdated + ? '\nThe live ticket panel has also been refreshed.' + : '\n> **Note:** The live panel could not be located. The new message will apply the next time you run `/ticket setup`.' + }`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Button Label โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleButtonLabel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_btn_label') + .setTitle('Edit Button Label') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('btn_label_input') + .setLabel('Button Label (max 80 characters)') + .setStyle(TextInputStyle.Short) + .setValue(guildConfig.ticketButtonLabel || 'Create Ticket') + .setMaxLength(80) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('Create Ticket'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_btn_label' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const newLabel = submitted.fields.getTextInputValue('btn_label_input').trim(); + guildConfig.ticketButtonLabel = newLabel; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Button Label Updated', + `Button label changed to \`${newLabel}\`.${ + panelUpdated + ? '\nThe live ticket panel button has also been updated.' + : '\n> **Note:** The live panel could not be located. The new label will apply the next time you run `/ticket setup`.' + }`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Staff Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleStaffRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('ticket_cfg_staff_role') + .setPlaceholder('Select the staff role...') + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(roleSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ›ก๏ธ Change Staff Role') + .setDescription( + `**Current:** ${guildConfig.ticketStaffRoleId ? `<@&${guildConfig.ticketStaffRoleId}>` : '`Not set`'}\n\nSelect the role that should have staff access to manage tickets.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_staff_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + guildConfig.ticketStaffRoleId = role.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Staff Role Updated', `Staff role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The staff role was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Open Tickets Category โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleOpenCategory(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_open_cat') + .setPlaceholder('Select a category...') + .addChannelTypes(ChannelType.GuildCategory) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ Change Open Tickets Category') + .setDescription( + `**Current:** ${guildConfig.ticketCategoryId ? `<#${guildConfig.ticketCategoryId}>` : '`Not set`'}\n\nSelect the category where new tickets will be created.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const catCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_open_cat', + time: 60_000, + max: 1, + }); + + catCollector.on('collect', async catInteraction => { + await catInteraction.deferUpdate(); + const category = catInteraction.channels.first(); + + guildConfig.ticketCategoryId = category.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await catInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Open Category Updated', + `New tickets will now be created in **${category.name}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + catCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Closed Tickets Category โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleClosedCategory( + selectInteraction, + rootInteraction, + guildConfig, + guildId, + client, +) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_closed_cat') + .setPlaceholder('Select a category...') + .addChannelTypes(ChannelType.GuildCategory) + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(channelSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“‚ Change Closed Tickets Category') + .setDescription( + `**Current:** ${guildConfig.ticketClosedCategoryId ? `<#${guildConfig.ticketClosedCategoryId}>` : '`Not set`'}\n\nSelect the category where closed tickets will be moved.`, + ) + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const catCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_closed_cat', + time: 60_000, + max: 1, + }); + + catCollector.on('collect', async catInteraction => { + await catInteraction.deferUpdate(); + const category = catInteraction.channels.first(); + + guildConfig.ticketClosedCategoryId = category.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await catInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Closed Category Updated', + `Closed tickets will now be moved to **${category.name}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + catCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [ + errorEmbed('Timed Out', 'No category was selected. The setting was not changed.'), + ], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Max Tickets per User โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMaxTickets(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('ticket_cfg_max_tickets') + .setTitle('Set Max Tickets per User') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('max_tickets_input') + .setLabel('Max Open Tickets (1โ€“10)') + .setStyle(TextInputStyle.Short) + .setValue(String(guildConfig.maxTicketsPerUser || 3)) + .setMaxLength(2) + .setMinLength(1) + .setRequired(true) + .setPlaceholder('3'), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'ticket_cfg_max_tickets' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const raw = submitted.fields.getTextInputValue('max_tickets_input').trim(); + const newMax = parseInt(raw, 10); + + if (isNaN(newMax) || newMax < 1 || newMax > 10) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Value', 'Max tickets must be a whole number between **1** and **10**.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.maxTicketsPerUser = newMax; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await submitted.reply({ + embeds: [ + successEmbed( + 'โœ… Max Tickets Updated', + `Users can now have at most **${newMax}** open ticket${newMax !== 1 ? 's' : ''} at a time.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ DM on Close Toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { + await btnInteraction.deferUpdate(); + + const newState = guildConfig.dmOnClose === false; + guildConfig.dmOnClose = newState; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… DM on Close Updated', + `Users will **${newState ? 'now' : 'no longer'}** receive a DM when their ticket is closed.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); +} + +// โ”€โ”€โ”€ Feedback Logs Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleLogsChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_logs_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŽซ Select Ticket Logs Channel') + .setDescription('Choose where ticket feedback, lifecycle events (open, close, claim, etc.), and other logs will be sent.') + .setColor(getColor('info')) + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral + }); + + const collector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_logs_channel', + time: 60_000, + max: 1 + }); + + collector.on('collect', async channelInteraction => { + await channelInteraction.deferUpdate(); + const channel = channelInteraction.channels.first(); + + guildConfig.ticketLogsChannelId = channel.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await channelInteraction.followUp({ + embeds: [successEmbed('โœ… Logs Channel Updated', `Ticket logs will be sent to ${channel}`)], + flags: MessageFlags.Ephemeral + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], + flags: MessageFlags.Ephemeral + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Transcript Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleTranscriptChannel(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('ticket_cfg_transcript_channel') + .setPlaceholder('Select a channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“œ Select Transcript Channel') + .setDescription('Choose where auto-generated transcripts will be sent when tickets are deleted.') + .setColor(getColor('info')) + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral + }); + + const collector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_transcript_channel', + time: 60_000, + max: 1 + }); + + collector.on('collect', async channelInteraction => { + await channelInteraction.deferUpdate(); + const channel = channelInteraction.channels.first(); + + guildConfig.ticketTranscriptChannelId = channel.id; + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await channelInteraction.followUp({ + embeds: [successEmbed('โœ… Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], + flags: MessageFlags.Ephemeral + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId); + }); + + collector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction.followUp({ + embeds: [errorEmbed('Timed Out', 'No channel selected. No changes were made.')], + flags: MessageFlags.Ephemeral + }).catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Check User Tickets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleCheckUser(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const userSelect = new UserSelectMenuBuilder() + .setCustomId('ticket_cfg_check_user') + .setPlaceholder('Select a user to check...') + .setMaxValues(1); + + const row = new ActionRowBuilder().addComponents(userSelect); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ” Check User Tickets') + .setDescription('Select a user to view their current open ticket count.') + .setColor(getColor('info')), + ], + components: [row], + flags: MessageFlags.Ephemeral, + }); + + const userCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.UserSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'ticket_cfg_check_user', + time: 60_000, + max: 1, + }); + + userCollector.on('collect', async userInteraction => { + await userInteraction.deferUpdate(); + const targetUser = userInteraction.users.first(); + const maxTickets = guildConfig.maxTicketsPerUser || 3; + const openCount = await getUserTicketCount(guildId, targetUser.id); + const atLimit = openCount >= maxTickets; + + await userInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle(`๐ŸŽซ Ticket Check โ€” ${targetUser.username}`) + .setDescription( + `**Open Tickets:** ${openCount} / ${maxTickets}\n` + + `**Remaining:** ${Math.max(0, maxTickets - openCount)}\n\n` + + (atLimit + ? 'โš ๏ธ This user has reached their ticket limit.' + : 'โœ… This user can still open more tickets.'), + ) + .setColor(atLimit ? getColor('error') : getColor('success')) + .setThumbnail(targetUser.displayAvatarURL({ size: 64 })) + .setTimestamp(), + ], + flags: MessageFlags.Ephemeral, + }); + }); + + userCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No user was selected.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Delete Ticket System โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDeleteSystem(btnInteraction, rootInteraction, guildConfig, guildId, client) { + const deleteModal = new ModalBuilder() + .setCustomId('ticket_delete_confirm_modal') + .setTitle('Delete Ticket System') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('delete_confirmation') + .setLabel('Type "DELETE" to confirm') + .setStyle(TextInputStyle.Short) + .setPlaceholder('DELETE') + .setMaxLength(6) + .setMinLength(6) + .setRequired(true) + ) + ); + + await btnInteraction.showModal(deleteModal); + + const submitted = await btnInteraction + .awaitModalSubmit({ + filter: i => i.customId === 'ticket_delete_confirm_modal' && i.user.id === btnInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) { + await refreshDashboard(rootInteraction, guildConfig, guildId); + return; + } + + const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); + + if (confirmation !== 'DELETE') { + await submitted.reply({ + embeds: [errorEmbed('Incorrect Confirmation', 'You must type "DELETE" exactly to confirm deletion.')], + flags: MessageFlags.Ephemeral, + }); + await refreshDashboard(rootInteraction, guildConfig, guildId); + return; + } + + await submitted.deferUpdate(); + + const keysToDelete = [ + 'ticketPanelChannelId', + 'ticketPanelMessageId', + 'ticketStaffRoleId', + 'ticketCategoryId', + 'ticketClosedCategoryId', + 'ticketPanelMessage', + 'ticketButtonLabel', + 'maxTicketsPerUser', + 'dmOnClose', + ]; + + // Delete the panel embed from Discord + if (guildConfig.ticketPanelChannelId) { + try { + const panelChannel = await client.guilds.cache.get(guildId)?.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); + if (panelChannel) { + if (guildConfig.ticketPanelMessageId) { + const panelMessage = await panelChannel.messages.fetch(guildConfig.ticketPanelMessageId).catch(() => null); + if (panelMessage) await panelMessage.delete().catch(() => {}); + } else { + // Fallback: scan for the panel by button customId + const messages = await panelChannel.messages.fetch({ limit: 50 }).catch(() => null); + if (messages) { + const found = messages.find( + m => m.author.id === client.user.id && + m.components?.[0]?.components?.[0]?.customId === 'create_ticket' + ); + if (found) await found.delete().catch(() => {}); + } + } + } + } catch (panelDeleteError) { + logger.warn('Could not delete ticket panel message:', panelDeleteError.message); + } + } + + // Clear all open ticket records for the guild from the database + try { + const { pgConfig } = await import('../../../config/postgres.js'); + if (client.db?.db?.pool && typeof client.db.db.isAvailable === 'function' && client.db.db.isAvailable()) { + await client.db.db.pool.query( + `DELETE FROM ${pgConfig.tables.tickets} WHERE guild_id = $1`, + [guildId] + ); + } + } catch (ticketDeleteError) { + logger.warn('Could not clear ticket records from database:', ticketDeleteError.message); + } + + for (const key of keysToDelete) { + delete guildConfig[key]; + } + await client.db.set(getGuildConfigKey(guildId), guildConfig); + + await submitted.followUp({ + embeds: [ + successEmbed( + 'โœ… Ticket System Deleted', + 'All ticket system configuration has been cleared. Run `/ticket setup` to set it up again.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ—‘๏ธ Ticket System Deleted') + .setDescription('The ticket system configuration has been cleared.') + .setColor(getColor('error')) + .setTimestamp(), + ], + components: [], + }).catch(() => {}); +} \ No newline at end of file diff --git a/src/commands/Ticket/modules/ticket_limits_check.js b/src/commands/Ticket/modules/ticket_limits_check.js deleted file mode 100644 index 29e6bd8448..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_check.js +++ /dev/null @@ -1,64 +0,0 @@ -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'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const user = interaction.options.getUser('user'); - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - const maxTickets = guildConfig.maxTicketsPerUser || 3; - - const openTicketCount = await getUserTicketCount(guildId, user.id); - - const embed = infoEmbed( - `๐ŸŽซ Ticket Limit Check: ${user.tag}`, - `**Open Tickets:** ${openTicketCount}/${maxTickets}\n` + - `**Remaining:** ${Math.max(0, maxTickets - openTicketCount)}\n\n` + - (openTicketCount >= maxTickets - ? 'โš ๏ธ This user has reached their ticket limit.' - : 'โœ… This user can create more tickets.') - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket limit check completed', { - userId: interaction.user.id, - targetUserId: user.id, - targetUserTag: user.tag, - guildId: guildId, - openTickets: openTicketCount, - maxTickets: maxTickets, - commandName: 'ticket_limits_check' - }); - } catch (error) { - logger.error('Error checking ticket limits', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_check' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_check', - source: 'ticket_limits_module' - }); - } - } -}; - - - diff --git a/src/commands/Ticket/modules/ticket_limits_set.js b/src/commands/Ticket/modules/ticket_limits_set.js deleted file mode 100644 index 337d31d5e7..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_set.js +++ /dev/null @@ -1,61 +0,0 @@ -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const maxTickets = interaction.options.getInteger('max_tickets'); - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - - guildConfig.maxTicketsPerUser = maxTickets; - - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, guildConfig); - - const embed = successEmbed( - 'โœ… Ticket Limit Updated', - `Maximum tickets per user has been set to **${maxTickets}**.\n\n` + - `Users will now be limited to ${maxTickets} open ticket${maxTickets !== 1 ? 's' : ''} at a time.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket limit updated', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: guildId, - maxTickets: maxTickets, - commandName: 'ticket_limits_set' - }); - } catch (error) { - logger.error('Error setting ticket limits', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_set' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_set', - source: 'ticket_limits_module' - }); - } - } -}; - - - diff --git a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js b/src/commands/Ticket/modules/ticket_limits_toggle_dm.js deleted file mode 100644 index b6d87e11c9..0000000000 --- a/src/commands/Ticket/modules/ticket_limits_toggle_dm.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/guildConfig.js'; -import { getGuildConfigKey } from '../../../utils/database.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError } from '../../../utils/errorHandler.js'; - -export default { - async execute(interaction, config, client) { - try { - - if (!interaction.deferred && !interaction.replied) { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - } - - const guildId = interaction.guild.id; - - const guildConfig = await getGuildConfig(client, guildId); - - const currentSetting = guildConfig.dmOnClose !== false; - guildConfig.dmOnClose = !currentSetting; - - const configKey = getGuildConfigKey(guildId); - await client.db.set(configKey, guildConfig); - - const embed = successEmbed( - 'โœ… DM Notification Setting Updated', - `DM notifications when tickets are closed: **${guildConfig.dmOnClose ? 'Enabled' : 'Disabled'}**\n\n` + - (guildConfig.dmOnClose - ? '๐Ÿ“ฌ Users will receive a DM when their ticket is closed.' - : '๐Ÿ“ญ Users will NOT receive a DM when their ticket is closed.') - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Ticket DM notification setting toggled', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: guildId, - dmOnClose: guildConfig.dmOnClose, - commandName: 'ticket_limits_toggle_dm' - }); - } catch (error) { - logger.error('Error toggling DM setting', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guild?.id, - commandName: 'ticket_limits_toggle_dm' - }); - await handleInteractionError(interaction, error, { - commandName: 'ticket_limits_toggle_dm', - source: 'ticket_limits_module' - }); - } - } -}; - - - - diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js index 750e3b3edb..22ec056e1a 100644 --- a/src/commands/Ticket/ticket.js +++ b/src/commands/Ticket/ticket.js @@ -7,9 +7,7 @@ import { InteractionHelper } from '../../utils/interactionHelper.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; -import ticketLimitsSet from './modules/ticket_limits_set.js'; -import ticketLimitsCheck from './modules/ticket_limits_check.js'; -import ticketLimitsToggleDM from './modules/ticket_limits_toggle_dm.js'; +import ticketConfig from './modules/ticket_dashboard.js'; export default { data: new SlashCommandBuilder() @@ -89,39 +87,10 @@ export default { .setRequired(false), ), ) - .addSubcommandGroup((group) => - group - .setName("limits") - .setDescription("Manage ticket limits and settings") - .addSubcommand((subcommand) => - subcommand - .setName("set") - .setDescription("Set the maximum number of tickets per user") - .addIntegerOption((option) => - option - .setName("max_tickets") - .setDescription("Maximum number of tickets a user can create (1-10)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("check") - .setDescription("Check a user's current ticket count") - .addUserOption((option) => - option - .setName("user") - .setDescription("The user to check") - .setRequired(true) - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("toggle_dm") - .setDescription("Toggle DM notifications when tickets are closed") - ) + .addSubcommand((subcommand) => + subcommand + .setName("dashboard") + .setDescription("Open the interactive ticket system dashboard"), ), category: "ticket", @@ -154,29 +123,24 @@ export default { } const subcommand = interaction.options.getSubcommand(); - const subcommandGroup = interaction.options.getSubcommandGroup(); - if (subcommandGroup === "limits") { - switch (subcommand) { - case "set": - return ticketLimitsSet.execute(interaction, config, client); - case "check": - return ticketLimitsCheck.execute(interaction, config, client); - case "toggle_dm": - return ticketLimitsToggleDM.execute(interaction, config, client); - default: - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Invalid Subcommand", - "Please select a valid limits subcommand." - ), - ], - }); - } + if (subcommand === "dashboard") { + return ticketConfig.execute(interaction, config, client); } if (subcommand === "setup") { + const existingConfig = await getGuildConfig(client, interaction.guildId); + if (existingConfig?.ticketPanelChannelId) { + return await InteractionHelper.safeEditReply(interaction, { + embeds: [ + errorEmbed( + 'Ticket System Already Active', + `This server already has a ticket system set up (panel in <#${existingConfig.ticketPanelChannelId}>).\n\nOnly one ticket system is supported per server. Use \`/ticket dashboard\` to edit or update the existing setup, or select **Delete System** from the dashboard to remove it and start fresh.`, + ), + ], + }); + } + const panelChannel = interaction.options.getChannel("panel_channel"); const categoryChannel = interaction.options.getChannel("category"); @@ -210,14 +174,13 @@ description: panelMessage, }); if (client.db && interaction.guildId) { - const currentConfig = await getGuildConfig( - client, - interaction.guildId, - ); + const currentConfig = existingConfig; currentConfig.ticketCategoryId = categoryChannel ? categoryChannel.id : null; currentConfig.ticketClosedCategoryId = closedCategoryChannel ? closedCategoryChannel.id : null; currentConfig.ticketStaffRoleId = staffRole ? staffRole.id : null; currentConfig.ticketPanelChannelId = panelChannel.id; + currentConfig.ticketPanelMessage = panelMessage; + currentConfig.ticketButtonLabel = buttonLabel; currentConfig.maxTicketsPerUser = maxTicketsPerUser; currentConfig.dmOnClose = dmOnClose; diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.js new file mode 100644 index 0000000000..4c3f08ed77 --- /dev/null +++ b/src/commands/Utility/modules/report.js @@ -0,0 +1,69 @@ +import { getColor } from '../../../config/bot.js'; +import { createEmbed, errorEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { handleInteractionError } from '../../../utils/errorHandler.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + const deferSuccess = await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + if (!deferSuccess) { + logger.warn('Report interaction defer failed', { userId: interaction.user.id, guildId: interaction.guildId }); + return; + } + + const targetUser = interaction.options.getUser('user'); + const reason = interaction.options.getString('reason'); + const guildId = interaction.guildId; + + const guildConfig = await getGuildConfig(client, guildId); + const reportChannelId = guildConfig.reportChannelId; + + if (!reportChannelId) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Setup Required', 'The report channel has not been set up. Please ask a moderator to use `/report setchannel` first.')], + }); + } + + const reportChannel = interaction.guild.channels.cache.get(reportChannelId); + if (!reportChannel) { + return InteractionHelper.safeEditReply(interaction, { + embeds: [errorEmbed('Channel Missing', 'The configured report channel is missing or inaccessible. Please ask a moderator to reset it.')], + }); + } + + try { + const reportEmbed = createEmbed({ + title: `๐Ÿšจ NEW USER REPORT: ${targetUser.tag}`, + description: `**Reported By:** ${interaction.user.tag} (\`${interaction.user.id}\`)\n**Reported User:** ${targetUser.tag} (\`${targetUser.id}\`)`, + }) + .setColor(getColor('error')) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { name: 'Reason', value: reason }, + { name: 'Reported In Channel', value: interaction.channel.toString(), inline: true }, + { name: 'Time', value: new Date().toUTCString(), inline: true }, + ); + + await reportChannel.send({ + content: `<@&${interaction.guild.ownerId}> New Report!`, + embeds: [reportEmbed], + }); + + 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!` })], + }); + + logger.info('Report submitted', { + userId: interaction.user.id, + reportedUserId: targetUser.id, + guildId, + reasonLength: reason.length, + }); + } catch (error) { + logger.error('report error:', error); + await handleInteractionError(interaction, error, { commandName: 'report', source: 'report' }); + } + }, +}; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.js new file mode 100644 index 0000000000..10c1e803e5 --- /dev/null +++ b/src/commands/Utility/modules/report_setchannel.js @@ -0,0 +1,36 @@ +import { PermissionsBitField, ChannelType } from 'discord.js'; +import { errorEmbed, successEmbed } from '../../../utils/embeds.js'; +import { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { InteractionHelper } from '../../../utils/interactionHelper.js'; +import { logger } from '../../../utils/logger.js'; + +export default { + async execute(interaction, config, client) { + if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Permission Denied', 'You need **Manage Server** permissions to set the report channel.')], + ephemeral: true, + }); + } + + const channel = interaction.options.getChannel('channel'); + const guildId = interaction.guildId; + + try { + const guildConfig = await getGuildConfig(client, guildId); + guildConfig.reportChannelId = channel.id; + await setGuildConfig(client, guildId, guildConfig); + + return InteractionHelper.safeReply(interaction, { + embeds: [successEmbed('โœ… Report Channel Set', `All new reports will now be sent to ${channel}.`)], + ephemeral: true, + }); + } catch (error) { + logger.error('report_setchannel error:', error); + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Database Error', 'Could not save the channel configuration.')], + ephemeral: true, + }); + } + }, +}; diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js index 4f062fe279..57ef35b97e 100644 --- a/src/commands/Utility/report.js +++ b/src/commands/Utility/report.js @@ -1,142 +1,68 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { getGuildConfig } from '../../services/guildConfig.js'; +import { SlashCommandBuilder, ChannelType } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; import { logger } from '../../utils/logger.js'; import { handleInteractionError } from '../../utils/errorHandler.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; + +import report from './modules/report.js'; +import reportSetchannel from './modules/report_setchannel.js'; + export default { data: new SlashCommandBuilder() - .setName("report") - .setDescription("Report a user or an issue to the server staff.") - .addUserOption((option) => - option - .setName("user") - .setDescription("The user you want to report.") - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("reason") - .setDescription("The reason for the report (be detailed).") - .setRequired(true) - .setMaxLength(500), + .setName('report') + .setDescription('Report a user to server staff, or configure where reports are sent.') + .setDMPermission(false) + .addSubcommand(subcommand => + subcommand + .setName('file') + .setDescription('Report a user to the server moderation team.') + .addUserOption(option => + option + .setName('user') + .setDescription('The user you want to report.') + .setRequired(true), + ) + .addStringOption(option => + option + .setName('reason') + .setDescription('The reason for the report (be detailed).') + .setRequired(true) + .setMaxLength(500), + ), ) - .setDMPermission(false), - category: "Utility", - - - - - + .addSubcommand(subcommand => + subcommand + .setName('setchannel') + .setDescription('Set the channel where user reports are sent. (Manage Server required)') + .addChannelOption(option => + option + .setName('channel') + .setDescription('The text channel to receive reports.') + .addChannelTypes(ChannelType.GuildText) + .setRequired(true), + ), + ), + category: 'Utility', 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; + const subcommand = interaction.options.getSubcommand(); - const guildConfig = await getGuildConfig(client, guildId); - const reportChannelId = guildConfig.reportChannelId; - - if (!reportChannelId) { - logger.warn(`Report command - report channel not configured`, { - userId: interaction.user.id, - guildId: guildId, - commandName: 'report' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Setup Required", - "The report channel has not been set up. Please ask a moderator to use `/setreportchannel` first.", - ), - ], - }); + if (subcommand === 'file') { + return await report.execute(interaction, config, client); } - const reportChannel = interaction.guild.channels.cache.get(reportChannelId); - - if (!reportChannel) { - logger.warn(`Report command - report channel missing`, { - userId: interaction.user.id, - guildId: guildId, - reportChannelId: reportChannelId, - commandName: 'report' - }); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - errorEmbed( - "Channel Missing", - "The configured report channel is missing or inaccessible. Please ask a moderator to reset it.", - ), - ], - }); + if (subcommand === 'setchannel') { + return await reportSetchannel.execute(interaction, config, client); } - const reportEmbed = createEmbed({ - title: `๐Ÿšจ NEW USER REPORT: ${targetUser.tag}`, - description: `**Reported By:** ${interaction.user.tag} (\`${interaction.user.id}\`)\n**Reported User:** ${targetUser.tag} (\`${targetUser.id}\`)` - }) - .setColor(getColor('error')) - .setThumbnail(targetUser.displayAvatarURL()) - .addFields( - { name: "Reason", value: reason }, - { - name: "Reported In Channel", - value: interaction.channel.toString(), - inline: true, - }, - { - name: "Time", - value: new Date().toUTCString(), - inline: true, - }, - ); - - await reportChannel.send({ - content: `<@&${interaction.guild.ownerId}> New Report!`, - embeds: [reportEmbed], - }); - - 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!`, }), - ], - }); - - logger.info(`Report command executed - user report submitted`, { - userId: interaction.user.id, - reportedUserId: targetUser.id, - guildId: guildId, - reasonLength: reason.length + return InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Error', 'Unknown subcommand.')], + ephemeral: true, }); } catch (error) { - logger.error(`Report command execution failed`, { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'report' - }); - await handleInteractionError(interaction, error, { - commandName: 'report', - source: 'report_command' - }); + logger.error('report command error:', error); + await handleInteractionError(interaction, error, { commandName: 'report', source: 'report_command' }); } }, -}; - - - - +}; \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js index b944131c1e..3b0fd28dcc 100644 --- a/src/commands/Verification/modules/autoVerify.js +++ b/src/commands/Verification/modules/autoVerify.js @@ -7,12 +7,12 @@ import { validateAutoVerifyCriteria } from '../../../services/verificationServic import { logger } from '../../../utils/logger.js'; import { InteractionHelper } from '../../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../../utils/database.js'; +import autoVerifyDashboard from './autoVerifyDashboard.js'; const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; -const serverSizeThreshold = autoVerifyDefaults.serverSizeThreshold ?? 1000; export default { data: new SlashCommandBuilder() @@ -34,16 +34,15 @@ export default { .setName("criteria") .setDescription("Criteria for automatic verification") .addChoices( - { name: `Account Age (older than ${defaultAccountAgeDays} days)`, value: "account_age" }, - { name: `Server Members (less than ${serverSizeThreshold} members)`, value: "server_size" }, - { name: "No Criteria (verify everyone)", value: "none" } + { name: "Account Age", value: "account_age" }, + { name: "No Criteria", value: "none" } ) .setRequired(true) ) .addIntegerOption(option => option .setName("account_age_days") - .setDescription("Minimum account age in days (for account age criteria)") + .setDescription("Minimum account age in days (required for account age criteria)") .setMinValue(minAccountAgeDays) .setMaxValue(maxAccountAgeDays) .setRequired(false) @@ -51,13 +50,8 @@ export default { ) .addSubcommand(subcommand => subcommand - .setName("disable") - .setDescription("Disable automatic verification") - ) - .addSubcommand(subcommand => - subcommand - .setName("status") - .setDescription("Check automatic verification status") + .setName("dashboard") + .setDescription("Open the auto-verification dashboard for customization") ), async execute(interaction, config, client) { @@ -68,10 +62,8 @@ export default { switch (subcommand) { case "setup": return await handleSetup(interaction, guild, client); - case "disable": - return await handleDisable(interaction, guild, client); - case "status": - return await handleStatus(interaction, guild, client); + case "dashboard": + return await autoVerifyDashboard.execute(interaction, config, client); default: throw createError( `Unknown subcommand: ${subcommand}`, @@ -171,10 +163,7 @@ async function handleSetup(interaction, guild, client) { let criteriaDescription = ""; switch (criteria) { case "account_age": - criteriaDescription = `Accounts older than ${accountAgeDays} days`; - break; - case "server_size": - criteriaDescription = `All users (server has less than ${serverSizeThreshold} members)`; + criteriaDescription = `\`${accountAgeDays} days\` old`; break; case "none": criteriaDescription = "All users immediately"; @@ -201,92 +190,3 @@ async function handleSetup(interaction, guild, client) { } } -async function handleDisable(interaction, guild, client) { - await InteractionHelper.safeDefer(interaction); - - const guildConfig = await getGuildConfig(client, guild.id); - - if (!guildConfig.verification?.autoVerify?.enabled) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed("Already Disabled", "Auto-verification is already disabled.")], - }); - } - - guildConfig.verification.autoVerify.enabled = false; - await setGuildConfig(client, guild.id, guildConfig); - - logger.info('Auto-verify disabled', { guildId: guild.id }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "Auto-Verification Disabled", - "Automatic verification has been disabled. Users will now need to verify manually." - )] - }); -} - -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.\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) { - case "account_age": - criteriaDescription = `Accounts older than ${autoVerify.accountAgeDays} days`; - break; - case "server_size": - criteriaDescription = `All users (server has less than ${serverSizeThreshold} members)`; - break; - case "none": - criteriaDescription = "All users immediately"; - break; - } - - const statusEmbed = createEmbed({ - title: "๐Ÿค– Auto-Verification Status", - description: "Current auto-verification configuration:", - color: getColor('success') - }) - .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 - } - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [statusEmbed], - flags: MessageFlags.Ephemeral - }); -} - - - diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js new file mode 100644 index 0000000000..1b3834e192 --- /dev/null +++ b/src/commands/Verification/modules/autoVerifyDashboard.js @@ -0,0 +1,544 @@ +import { botConfig, getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + ComponentType, + 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 { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getWelcomeConfig } from '../../../utils/database.js'; +import { validateAutoVerifyCriteria } from '../../../services/verificationService.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +const autoVerifyDefaults = botConfig.verification?.autoVerify || {}; +const minAccountAgeDays = autoVerifyDefaults.minAccountAge ?? 1; +const maxAccountAgeDays = autoVerifyDefaults.maxAccountAge ?? 365; +const defaultAccountAgeDays = autoVerifyDefaults.defaultAccountAgeDays ?? 7; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild, conflictSummary = '') { + const autoVerify = cfg.verification?.autoVerify; + const autoVerifyRole = autoVerify?.roleId ? guild.roles.cache.get(autoVerify.roleId) : null; + + let criteriaDescription = "`Not configured`"; + if (autoVerify?.criteria) { + switch (autoVerify.criteria) { + case "account_age": + criteriaDescription = `\`Account Age\` - \`${autoVerify.accountAgeDays} days\``; + break; + case "none": + criteriaDescription = `\`No Criteria\``; + break; + } + } + + const embed = new EmbedBuilder() + .setTitle('๐Ÿค– Auto-Verification Dashboard') + .setDescription(`Manage auto-verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: 'โš™๏ธ System Status', value: autoVerify?.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿท๏ธ Target Role', value: autoVerifyRole ? autoVerifyRole.toString() : '`Not set`', inline: true }, + { name: '๐ŸŽฏ Criteria', value: criteriaDescription, inline: true }, + { name: '๐Ÿ“… Account Age', value: autoVerify?.accountAgeDays ? `\`${autoVerify.accountAgeDays}\` days` : '`N/A`', inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + ); + + if (conflictSummary) { + embed.addFields({ name: 'โš ๏ธ Setup Conflicts', value: conflictSummary, inline: false }); + } + + return embed + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`autoverify_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Role') + .setDescription('Select the role to assign automatically') + .setValue('role') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Account Age Days') + .setDescription('Set minimum account age in days') + .setValue('account_age') + .setEmoji('๐Ÿ“…'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const autoVerifyOn = cfg.verification?.autoVerify?.enabled === true; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`autoverify_cfg_criteria_${guildId}`) + .setLabel('Change Criteria') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽฏ') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`autoverify_cfg_toggle_${guildId}`) + .setLabel('Auto-Verification') + .setStyle(autoVerifyOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿค–') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId, client) { + try { + const selectMenu = buildSelectMenu(guildId); + + // Get conflict summary + let conflictSummary = ''; + try { + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(cfg.verification?.enabled); + const autoRoleConfigured = Boolean(cfg.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + } + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh autoverify dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + + // Check if auto-verification is configured + if (!guildConfig.verification?.autoVerify?.enabled) { + // Check for blocking systems + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const blockingMessage = []; + if (verificationEnabled) blockingMessage.push('Verification system is enabled'); + if (autoRoleConfigured) blockingMessage.push('AutoRole is configured'); + + const blockingText = blockingMessage.length > 0 + ? `\n\nโš ๏ธ **To enable AutoVerify, you must first disable:**\n${blockingMessage.map(msg => `โ€ข ${msg}`).join('\n')}` + : ''; + + return await InteractionHelper.safeReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿค– Auto-Verification Dashboard') + .setDescription(`Auto-verification is not yet configured.${blockingText}\n\nUse \`/autoverify setup\` to configure it.`) + .setColor(getColor('warning')) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp() + ], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeDefer(interaction, { ephemeral: true }); + + const selectMenu = buildSelectMenu(guildId); + + // Get conflict summary + let conflictSummary = ''; + try { + const welcomeConfig = await getWelcomeConfig(client, guildId); + const verificationEnabled = Boolean(guildConfig.verification?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + verificationEnabled ? 'Verification system is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch autoverify dashboard conflicts:', error.message); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(guildConfig, interaction.guild, conflictSummary)], + components: [ + buildButtonRow(guildConfig, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + // โ”€โ”€ Select collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `autoverify_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'role': + await handleRole(selectInteraction, interaction, guildConfig, guildId, client); + break; + case 'account_age': + await handleAccountAge(selectInteraction, interaction, guildConfig, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Autoverify config validation error: ${error.message}`); + } else { + logger.error('Unexpected autoverify dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for buttons โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `autoverify_cfg_toggle_${guildId}` || i.customId === `autoverify_cfg_criteria_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + if (btnInteraction.customId === `autoverify_cfg_criteria_${guildId}`) { + await handleCriteria(btnInteraction, interaction, guildConfig, guildId, client); + } else if (btnInteraction.customId === `autoverify_cfg_toggle_${guildId}`) { + await btnInteraction.deferUpdate().catch(() => null); + guildConfig.verification.autoVerify.enabled = !guildConfig.verification.autoVerify.enabled; + await setGuildConfig(client, guildId, guildConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Status Updated', + `Auto-verification is now **${guildConfig.verification.autoVerify.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(interaction, guildConfig, guildId, client); + } + } catch (err) { + logger.debug('Button interaction error:', err.message); + } + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + const timeoutEmbed = new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')); + await InteractionHelper.safeEditReply(interaction, { + embeds: [timeoutEmbed], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in autoverify_dashboard:', error); + throw new TitanBotError( + `Auto-verification dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the auto-verification dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Handle Criteria โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleCriteria(selectInteraction, rootInteraction, guildConfig, guildId, client) { + // Defer the interaction if it's a button, otherwise it was already deferred by select menu + if (!selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => null); + } + + const criteriaEmbed = new EmbedBuilder() + .setTitle('๐ŸŽฏ Select Verification Criteria') + .setDescription('Choose the criteria for automatic verification') + .setColor(getColor('info')); + + const criteriaMenu = new StringSelectMenuBuilder() + .setCustomId('autoverify_criteria_select') + .setPlaceholder('Select criteria...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(`Account Age (older than ${defaultAccountAgeDays} days)`) + .setDescription('Users with older accounts will be auto-verified') + .setValue('account_age'), + new StringSelectMenuOptionBuilder() + .setLabel('No Criteria (verify everyone)') + .setDescription('All users gain the role immediately') + .setValue('none'), + ); + + await selectInteraction.followUp({ + embeds: [criteriaEmbed], + components: [new ActionRowBuilder().addComponents(criteriaMenu)], + flags: MessageFlags.Ephemeral, + }); + + const criteriaCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'autoverify_criteria_select', + time: 60_000, + max: 1, + }); + + criteriaCollector.on('collect', async criteriaInteraction => { + await criteriaInteraction.deferUpdate(); + const newCriteria = criteriaInteraction.values[0]; + + guildConfig.verification.autoVerify.criteria = newCriteria; + + // Reset age-related fields if not using them + if (newCriteria !== 'account_age') { + guildConfig.verification.autoVerify.accountAgeDays = null; + } else if (!guildConfig.verification.autoVerify.accountAgeDays) { + guildConfig.verification.autoVerify.accountAgeDays = defaultAccountAgeDays; + } + + await setGuildConfig(client, guildId, guildConfig); + + let criteriaDisplay = ''; + switch (newCriteria) { + case 'account_age': + criteriaDisplay = `Account Age (${guildConfig.verification.autoVerify.accountAgeDays} days)`; + break; + case 'none': + criteriaDisplay = 'No Criteria'; + break; + } + + await criteriaInteraction.followUp({ + embeds: [successEmbed('โœ… Criteria Updated', `Auto-verification criteria changed to **${criteriaDisplay}**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); + }); + + criteriaCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No criteria selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Handle Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRole(selectInteraction, rootInteraction, guildConfig, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('autoverify_role_select') + .setPlaceholder('Select a role...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿท๏ธ Auto-Verification Role') + .setDescription('Select the role to assign to auto-verified users.') + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'autoverify_role_select', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + + if (role.id === rootInteraction.guild.id || role.managed) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Invalid Role', + 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + const botMember = rootInteraction.guild.members.me; + if (role.position >= botMember.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + 'The selected role must be below my highest role in the server role hierarchy.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.verification.autoVerify.roleId = role.id; + await setGuildConfig(client, guildId, guildConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Updated', `Auto-verification role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Handle Account Age โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleAccountAge(selectInteraction, rootInteraction, guildConfig, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('autoverify_account_age_modal') + .setTitle('Set Account Age Requirement') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('age_input') + .setLabel('Minimum Account Age (days)') + .setStyle(TextInputStyle.Short) + .setPlaceholder(`Between ${minAccountAgeDays} and ${maxAccountAgeDays}`) + .setValue((guildConfig.verification.autoVerify.accountAgeDays || defaultAccountAgeDays).toString()) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'autoverify_account_age_modal' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const inputValue = submitted.fields.getTextInputValue('age_input').trim(); + const days = parseInt(inputValue, 10); + + if (isNaN(days) || days < minAccountAgeDays || days > maxAccountAgeDays) { + await submitted.reply({ + embeds: [errorEmbed('Invalid Input', `Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.`)], + flags: MessageFlags.Ephemeral, + }); + return; + } + + guildConfig.verification.autoVerify.accountAgeDays = days; + await setGuildConfig(client, guildId, guildConfig); + + await submitted.reply({ + embeds: [successEmbed('โœ… Account Age Updated', `Minimum account age requirement set to **${days} days**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, guildConfig, guildId, client); +} + +// โ”€โ”€โ”€ Handle Member Duration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js new file mode 100644 index 0000000000..371701581b --- /dev/null +++ b/src/commands/Verification/modules/verification_dashboard.js @@ -0,0 +1,699 @@ +import { botConfig, getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + RoleSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + 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 { getGuildConfig, setGuildConfig } from '../../../services/guildConfig.js'; +import { getWelcomeConfig } from '../../../utils/database.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Live Panel Sync โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function updateLivePanel(guild, cfg) { + if (!cfg.channelId || !cfg.messageId) return; + try { + const channel = guild.channels.cache.get(cfg.channelId); + if (!channel) return; + const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); + if (!msg) return; + + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + await msg.edit({ embeds: [verifyEmbed], components: [verifyButton] }); + } catch (error) { + logger.warn('Could not update live verification panel:', error.message); + } +} + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary = '') { + const channel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; + const role = cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'; + const rawMsg = cfg.message || botConfig.verification.defaultMessage; + const msgPreview = `\`${rawMsg.length > 60 ? rawMsg.substring(0, 60) + 'โ€ฆ' : rawMsg}\``; + const buttonText = cfg.buttonText || botConfig.verification.defaultButtonText; + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ”’ Verification System Dashboard') + .setDescription(`Manage verification settings for **${guild.name}**.\nSelect an option below to modify a setting.`) + .setColor(getColor('info')) + .addFields( + { name: '๐Ÿ“ข Verification Channel', value: channel, inline: true }, + { name: '๐Ÿท๏ธ Verified Role', value: role, inline: true }, + { name: 'โš™๏ธ System Status', value: cfg.enabled !== false ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”˜ Button Text', value: `\`${buttonText}\``, inline: true }, + { name: '๐Ÿ‘ฅ Verified Users', value: `${verifiedUserCount} users`, inline: true }, + { name: '\u200B', value: '\u200B', inline: true }, + { name: '๐Ÿ’ฌ Verification Message', value: msgPreview, inline: false }, + ); + + if (conflictSummary) { + embed.addFields({ name: 'โš ๏ธ Setup Conflicts', value: conflictSummary, inline: false }); + } + + return embed + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`verif_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Change Verification Channel') + .setDescription('Set the channel where the verification panel is posted') + .setValue('channel') + .setEmoji('๐Ÿ“ข'), + new StringSelectMenuOptionBuilder() + .setLabel('Change Verified Role') + .setDescription('Set the role assigned when a user verifies') + .setValue('role') + .setEmoji('๐Ÿท๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Verification Message') + .setDescription('Customise the message shown on the verification panel embed') + .setValue('message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Edit Button Text') + .setDescription('Change the label on the verify button') + .setValue('button_text') + .setEmoji('๐Ÿ”˜'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const systemOn = cfg.enabled !== false; + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`verif_cfg_toggle_${guildId}`) + .setLabel('Verification') + .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ”’') + .setDisabled(disabled), + ); +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId, client) { + try { + const selectMenu = buildSelectMenu(guildId); + + // Get verified user count and conflict summary + let verifiedUserCount = 0; + let conflictSummary = ''; + + try { + const verifiedRole = rootInteraction.guild.roles.cache.get(cfg.roleId); + if (verifiedRole) { + verifiedUserCount = verifiedRole.members.size; + } + + const guildConfig = await getGuildConfig(client, guildId); + const welcomeConfig = await getWelcomeConfig(client, guildId); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + autoVerifyEnabled ? 'AutoVerify is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch verification dashboard details:', error.message); + } + + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild, verifiedUserCount, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh verification dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const guildConfig = await getGuildConfig(client, guildId); + const cfg = guildConfig.verification; + + if (!cfg?.channelId) { + throw new TitanBotError( + 'Verification not configured', + ErrorTypes.CONFIGURATION, + 'The verification system has not been set up yet. Run `/verification setup` first.', + ); + } + + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + const selectMenu = buildSelectMenu(guildId); + + // Get verified user count and conflict summary + let verifiedUserCount = 0; + let conflictSummary = ''; + + try { + const verifiedRole = interaction.guild.roles.cache.get(cfg.roleId); + if (verifiedRole) { + verifiedUserCount = verifiedRole.members.size; + } + + const welcomeConfig = await getWelcomeConfig(client, guildId); + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + const autoRoleConfigured = Boolean(guildConfig.autoRole) || (Array.isArray(welcomeConfig.roleIds) && welcomeConfig.roleIds.length > 0); + + const conflicts = [ + autoVerifyEnabled ? 'AutoVerify is enabled' : null, + autoRoleConfigured ? 'AutoRole is configured' : null + ].filter(Boolean); + + if (conflicts.length > 0) { + conflictSummary = conflicts.join('\n'); + } + } catch (error) { + logger.warn('Could not fetch verification dashboard details:', error.message); + } + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild, verifiedUserCount, conflictSummary)], + components: [ + buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `verif_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'channel': + await handleChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'role': + await handleRole(selectInteraction, interaction, cfg, guildId, client); + break; + case 'message': + await handleMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'button_text': + await handleButtonText(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Verification config validation error: ${error.message}`); + } else { + logger.error('Unexpected verification dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for toggle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + i.customId === `verif_cfg_toggle_${guildId}`, + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + + const wasEnabled = cfg.enabled !== false; + const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); + + // Prevent enabling Verification if AutoVerify is enabled + if (!wasEnabled && autoVerifyEnabled) { + await btnInteraction.followUp({ + embeds: [errorEmbed( + 'โŒ Cannot Enable Verification', + 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.' + )], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.enabled = !wasEnabled; + + // Disabling โ€” remove the live panel message from the channel + if (!cfg.enabled && cfg.channelId && cfg.messageId) { + const channel = interaction.guild.channels.cache.get(cfg.channelId); + if (channel) { + try { + const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); + if (msg) await msg.delete(); + } catch { + // already gone + } + } + } + + // Re-enabling โ€” re-post the verification panel in the configured channel + if (cfg.enabled && cfg.channelId) { + const channel = interaction.guild.channels.cache.get(cfg.channelId); + if (channel) { + try { + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + const newMsg = await channel.send({ embeds: [verifyEmbed], components: [verifyButton] }); + cfg.messageId = newMsg.id; + } catch (error) { + logger.warn('Could not re-post verification panel on re-enable:', error.message); + } + } + } + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… System Updated', + `The verification system is now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(interaction, cfg, guildId, client); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')) + ], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in verification_dashboard:', error); + throw new TitanBotError( + `Verification dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the verification dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Change Verification Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('verif_cfg_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ“ข Change Verification Channel') + .setDescription( + `**Current:** ${cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'}\n\nSelect the channel where the verification panel will be posted.\n\n> โš ๏ธ The existing panel will be deleted and re-posted in the new channel.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const newChannel = chanInteraction.channels.first(); + + if (!botHasPermission(newChannel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** permissions in ${newChannel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + // Delete old panel if it exists + if (cfg.channelId && cfg.messageId) { + const oldChannel = rootInteraction.guild.channels.cache.get(cfg.channelId); + if (oldChannel) { + try { + const oldMsg = await oldChannel.messages.fetch(cfg.messageId).catch(() => null); + if (oldMsg) await oldMsg.delete(); + } catch { + // already gone + } + } + } + + // Post new panel in the new channel (only if system is enabled) + if (cfg.enabled !== false) { + try { + const verifyEmbed = new EmbedBuilder() + .setTitle('โœ… Server Verification') + .setDescription(cfg.message || botConfig.verification.defaultMessage) + .setColor(getColor('success')); + + const verifyButton = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('verify_user') + .setLabel(cfg.buttonText || botConfig.verification.defaultButtonText) + .setStyle(ButtonStyle.Success) + .setEmoji('โœ…'), + ); + + const newMsg = await newChannel.send({ embeds: [verifyEmbed], components: [verifyButton] }); + cfg.messageId = newMsg.id; + } catch (error) { + logger.warn('Could not post verification panel in new channel:', error.message); + } + } + + cfg.channelId = newChannel.id; + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Verification panel moved to ${newChannel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Change Verified Role โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleRole(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('verif_cfg_role') + .setPlaceholder('Select a role...') + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿท๏ธ Change Verified Role') + .setDescription( + `**Current:** ${cfg.roleId ? `<@&${cfg.roleId}>` : '`Not set`'}\n\nSelect the role to assign when a user verifies.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(roleSelect)], + flags: MessageFlags.Ephemeral, + }); + + const roleCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.RoleSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'verif_cfg_role', + time: 60_000, + max: 1, + }); + + roleCollector.on('collect', async roleInteraction => { + await roleInteraction.deferUpdate(); + const role = roleInteraction.roles.first(); + const guild = rootInteraction.guild; + const botMember = guild.members.me; + + if (role.id === guild.id || role.managed) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Invalid Role', + 'Please choose a normal assignable role (not @everyone or a bot-managed role).', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + if (role.position >= botMember.roles.highest.position) { + await roleInteraction.followUp({ + embeds: [ + errorEmbed( + 'Role Too High', + 'The verified role must be below my highest role in the server role hierarchy.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.roleId = role.id; + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await roleInteraction.followUp({ + embeds: [successEmbed('โœ… Role Updated', `Verified role set to ${role}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + }); + + roleCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No role was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Edit Verification Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + try { + const modal = new ModalBuilder() + .setCustomId('verif_cfg_message') + .setTitle('Edit Verification Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message shown on the verification panel embed') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.message || botConfig.verification.defaultMessage) + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'verif_cfg_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.message = submitted.fields.getTextInputValue('message_input').trim(); + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await updateLivePanel(rootInteraction.guild, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Message Updated', 'The verification panel has been updated with the new message.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + } catch (error) { + logger.error('Error in handleMessage:', error); + // Silently fail - modal display failed, user can try again + } +} + +// โ”€โ”€โ”€ Edit Button Text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleButtonText(selectInteraction, rootInteraction, cfg, guildId, client) { + try { + const modal = new ModalBuilder() + .setCustomId('verif_cfg_button_text') + .setTitle('Edit Button Text') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('button_text_input') + .setLabel('Button label (max 80 characters)') + .setStyle(TextInputStyle.Short) + .setValue(cfg.buttonText || botConfig.verification.defaultButtonText) + .setMaxLength(80) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'verif_cfg_button_text' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.buttonText = submitted.fields.getTextInputValue('button_text_input').trim(); + + const latestConfig = await getGuildConfig(client, guildId); + latestConfig.verification = cfg; + await setGuildConfig(client, guildId, latestConfig); + + await updateLivePanel(rootInteraction.guild, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Button Text Updated', `The verify button now reads **${cfg.buttonText}**.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId, client); + } catch (error) { + logger.error('Error in handleButtonText:', error); + // Silently fail - modal display failed, user can try again + } +} diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js index 27e77604a5..5852451833 100644 --- a/src/commands/Verification/verification.js +++ b/src/commands/Verification/verification.js @@ -8,6 +8,7 @@ import { ContextualMessages } from '../../utils/messageTemplates.js'; import { logger } from '../../utils/logger.js'; import { InteractionHelper } from '../../utils/interactionHelper.js'; import { getWelcomeConfig } from '../../utils/database.js'; +import verificationDashboard from './modules/verification_dashboard.js'; export default { data: new SlashCommandBuilder() @@ -45,11 +46,6 @@ export default { .setRequired(false) ) ) - .addSubcommand(subcommand => - subcommand - .setName("verify") - .setDescription("Verify yourself (for users to use)") - ) .addSubcommand(subcommand => subcommand .setName("remove") @@ -63,13 +59,8 @@ export default { ) .addSubcommand(subcommand => subcommand - .setName("disable") - .setDescription("Disable the verification system") - ) - .addSubcommand(subcommand => - subcommand - .setName("status") - .setDescription("Check verification system status") + .setName("dashboard") + .setDescription("Open the verification system configuration dashboard") ), async execute(interaction, config, client) { @@ -77,7 +68,7 @@ export default { const subcommand = interaction.options.getSubcommand(); const guild = interaction.guild; - if (subcommand !== 'verify' && !interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { throw createError( 'Missing ManageGuild permission for verification admin subcommand', ErrorTypes.PERMISSION, @@ -89,14 +80,10 @@ export default { switch (subcommand) { case "setup": return await handleSetup(interaction, guild, client); - case "verify": - return await handleVerify(interaction, guild, client); case "remove": return await handleRemove(interaction, guild, client); - case "disable": - return await handleDisable(interaction, guild, client); - case "status": - return await handleStatus(interaction, guild, client); + case "dashboard": + return await verificationDashboard.execute(interaction, config, client); default: throw createError( `Unknown subcommand: ${subcommand}`, @@ -237,38 +224,6 @@ async function handleSetup(interaction, guild, client) { }); } -async function handleVerify(interaction, guild, client) { - const result = await verifyUser(client, guild.id, interaction.user.id, { - source: 'command_self', - moderatorId: null - }); - - if (!result.success) { - if (result.alreadyVerified) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Already Verified", "You are already verified.")], - flags: MessageFlags.Ephemeral - }); - } - - return await InteractionHelper.safeReply(interaction, { - embeds: [errorEmbed( - "Verification Failed", - "An error occurred during verification. Please try again or contact an administrator." - )], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed( - "Verification Complete", - `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` - )], - flags: MessageFlags.Ephemeral - }); -} - async function handleRemove(interaction, guild, client) { const targetUser = interaction.options.getUser("user"); @@ -306,107 +261,6 @@ async function handleRemove(interaction, guild, client) { } } -async function handleDisable(interaction, guild, client) { - const guildConfig = await getGuildConfig(client, guild.id); - - if (!guildConfig.verification?.enabled) { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed("Already Disabled", "The verification system is already disabled.")], - flags: MessageFlags.Ephemeral - }); - } - - await InteractionHelper.safeDefer(interaction); - - if (guildConfig.verification.channelId && guildConfig.verification.messageId) { - const channel = guild.channels.cache.get(guildConfig.verification.channelId); - if (channel) { - try { - const message = await channel.messages.fetch(guildConfig.verification.messageId); - if (message) { - await message.delete(); - } - } catch (error) { - logger.info("Could not delete verification message (may have been deleted already):", error.message); - } - } - } - - guildConfig.verification.enabled = false; - await setGuildConfig(client, guild.id, guildConfig); - - await InteractionHelper.safeEditReply(interaction, { - 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.${conflictSummary ? `\n\nโš ๏ธ **Setup Blockers:**\n${conflictSummary}` : ''}` - )], - flags: MessageFlags.Ephemeral - }); - } - - const verificationChannel = guild.channels.cache.get(guildConfig.verification.channelId); - const verifiedRole = guild.roles.cache.get(guildConfig.verification.roleId); - - const statusEmbed = createEmbed({ - title: "โœ… Verification System Status", - description: "Current verification system configuration:", - color: getColor('success') - }) - .addFields( - { - name: "๐Ÿ“ข Verification Channel", - value: verificationChannel ? verificationChannel.toString() : "Not found", - inline: true - }, - { - name: "๐Ÿท๏ธ Verified Role", - value: verifiedRole ? verifiedRole.toString() : "Not found", - inline: true - }, - { - name: "๐Ÿ”˜ Button Text", - value: guildConfig.verification.buttonText || "Verify", - inline: true - }, - { - name: "๐Ÿ“ Custom Message", - value: guildConfig.verification.message ? "โœ… Configured" : "โŒ Not set", - inline: true - }, - { - name: "๐Ÿ‘ฅ Verified Users", - value: verifiedRole ? `${verifiedRole.members.size} users` : "Unknown", - inline: true - }, - { - name: "โš ๏ธ Setup Conflicts", - value: conflictSummary || "None", - inline: false - } - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [statusEmbed], - flags: MessageFlags.Ephemeral - }); -} - diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.js new file mode 100644 index 0000000000..a49ff844a7 --- /dev/null +++ b/src/commands/Verification/verify.js @@ -0,0 +1,50 @@ +import { SlashCommandBuilder, MessageFlags } from 'discord.js'; +import { errorEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; +import { withErrorHandling } from '../../utils/errorHandler.js'; +import { verifyUser } from '../../services/verificationService.js'; +import { logger } from '../../utils/logger.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; + +export default { + data: new SlashCommandBuilder() + .setName('verify') + .setDescription('Verify yourself and gain access to the server'), + + async execute(interaction, config, client) { + const wrappedExecute = withErrorHandling(async () => { + const guild = interaction.guild; + + const result = await verifyUser(client, guild.id, interaction.user.id, { + source: 'command_self', + moderatorId: null + }); + + if (!result.success) { + if (result.alreadyVerified) { + return await InteractionHelper.safeReply(interaction, { + embeds: [infoEmbed("Already Verified", "You are already verified.")], + flags: MessageFlags.Ephemeral + }); + } + + return await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed( + "Verification Failed", + "An error occurred during verification. Please try again or contact an administrator." + )], + flags: MessageFlags.Ephemeral + }); + } + + await InteractionHelper.safeReply(interaction, { + embeds: [successEmbed( + "Verification Complete", + `You have been verified and given the **${result.roleName}** role! Welcome to the server! ๐ŸŽ‰` + )], + flags: MessageFlags.Ephemeral + }); + }, { command: 'verify' }); + + return await wrappedExecute(interaction, config, client); + } +}; diff --git a/src/commands/Welcome/goodbye.js b/src/commands/Welcome/goodbye.js index 579d1762f9..dcfd25e318 100644 --- a/src/commands/Welcome/goodbye.js +++ b/src/commands/Welcome/goodbye.js @@ -5,7 +5,6 @@ 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() @@ -32,15 +31,7 @@ export default { .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')) - .addSubcommand(subcommand => - subcommand - .setName('config') - .setDescription('Customize your existing goodbye setup with buttons')), + .setRequired(false))), async execute(interaction) { const deferSuccess = await InteractionHelper.safeDefer(interaction); @@ -135,7 +126,7 @@ export default { { name: 'Ping User', value: ping ? 'โœ… Yes' : 'โŒ No' }, { name: 'Status', value: 'โœ… Enabled' } ) - .setFooter({ text: 'Tip: Use /goodbye toggle to enable/disable goodbye messages' }); + .setFooter({ text: 'Tip: Use /goodbye config to customize goodbye settings' }); if (image) { embed.setImage(image); @@ -154,63 +145,6 @@ export default { }); } } - 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 { - const currentConfig = await getWelcomeConfig(client, guild.id); - const newStatus = !currentConfig.goodbyeEnabled; - - await updateWelcomeConfig(client, guild.id, { - goodbyeEnabled: newStatus - }); - - logger.info(`[Goodbye] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Toggle Failed', - 'An error occurred while toggling goodbye messages. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } - } }, }; diff --git a/src/commands/Welcome/greet.js b/src/commands/Welcome/greet.js new file mode 100644 index 0000000000..ce2b93e17e --- /dev/null +++ b/src/commands/Welcome/greet.js @@ -0,0 +1,51 @@ +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; +import { errorEmbed } from '../../utils/embeds.js'; +import { InteractionHelper } from '../../utils/interactionHelper.js'; +import { logger } from '../../utils/logger.js'; +import { handleInteractionError, TitanBotError } from '../../utils/errorHandler.js'; +import greetDashboard from './modules/greet_dashboard.js'; + +export default { + data: new SlashCommandBuilder() + .setName('greet') + .setDescription('Manage welcome & goodbye settings') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand(subcommand => + subcommand + .setName('dashboard') + .setDescription('Open the welcome & goodbye configuration dashboard'), + ), + + async execute(interaction, config, client) { + try { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { + return await InteractionHelper.safeReply(interaction, { + embeds: [ + errorEmbed( + 'Missing Permissions', + 'You need the **Manage Server** permission to use `/greet`.', + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'dashboard': + return await greetDashboard.execute(interaction, config, client); + default: + logger.warn(`Unknown /greet subcommand: ${subcommand}`); + } + } catch (error) { + if (error instanceof TitanBotError) { + return await InteractionHelper.safeReply(interaction, { + embeds: [errorEmbed('Configuration Error', error.userMessage || 'Something went wrong.')], + flags: MessageFlags.Ephemeral, + }); + } + await handleInteractionError(interaction, error, { command: 'greet' }); + } + }, +}; diff --git a/src/commands/Welcome/modules/goodbyeConfig.js b/src/commands/Welcome/modules/goodbyeConfig.js deleted file mode 100644 index 79875b29e2..0000000000 --- a/src/commands/Welcome/modules/goodbyeConfig.js +++ /dev/null @@ -1,182 +0,0 @@ -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/greet_dashboard.js b/src/commands/Welcome/modules/greet_dashboard.js new file mode 100644 index 0000000000..9302c9f036 --- /dev/null +++ b/src/commands/Welcome/modules/greet_dashboard.js @@ -0,0 +1,744 @@ +import { getColor } from '../../../config/bot.js'; +import { + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, + ChannelSelectMenuBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + MessageFlags, + ComponentType, + 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 { getWelcomeConfig, saveWelcomeConfig } from '../../../utils/database.js'; +import { botHasPermission } from '../../../utils/permissionGuard.js'; + +// โ”€โ”€โ”€ Embed & Menu Builders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildDashboardEmbed(cfg, guild) { + const welcomeChannel = cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'; + const goodbyeChannel = cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'; + + const rawWelcome = cfg.welcomeMessage || 'Welcome {user} to {server}!'; + const rawGoodbye = cfg.leaveMessage || '{user.tag} has left the server.'; + const welcomePreview = `\`${rawWelcome.length > 55 ? rawWelcome.substring(0, 55) + 'โ€ฆ' : rawWelcome}\``; + const goodbyePreview = `\`${rawGoodbye.length > 55 ? rawGoodbye.substring(0, 55) + 'โ€ฆ' : rawGoodbye}\``; + + return new EmbedBuilder() + .setTitle('๐Ÿ‘‹ Greet System Dashboard') + .setDescription( + `Manage welcome & goodbye settings for **${guild.name}**.\nUse the toggles to enable/disable each side, then select an option to edit.`, + ) + .setColor(getColor('info')) + .addFields( + { name: '๐ŸŸข Welcome Channel', value: welcomeChannel, inline: true }, + { name: 'โš™๏ธ Welcome Status', value: cfg.enabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”” Welcome Ping', value: cfg.welcomePing ? 'โœ… On' : 'โŒ Off', inline: true }, + { name: '๐Ÿ”ด Goodbye Channel', value: goodbyeChannel, inline: true }, + { name: 'โš™๏ธ Goodbye Status', value: cfg.goodbyeEnabled ? 'โœ… Enabled' : 'โŒ Disabled', inline: true }, + { name: '๐Ÿ”” Goodbye Ping', value: cfg.goodbyePing ? 'โœ… On' : 'โŒ Off', inline: true }, + { name: '๐Ÿ’ฌ Welcome Message', value: welcomePreview, inline: false }, + { name: '๐Ÿ’ฌ Goodbye Message', value: goodbyePreview, inline: false }, + ) + .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) + .setTimestamp(); +} + +function buildSelectMenu(guildId) { + return new StringSelectMenuBuilder() + .setCustomId(`greet_cfg_${guildId}`) + .setPlaceholder('Select a setting to configure...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Channel') + .setDescription('Set the channel where welcome messages are sent') + .setValue('welcome_channel') + .setEmoji('๐ŸŸข'), + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Message') + .setDescription('Edit the text shown when a member joins') + .setValue('welcome_message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Welcome Image') + .setDescription('Set the image for welcome messages') + .setValue('welcome_image') + .setEmoji('๐Ÿ–ผ๏ธ'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Channel') + .setDescription('Set the channel where goodbye messages are sent') + .setValue('goodbye_channel') + .setEmoji('๐Ÿ”ด'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Message') + .setDescription('Edit the text shown when a member leaves') + .setValue('goodbye_message') + .setEmoji('๐Ÿ’ฌ'), + new StringSelectMenuOptionBuilder() + .setLabel('Goodbye Image') + .setDescription('Set the image for goodbye messages') + .setValue('goodbye_image') + .setEmoji('๐Ÿ–ผ๏ธ'), + ); +} + +function buildButtonRow(cfg, guildId, disabled = false) { + const welcomeOn = cfg.enabled === true; + const goodbyeOn = cfg.goodbyeEnabled === true; + const welcomePingOn = cfg.welcomePing === true; + const goodbyePingOn = cfg.goodbyePing === true; + + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`greet_cfg_toggle_welcome_${guildId}`) + .setLabel('Welcome') + .setStyle(welcomeOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐ŸŸข') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`greet_cfg_toggle_goodbye_${guildId}`) + .setLabel('Goodbye') + .setStyle(goodbyeOn ? ButtonStyle.Success : ButtonStyle.Danger) + .setEmoji('๐Ÿ”ด') + .setDisabled(disabled), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`greet_cfg_ping_welcome_${guildId}`) + .setLabel('Ping Welcome') + .setStyle(welcomePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('๐Ÿ””') + .setDisabled(disabled), + new ButtonBuilder() + .setCustomId(`greet_cfg_ping_goodbye_${guildId}`) + .setLabel('Ping Goodbye') + .setStyle(goodbyePingOn ? ButtonStyle.Primary : ButtonStyle.Secondary) + .setEmoji('๐Ÿ””') + .setDisabled(disabled), + ), + ]; +} + +// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function refreshDashboard(rootInteraction, cfg, guildId) { + try { + const selectMenu = buildSelectMenu(guildId); + await InteractionHelper.safeEditReply(rootInteraction, { + embeds: [buildDashboardEmbed(cfg, rootInteraction.guild)], + components: [ + ...buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not refresh greet dashboard (interaction may have expired):', error.message); + } +} + +// โ”€โ”€โ”€ Main Export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export default { + async execute(interaction, config, client) { + try { + const guildId = interaction.guild.id; + const cfg = await getWelcomeConfig(client, guildId); + + if (!cfg.channelId && !cfg.goodbyeChannelId) { + throw new TitanBotError( + 'Greet system not configured', + ErrorTypes.CONFIGURATION, + 'Neither Welcome nor Goodbye has been set up yet. Run `/welcome setup` or `/goodbye setup` first.', + ); + } + + await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); + + const selectMenu = buildSelectMenu(guildId); + + await InteractionHelper.safeEditReply(interaction, { + embeds: [buildDashboardEmbed(cfg, interaction.guild)], + components: [ + ...buildButtonRow(cfg, guildId), + new ActionRowBuilder().addComponents(selectMenu), + ], + flags: MessageFlags.Ephemeral, + }); + + // โ”€โ”€ Select collector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const collector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + filter: i => + i.user.id === interaction.user.id && i.customId === `greet_cfg_${guildId}`, + time: 600_000, + }); + + collector.on('collect', async selectInteraction => { + const selectedOption = selectInteraction.values[0]; + try { + switch (selectedOption) { + case 'welcome_channel': + await handleWelcomeChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'welcome_message': + await handleWelcomeMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'welcome_image': + await handleWelcomeImage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_channel': + await handleGoodbyeChannel(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_message': + await handleGoodbyeMessage(selectInteraction, interaction, cfg, guildId, client); + break; + case 'goodbye_image': + await handleGoodbyeImage(selectInteraction, interaction, cfg, guildId, client); + break; + } + } catch (error) { + if (error instanceof TitanBotError) { + logger.debug(`Greet config validation error: ${error.message}`); + } else { + logger.error('Unexpected greet dashboard error:', error); + } + + const errorMessage = + error instanceof TitanBotError + ? error.userMessage || 'An error occurred while processing your selection.' + : 'An unexpected error occurred while updating the configuration.'; + + if (!selectInteraction.replied && !selectInteraction.deferred) { + await selectInteraction.deferUpdate().catch(() => {}); + } + + await selectInteraction + .followUp({ + embeds: [errorEmbed('Configuration Error', errorMessage)], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); + + // โ”€โ”€ Button collector for toggles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const btnCollector = interaction.channel.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: i => + i.user.id === interaction.user.id && + (i.customId === `greet_cfg_toggle_welcome_${guildId}` || + i.customId === `greet_cfg_toggle_goodbye_${guildId}` || + i.customId === `greet_cfg_ping_welcome_${guildId}` || + i.customId === `greet_cfg_ping_goodbye_${guildId}`), + time: 600_000, + }); + + btnCollector.on('collect', async btnInteraction => { + try { + await btnInteraction.deferUpdate().catch(() => null); + } catch (err) { + logger.debug('Button interaction already expired:', err.message); + return; + } + const customId = btnInteraction.customId; + + if (customId === `greet_cfg_toggle_welcome_${guildId}`) { + cfg.enabled = !cfg.enabled; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Updated', + `Welcome messages are now **${cfg.enabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_toggle_goodbye_${guildId}`) { + cfg.goodbyeEnabled = !cfg.goodbyeEnabled; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Updated', + `Goodbye messages are now **${cfg.goodbyeEnabled ? 'enabled' : 'disabled'}**.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_ping_welcome_${guildId}`) { + cfg.welcomePing = !cfg.welcomePing; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Ping Updated', + `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } else if (customId === `greet_cfg_ping_goodbye_${guildId}`) { + cfg.goodbyePing = !cfg.goodbyePing; + await saveWelcomeConfig(client, guildId, cfg); + await btnInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Ping Updated', + `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + } + + await refreshDashboard(interaction, cfg, guildId); + }); + + collector.on('end', async (collected, reason) => { + if (reason === 'time') { + btnCollector.stop(); + try { + await InteractionHelper.safeEditReply(interaction, { + embeds: [ + new EmbedBuilder() + .setTitle('โฐ Dashboard Timed Out') + .setDescription('This dashboard has been closed due to inactivity. Please run the command again to continue.') + .setColor(getColor('error')) + ], + components: [], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + logger.debug('Could not update dashboard on timeout:', error.message); + } + } + }); + } catch (error) { + if (error instanceof TitanBotError) throw error; + logger.error('Unexpected error in greet_dashboard:', error); + throw new TitanBotError( + `Greet dashboard failed: ${error.message}`, + ErrorTypes.UNKNOWN, + 'Failed to open the greet dashboard.', + ); + } + }, +}; + +// โ”€โ”€โ”€ Welcome Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('greet_cfg_welcome_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐ŸŸข Welcome Channel') + .setDescription( + `**Current:** ${cfg.channelId ? `<#${cfg.channelId}>` : '`Not set`'}\n\nSelect the channel where welcome messages will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_welcome_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.channelId = channel.id; + await saveWelcomeConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Welcome messages will now be sent in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Welcome Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_welcome_message') + .setTitle('Edit Welcome Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message (variables: {user}, {server}, etc)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.welcomeMessage || 'Welcome {user} to {server}!') + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_welcome_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.welcomeMessage = submitted.fields.getTextInputValue('message_input').trim(); + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Welcome Message Updated', 'The welcome message has been saved.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Welcome Image โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomeImage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_welcome_image') + .setTitle('Set Welcome Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/welcome.png') + .setStyle(TextInputStyle.Short) + .setValue(cfg.welcomeImage || '') + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_welcome_image' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + + // Validate image URL + if (imageUrl) { + try { + new URL(imageUrl); + if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } catch { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } + + cfg.welcomeImage = imageUrl || null; + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Welcome Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Welcome Ping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleWelcomePing(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + cfg.welcomePing = !cfg.welcomePing; + await saveWelcomeConfig(client, guildId, cfg); + + await selectInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Welcome Ping Updated', + `Joining users will${cfg.welcomePing ? '' : ' **not**'} be pinged in the welcome message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Channel โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeChannel(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('greet_cfg_goodbye_channel') + .setPlaceholder('Select a text channel...') + .addChannelTypes(ChannelType.GuildText) + .setMaxValues(1); + + await selectInteraction.followUp({ + embeds: [ + new EmbedBuilder() + .setTitle('๐Ÿ”ด Goodbye Channel') + .setDescription( + `**Current:** ${cfg.goodbyeChannelId ? `<#${cfg.goodbyeChannelId}>` : '`Not set`'}\n\nSelect the channel where goodbye messages will be sent.`, + ) + .setColor(getColor('info')), + ], + components: [new ActionRowBuilder().addComponents(channelSelect)], + flags: MessageFlags.Ephemeral, + }); + + const chanCollector = rootInteraction.channel.createMessageComponentCollector({ + componentType: ComponentType.ChannelSelect, + filter: i => + i.user.id === selectInteraction.user.id && i.customId === 'greet_cfg_goodbye_channel', + time: 60_000, + max: 1, + }); + + chanCollector.on('collect', async chanInteraction => { + await chanInteraction.deferUpdate(); + const channel = chanInteraction.channels.first(); + + if (!botHasPermission(channel, ['ViewChannel', 'SendMessages', 'EmbedLinks'])) { + await chanInteraction.followUp({ + embeds: [ + errorEmbed( + 'Missing Permissions', + `I need **View Channel**, **Send Messages**, and **Embed Links** in ${channel}.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + return; + } + + cfg.goodbyeChannelId = channel.id; + await saveWelcomeConfig(client, guildId, cfg); + + await chanInteraction.followUp({ + embeds: [successEmbed('โœ… Channel Updated', `Goodbye messages will now be sent in ${channel}.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); + }); + + chanCollector.on('end', (collected, reason) => { + if (reason === 'time' && collected.size === 0) { + selectInteraction + .followUp({ + embeds: [errorEmbed('Timed Out', 'No channel was selected. The setting was not changed.')], + flags: MessageFlags.Ephemeral, + }) + .catch(() => {}); + } + }); +} + +// โ”€โ”€โ”€ Goodbye Message โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeMessage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_goodbye_message') + .setTitle('Edit Goodbye Message') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Message (variables: {user}, {server}, etc)') + .setStyle(TextInputStyle.Paragraph) + .setValue(cfg.leaveMessage || '{user.tag} has left the server.') + .setMaxLength(2000) + .setMinLength(1) + .setRequired(true), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_goodbye_message' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + cfg.leaveMessage = submitted.fields.getTextInputValue('message_input').trim(); + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Goodbye Message Updated', 'The goodbye message has been saved.')], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Image โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyeImage(selectInteraction, rootInteraction, cfg, guildId, client) { + const modal = new ModalBuilder() + .setCustomId('greet_cfg_goodbye_image') + .setTitle('Set Goodbye Image') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('image_input') + .setLabel('Image URL (leave blank to remove)') + .setPlaceholder('https://example.com/goodbye.png') + .setStyle(TextInputStyle.Short) + .setValue( + typeof cfg.leaveEmbed?.image === 'string' + ? cfg.leaveEmbed.image + : cfg.leaveEmbed?.image?.url || '' + ) + .setRequired(false), + ), + ); + + await selectInteraction.showModal(modal); + + const submitted = await selectInteraction + .awaitModalSubmit({ + filter: i => + i.customId === 'greet_cfg_goodbye_image' && i.user.id === selectInteraction.user.id, + time: 120_000, + }) + .catch(() => null); + + if (!submitted) return; + + const imageUrl = submitted.fields.getTextInputValue('image_input').trim(); + + // Validate image URL + if (imageUrl) { + try { + new URL(imageUrl); + if (!['http:', 'https:'].includes(new URL(imageUrl).protocol)) { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Image URL must start with `http://` or `https://`.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } catch { + await submitted.reply({ + embeds: [errorEmbed('Invalid URL', 'Please provide a valid image URL.')], + flags: MessageFlags.Ephemeral, + }); + return; + } + } + + const nextLeaveEmbed = { ...(cfg.leaveEmbed || {}) }; + if (imageUrl) { + nextLeaveEmbed.image = imageUrl; + } else { + delete nextLeaveEmbed.image; + } + + cfg.leaveEmbed = nextLeaveEmbed; + await saveWelcomeConfig(client, guildId, cfg); + + await submitted.reply({ + embeds: [successEmbed('โœ… Goodbye Image Updated', `Image ${imageUrl ? 'updated' : 'removed'} successfully.`)], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} + +// โ”€โ”€โ”€ Goodbye Ping โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleGoodbyePing(selectInteraction, rootInteraction, cfg, guildId, client) { + await selectInteraction.deferUpdate(); + + cfg.goodbyePing = !cfg.goodbyePing; + await saveWelcomeConfig(client, guildId, cfg); + + await selectInteraction.followUp({ + embeds: [ + successEmbed( + 'โœ… Goodbye Ping Updated', + `Leaving users will${cfg.goodbyePing ? '' : ' **not**'} be pinged in the goodbye message.`, + ), + ], + flags: MessageFlags.Ephemeral, + }); + + await refreshDashboard(rootInteraction, cfg, guildId); +} diff --git a/src/commands/Welcome/modules/welcomeConfig.js b/src/commands/Welcome/modules/welcomeConfig.js deleted file mode 100644 index ca6774c761..0000000000 --- a/src/commands/Welcome/modules/welcomeConfig.js +++ /dev/null @@ -1,172 +0,0 @@ -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 9c919e647d..02d864b62d 100644 --- a/src/commands/Welcome/welcome.js +++ b/src/commands/Welcome/welcome.js @@ -5,7 +5,6 @@ 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() @@ -32,15 +31,7 @@ export default { .addBooleanOption(option => option.setName('ping') .setDescription('Whether to ping the user in the welcome message') - .setRequired(false))) - .addSubcommand(subcommand => - subcommand - .setName('toggle') - .setDescription('Enable or disable welcome messages')) - .addSubcommand(subcommand => - subcommand - .setName('config') - .setDescription('Customize your existing welcome setup with buttons')), + .setRequired(false))), async execute(interaction) { try { @@ -133,7 +124,7 @@ export default { { name: 'Ping User', value: ping ? 'โœ… Yes' : 'โŒ No' }, { name: 'Status', value: 'โœ… Enabled' } ) - .setFooter({ text: 'Tip: Use /welcome toggle to enable/disable welcome messages' }); + .setFooter({ text: 'Tip: Use /welcome config to customize welcome settings' }); if (image) { embed.setImage(image); @@ -151,63 +142,6 @@ export default { flags: MessageFlags.Ephemeral }); } - } - 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 { - const currentConfig = await getWelcomeConfig(client, guild.id); - const newStatus = !currentConfig.enabled; - - await updateWelcomeConfig(client, guild.id, { - enabled: newStatus - }); - - logger.info(`[Welcome] Toggled to ${newStatus ? 'enabled' : 'disabled'} by ${interaction.user.tag} in guild ${guild.name} (${guild.id})`); - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [errorEmbed( - 'Toggle Failed', - 'An error occurred while toggling welcome messages. Please try again.', - { showDetails: true } - )], - flags: MessageFlags.Ephemeral - }); - } } }, }; diff --git a/src/events/channelDelete.js b/src/events/channelDelete.js index db21d9b6c5..3941e57745 100644 --- a/src/events/channelDelete.js +++ b/src/events/channelDelete.js @@ -1,14 +1,31 @@ import { getJoinToCreateConfig, removeJoinToCreateTrigger, - unregisterTemporaryChannel + unregisterTemporaryChannel, + getTicketData, + saveTicketData } from '../utils/database.js'; -import { getServerCounters, saveServerCounters } from '../services/counterService.js'; +import { getServerCounters, saveServerCounters } from '../services/serverstatsService.js'; import { logger } from '../utils/logger.js'; export default { name: 'channelDelete', async execute(channel, client) { + // Handle ticket text channel deletion + if (channel.type === 0 && channel.guild) { + try { + const ticketData = await getTicketData(channel.guild.id, channel.id); + if (ticketData && ticketData.status === 'open') { + ticketData.status = 'deleted'; + ticketData.closedAt = new Date().toISOString(); + await saveTicketData(channel.guild.id, channel.id, ticketData); + logger.info(`Ticket channel ${channel.id} was manually deleted in guild ${channel.guild.id}, marked as deleted`); + } + } catch (err) { + logger.warn(`Could not clean up ticket record for deleted channel ${channel.id}:`, err); + } + } + if (channel.type !== 2 && channel.type !== 4) { return; } diff --git a/src/events/guildMemberAdd.js b/src/events/guildMemberAdd.js index 0310155716..42c2d84507 100644 --- a/src/events/guildMemberAdd.js +++ b/src/events/guildMemberAdd.js @@ -4,7 +4,7 @@ import { getGuildConfig } from '../services/guildConfig.js'; 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 { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { setBirthday as dbSetBirthday } from '../utils/database.js'; import { logger } from '../utils/logger.js'; diff --git a/src/events/guildMemberRemove.js b/src/events/guildMemberRemove.js index a48aa6121f..97b4de438c 100644 --- a/src/events/guildMemberRemove.js +++ b/src/events/guildMemberRemove.js @@ -1,10 +1,11 @@ import { Events, EmbedBuilder, PermissionFlagsBits } from 'discord.js'; import { getColor } from '../config/bot.js'; -import { getWelcomeConfig } from '../utils/database.js'; +import { getWelcomeConfig, getUserApplications, deleteApplication } 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 { getServerCounters, updateCounter } from '../services/serverstatsService.js'; import { getGuildBirthdays, deleteBirthday } from '../utils/database.js'; +import { deleteUserLevelData } from '../services/leveling.js'; import { logger } from '../utils/logger.js'; export default { @@ -136,6 +137,27 @@ export default { logger.debug('Error handling birthday on member leave:', error); } + // Remove all pending applications when a member leaves + try { + const userApplications = await getUserApplications(member.client, guild.id, user.id); + if (userApplications && userApplications.length > 0) { + for (const app of userApplications) { + await deleteApplication(member.client, guild.id, app.id, user.id); + } + logger.debug(`Removed ${userApplications.length} applications for user ${user.id} in guild ${guild.id}`); + } + } catch (error) { + logger.debug('Error handling applications on member leave:', error); + } + + // Remove leveling data when a member leaves + try { + await deleteUserLevelData(member.client, guild.id, user.id); + logger.debug(`Removed leveling data for user ${user.id} in guild ${guild.id}`); + } catch (error) { + logger.debug('Error handling leveling data on member leave:', error); + } + } catch (error) { logger.error('Error in guildMemberRemove event:', error); } diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js index bfd3e9d2eb..450ace66bb 100644 --- a/src/events/interactionCreate.js +++ b/src/events/interactionCreate.js @@ -2,7 +2,7 @@ import { Events, MessageFlags } from 'discord.js'; import { logger } from '../utils/logger.js'; import { getGuildConfig } from '../services/guildConfig.js'; import { handleApplicationModal } from '../commands/Community/apply.js'; -import { handleApplicationButton, handleApplicationReviewModal } from '../commands/Community/app-admin.js'; +import { 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'; @@ -95,20 +95,134 @@ export default { commandName: interaction.commandName }, interactionTraceContext)); } - } else if (interaction.isButton()) { - if (interaction.customId.startsWith('app_approve_') || interaction.customId.startsWith('app_deny_')) { + } else if (interaction.isAutocomplete()) { + // Handle autocomplete interactions + const focusedOption = interaction.options.getFocused(true); + + if (interaction.commandName === 'apply' && focusedOption.name === 'application') { try { - await handleApplicationButton(interaction); + const { getApplicationRoles } = await import('../utils/database.js'); + const roles = await getApplicationRoles(client, interaction.guildId); + const roleName = interaction.options.getString('application', false); + + // Filter: only show enabled applications + const filtered = roles.filter(role => + role.enabled !== false && + role.name.toLowerCase().startsWith(roleName?.toLowerCase() || '') + ); + + await interaction.respond( + filtered.slice(0, 25).map(role => ({ + name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + value: role.name + })) + ); } catch (error) { - await handleInteractionError(interaction, error, withTraceContext({ - type: 'button', - customId: interaction.customId, - handler: 'application' - }, interactionTraceContext)); + logger.error('Error handling autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); + } + } else if (interaction.commandName === 'app-admin' && focusedOption.name === 'application') { + try { + const { getApplicationRoles } = await import('../utils/database.js'); + const roles = await getApplicationRoles(client, interaction.guildId); + const appName = interaction.options.getString('application', false); + + // Show all applications (enabled and disabled), but mark disabled ones + const filtered = roles.filter(role => + role.name.toLowerCase().startsWith(appName?.toLowerCase() || '') + ); + + await interaction.respond( + filtered.slice(0, 25).map(role => ({ + name: `${role.name}${role.enabled === false ? ' (disabled)' : ''}`, + value: role.name + })) + ); + } catch (error) { + logger.error('Error handling app-admin autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); + } + } else if (interaction.commandName === 'reactroles' && focusedOption.name === 'panel') { + try { + const { getAllReactionRoleMessages, deleteReactionRoleMessage } = await import('../services/reactionRoleService.js'); + const guildId = interaction.guildId; + const guild = interaction.guild; + + let panels = await getAllReactionRoleMessages(client, guildId); + + if (!panels || panels.length === 0) { + await interaction.respond([]); + return; + } + + // Filter out panels whose messages no longer exist + const validPanels = []; + for (const panel of panels) { + if (!panel.messageId || !panel.channelId) { + continue; + } + + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) { + await deleteReactionRoleMessage(client, guildId, panel.messageId).catch(() => {}); + continue; + } + validPanels.push(panel); + } + + if (validPanels.length === 0) { + await interaction.respond([]); + return; + } + + const choices = await Promise.all( + validPanels.slice(0, 25).map(async panel => { + try { + const channel = guild.channels.cache.get(panel.channelId); + if (!channel) return null; + + const msg = await channel.messages.fetch(panel.messageId).catch(() => null); + if (!msg) return null; + + const title = msg?.embeds?.[0]?.title ?? 'Untitled Panel'; + const channelName = channel?.name ?? 'unknown'; + + return { + name: `${title} (${channelName})`.substring(0, 100), + value: panel.messageId + }; + } catch (e) { + return null; + } + }) + ); + + const validChoices = choices.filter(c => c !== null); + await interaction.respond(validChoices); + } catch (error) { + logger.error('Error handling reactroles autocomplete:', { + error: error.message, + guildId: interaction.guildId, + commandName: interaction.commandName + }); + await interaction.respond([]); } - return; } - + } else if (interaction.isButton()) { if (interaction.customId.startsWith('shared_todo_')) { const parts = interaction.customId.split('_'); const buttonType = parts.slice(0, 3).join('_'); @@ -166,6 +280,13 @@ export default { const selectMenu = client.selectMenus.get(customId); if (!selectMenu) { + if (!interaction.customId.includes(':')) { + // No registered handler and no ':' delimiter โ€” this is an inline-collected + // select menu (e.g. ticket_config_, jointocreate_config_). + // Return silently so the existing MessageComponentCollector handles it. + return; + } + throw createError( `No select menu handler found for ${customId}`, ErrorTypes.CONFIGURATION, @@ -221,6 +342,12 @@ export default { const modal = client.modals.get(customId); if (!modal) { + if (!interaction.customId.includes(':')) { + // No registered handler and no ':' delimiter โ€” this is an inline-awaited + // modal (e.g. via awaitModalSubmit). Return silently so the caller handles it. + return; + } + throw createError( `No modal handler found for ${customId}`, ErrorTypes.CONFIGURATION, diff --git a/src/handlers/counterButtons.js b/src/handlers/counterButtons.js index 88380289ce..23b1a0d3f9 100644 --- a/src/handlers/counterButtons.js +++ b/src/handlers/counterButtons.js @@ -1,6 +1,6 @@ import { MessageFlags } from 'discord.js'; import { createEmbed, errorEmbed, successEmbed } from '../utils/embeds.js'; -import { performDeletionByCounterId } from '../commands/Counter/modules/counter_delete.js'; +import { performDeletionByCounterId } from '../commands/ServerStats/modules/serverstats_delete.js'; import { logger } from '../utils/logger.js'; export const counterDeleteActionHandler = { diff --git a/src/handlers/interactionHandlers/goodbyeConfigButton.js b/src/handlers/interactionHandlers/goodbyeConfigButton.js deleted file mode 100644 index 93b7ea9c3a..0000000000 --- a/src/handlers/interactionHandlers/goodbyeConfigButton.js +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 19dc944c8d..0000000000 --- a/src/handlers/interactionHandlers/goodbyeConfigModal.js +++ /dev/null @@ -1,119 +0,0 @@ -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/welcomeConfigButton.js b/src/handlers/interactionHandlers/welcomeConfigButton.js deleted file mode 100644 index 77847a6a43..0000000000 --- a/src/handlers/interactionHandlers/welcomeConfigButton.js +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index ce49097f84..0000000000 --- a/src/handlers/interactionHandlers/welcomeConfigModal.js +++ /dev/null @@ -1,104 +0,0 @@ -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/loggingButtons.js b/src/handlers/loggingButtons.js index 2eef33ab83..817a94d761 100644 --- a/src/handlers/loggingButtons.js +++ b/src/handlers/loggingButtons.js @@ -9,12 +9,12 @@ import { parseEventTypeFromButton } from '../utils/loggingUi.js'; import { logger } from '../utils/logger.js'; -import { buildLoggingStatusView } from '../commands/Config/modules/config_logging_status.js'; +import { buildLoggingDashboardView } from '../commands/Logging/modules/logging_dashboard.js'; const LOGGING_CATEGORIES = [...new Set(Object.values(EVENT_TYPES).map((eventType) => eventType.split('.')[0]))]; export default { - customIds: ['logging_toggle', 'logging_refresh_status'], + customIds: ['logging_toggle', 'logging_refresh_status', 'log_dash_toggle', 'log_dash_refresh'], async execute(interaction) { try { @@ -26,10 +26,18 @@ export default { }); } + // Dashboard-specific buttons + if (interaction.customId === 'log_dash_refresh') { + return await handleDashboardRefresh(interaction); + } + if (interaction.customId.startsWith('log_dash_toggle')) { + return await handleDashboardToggle(interaction); + } + + // Legacy /config logging status buttons if (interaction.customId === 'logging_refresh_status') { return await handleRefresh(interaction); } - if (interaction.customId.startsWith('logging_toggle')) { return await handleToggle(interaction); } @@ -60,7 +68,7 @@ async function handleToggle(interaction) { const newState = !Boolean(status.enabled); await setLoggingEnabled(interaction.client, interaction.guildId, newState); - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); return interaction.update({ embeds: [embed], components @@ -82,7 +90,7 @@ async function handleToggle(interaction) { await toggleEventLogging(interaction.client, interaction.guildId, eventType, newState); } - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); await interaction.update({ embeds: [embed], components @@ -99,7 +107,7 @@ async function handleToggle(interaction) { async function handleRefresh(interaction) { try { - const { embed, components } = await buildLoggingStatusView(interaction, interaction.client); + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); await interaction.update({ embeds: [embed], @@ -114,3 +122,45 @@ async function handleRefresh(interaction) { }); } } + +// โ”€โ”€โ”€ Dashboard button handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +async function handleDashboardToggle(interaction) { + try { + // customId: log_dash_toggle:audit_enabled | log_dash_toggle:all | log_dash_toggle:category.* + const eventType = interaction.customId.replace('log_dash_toggle:', ''); + if (!eventType) { + return interaction.reply({ content: 'โŒ Invalid event type.', ephemeral: true }); + } + + const status = await getLoggingStatus(interaction.client, interaction.guildId); + + if (eventType === 'audit_enabled') { + await setLoggingEnabled(interaction.client, interaction.guildId, !Boolean(status.enabled)); + } else if (eventType === 'all') { + const newState = !Object.values(status.enabledEvents).every((v) => v !== false); + const allTypes = Object.values(EVENT_TYPES); + const categoryTypes = LOGGING_CATEGORIES.map((c) => `${c}.*`); + await toggleEventLogging(interaction.client, interaction.guildId, [...allTypes, ...categoryTypes], newState); + } else { + const currentState = status.enabledEvents[eventType] !== false; + await toggleEventLogging(interaction.client, interaction.guildId, eventType, !currentState); + } + + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); + await interaction.update({ embeds: [embed], components }); + } catch (error) { + logger.error('Error in dashboard toggle:', error); + await interaction.reply({ content: 'โŒ An error occurred while toggling.', ephemeral: true }); + } +} + +async function handleDashboardRefresh(interaction) { + try { + const { embed, components } = await buildLoggingDashboardView(interaction, interaction.client); + await interaction.update({ embeds: [embed], components }); + } catch (error) { + logger.error('Error refreshing logging dashboard:', error); + await interaction.reply({ content: 'โŒ An error occurred while refreshing the dashboard.', ephemeral: true }); + } +} diff --git a/src/handlers/ticketButtons.js b/src/handlers/ticketButtons.js index 8af40f7b84..c28c5b6f46 100644 --- a/src/handlers/ticketButtons.js +++ b/src/handlers/ticketButtons.js @@ -9,6 +9,17 @@ import { InteractionHelper } from '../utils/interactionHelper.js'; import { checkRateLimit } from '../utils/rateLimiter.js'; import { getTicketPermissionContext } from '../utils/ticketPermissions.js'; +// Helper function to escape HTML special characters +function escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + async function ensureGuildContext(interaction) { if (interaction.inGuild()) { return true; @@ -24,6 +35,38 @@ async function ensureGuildContext(interaction) { return false; } +async function checkTicketPermissionWithTimeout(interaction, client, actionLabel, options = {}, timeoutMs = 2500) { + const { allowTicketCreator = false } = options; + + try { + const contextPromise = getTicketPermissionContext({ client, interaction }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Timeout')), timeoutMs) + ); + + const context = await Promise.race([contextPromise, timeoutPromise]); + + if (!context.ticketData) { + return { success: false, error: 'Not a Ticket Channel', details: 'This action can only be used in a valid ticket channel.' }; + } + + 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**.'; + return { success: false, error: 'Permission Denied', details: `${permissionMessage}\n\nYou cannot ${actionLabel}.` }; + } + + return { success: true, context }; + } catch (error) { + if (error.message === 'Timeout') { + return { success: false, error: 'Request Timeout', details: 'The permission check took too long. Please try again.' }; + } + return { success: false, error: 'Error', details: `Failed to check permissions: ${error.message}` }; + } +} + async function ensureTicketPermission(interaction, client, actionLabel, options = {}) { const { allowTicketCreator = false } = options; @@ -76,7 +119,7 @@ const createTicketHandler = { const currentTicketCount = await getUserTicketCount(interaction.guildId, interaction.user.id); if (currentTicketCount >= maxTicketsPerUser) { - return interaction.reply({ + return await interaction.reply({ embeds: [ errorEmbed( '๐ŸŽซ Ticket Limit Reached', @@ -102,6 +145,7 @@ const createTicketHandler = { const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); + // showModal must be called directly without defer await interaction.showModal(modal); } catch (error) { logger.error('Error creating ticket modal:', error); @@ -110,11 +154,6 @@ const createTicketHandler = { embeds: [errorEmbed('Error', 'Could not open ticket creation form.')], flags: MessageFlags.Ephemeral }); - } else { - await interaction.followUp({ - embeds: [errorEmbed('Error', 'Could not open ticket creation form.')], - flags: MessageFlags.Ephemeral - }); } } } @@ -169,7 +208,24 @@ const closeTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + // Use timeout-aware permission check to prevent interaction expiration + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'close this ticket', + { allowTicketCreator: true }, + 2000 // 2 second timeout for permission checks + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const modal = new ModalBuilder() .setCustomId('ticket_close_modal') @@ -186,6 +242,7 @@ const closeTicketHandler = { const actionRow = new ActionRowBuilder().addComponents(reasonInput); modal.addComponents(actionRow); + // showModal must be called directly without defer await interaction.showModal(modal); } catch (error) { logger.error('Error closing ticket:', error); @@ -195,11 +252,6 @@ const closeTicketHandler = { 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 - }); } } } @@ -211,7 +263,24 @@ const closeTicketModalHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'close this ticket', { allowTicketCreator: true }))) return; + // Use timeout-aware permission check + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'close this ticket', + { allowTicketCreator: true }, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -226,17 +295,6 @@ const closeTicketModalHandler = { embeds: [successEmbed('Ticket Closed', 'This ticket has been closed.')], flags: MessageFlags.Ephemeral }); - - await logEvent({ - client, - guildId: interaction.guildId, - event: { - action: 'Ticket Closed', - target: interaction.channel.toString(), - executor: interaction.user.toString(), - reason - } - }); } else { await interaction.editReply({ embeds: [errorEmbed('Error', result.error || 'Failed to close ticket.')], @@ -245,10 +303,17 @@ const closeTicketModalHandler = { } } catch (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 - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while closing the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -259,7 +324,23 @@ const claimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'claim tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'claim tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -289,10 +370,17 @@ const claimTicketHandler = { } } catch (error) { logger.error('Error claiming ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while claiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -303,7 +391,23 @@ const priorityTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'change ticket priority'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'change ticket priority', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -332,186 +436,139 @@ const priorityTicketHandler = { } } catch (error) { logger.error('Error updating ticket priority:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while updating the priority.')], + flags: MessageFlags.Ephemeral + }); + } } } }; -const transcriptTicketHandler = { - name: 'ticket_transcript', +const pinTicketHandler = { + name: 'ticket_pin', async execute(interaction, client) { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'create transcripts'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'pin tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; - - const messages = await interaction.channel.messages.fetch({ limit: 100 }); - if (process.env.NODE_ENV !== 'production') { - logger.debug('Total messages fetched:', messages?.size || 0); - } - - if (!messages || messages.size === 0) { + + const channel = interaction.channel; + const category = channel.parent; + + if (!category) { await interaction.editReply({ - embeds: [errorEmbed('No Messages', 'No messages found in this ticket channel.')], + embeds: [errorEmbed('Error', 'This ticket is not in a category.')], flags: MessageFlags.Ephemeral }); return; } + + // Check if channel name already has ping emoji + const hasPingEmoji = channel.name.startsWith('๐Ÿ“'); - const messagesArray = Array.from(messages.values()); - const userMessages = messagesArray.filter(m => { - const hasAuthor = m.author && typeof m.author === 'object'; - const hasTag = hasAuthor && m.author.tag; -const isUserMessage = m.type === 0; - - if (!hasAuthor) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Filtering message without author:', m.id, m.type); - } - } else if (!hasTag) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Message author exists but no tag:', m.id, m.author); - } - } - - return hasAuthor && hasTag && isUserMessage; - }); - - if (process.env.NODE_ENV !== 'production') { - logger.debug('Filtered user messages:', userMessages?.length || 0); - } - const sortedMessages = userMessages.sort((a, b) => a.createdTimestamp - b.createdTimestamp); - - if (!sortedMessages || sortedMessages.length === 0) { + if (hasPingEmoji) { + // Unpin: remove emoji and update position + const newName = channel.name.replace(/^๐Ÿ“\s*/, ''); + await channel.edit({ + name: newName, + position: 999 // Move to end + }); + await interaction.editReply({ - embeds: [errorEmbed('No User Messages', 'No user messages found to include in the transcript.')], + embeds: [createEmbed({ + title: '๐Ÿ“ Ticket Unpinned', + description: 'This ticket has been unpinned and moved back to normal position.', + color: 0x95A5A6 + })], flags: MessageFlags.Ephemeral }); - return; - } - - let htmlTranscript = ` - - - Ticket Transcript - ${interaction.channel.name} - - - -
-

๐ŸŽซ Ticket Transcript

-

Channel: ${interaction.channel.name}

-

Created:

-

Generated: ๐Ÿ“…

-

Messages: ${sortedMessages.length}

-
-`; - - for (const message of sortedMessages) { - if (process.env.NODE_ENV !== 'production') { - logger.debug('Processing message:', message.id, 'Author exists:', !!message.author, 'Attachments exist:', !!message.attachments); - } - - const timestamp = ``; - const author = message.author?.tag || message.author?.username || 'Unknown User'; - const content = message.content || '*No content (embed/attachment only)*'; - - htmlTranscript += ` -
-
[${timestamp}]
-
${author}
-
${content.replace(/\n/g, '
')}
`; - - if (message.attachments && message.attachments.size > 0) { - htmlTranscript += ` -
- ๐Ÿ“Ž Attachments: ${message.attachments.map(a => `${a.name}`).join(', ')} -
`; - } - - htmlTranscript += ` -
`; - } - - htmlTranscript += ` - -`; - - const transcriptEmbed = createEmbed({ - title: `๐Ÿ“œ Ticket Transcript - ${interaction.channel.name}`, - description: `**Channel:** ${interaction.channel.name}\n**Created:** \n**Generated:** ๐Ÿ“… \n**Messages:** ${sortedMessages.length}\n\n๐Ÿ“Ž The complete HTML transcript has been attached as a file.`, - color: 0x3498db, - footer: { text: `Ticket ID: ${interaction.channel.id}` } - }); - - const { Buffer } = await import('buffer'); - const buffer = Buffer.from(htmlTranscript, 'utf-8'); - - try { - await interaction.user.send({ - content: `๐Ÿ“œ **Ticket Transcript** for \`${interaction.channel.name}\``, - embeds: [transcriptEmbed], - files: [{ - attachment: buffer, - name: `ticket-transcript-${interaction.channel.name}.html` - }] + + logger.info('Ticket unpinned', { + guildId: interaction.guildId, + channelId: channel.id, + channelName: newName, + userId: interaction.user.id }); - + } else { + // Pin: add emoji and update position + const newName = `๐Ÿ“ ${channel.name}`; + await channel.edit({ + name: newName, + position: 0 // Move to top + }); + await interaction.editReply({ - embeds: [{ - title: 'โœ… Transcript Sent', - description: 'The ticket transcript has been sent to your DMs as both an embed and an HTML file.', -color: 4689679 - }], + embeds: [createEmbed({ + title: '๐Ÿ“ Ticket Pinned', + description: 'This ticket has been pinned to the top of the category.', + color: 0x3498db + })], flags: MessageFlags.Ephemeral }); - - await logTicketEvent({ - client: interaction.client, + + logger.info('Ticket pinned', { guildId: interaction.guildId, - event: { - type: 'transcript', - ticketId: interaction.channel.id, - ticketNumber: interaction.channel.name.replace(/[^0-9]/g, ''), - userId: interaction.user.id, - executorId: interaction.user.id, - metadata: { - messageCount: sortedMessages.length, - sentToDM: true, - transcriptSize: htmlTranscript.length - }, - attachments: [ - new AttachmentBuilder(buffer, { name: `ticket-transcript-${interaction.channel.name}.html` }) - ] + channelId: channel.id, + channelName: newName, + userId: interaction.user.id + }); + } + + await logTicketEvent({ + client: interaction.client, + guildId: interaction.guildId, + event: { + type: hasPingEmoji ? 'unpin' : 'pin', + ticketId: channel.id, + ticketNumber: channel.name.replace(/[^0-9]/g, ''), + userId: interaction.user.id, + executorId: interaction.user.id, + metadata: { + isPinned: !hasPingEmoji, + newChannelName: hasPingEmoji ? channel.name.replace(/^๐Ÿ“\s*/, '') : `๐Ÿ“ ${channel.name}` } + } + }); + + } catch (error) { + logger.error('Error pinning/unpinning ticket:', error); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'Failed to pin/unpin the ticket.')], + flags: MessageFlags.Ephemeral }); - } catch (dmError) { - logger.error('Could not DM user:', dmError); + } else if (interaction.deferred) { await interaction.editReply({ - embeds: [errorEmbed('DM Failed', 'I couldn\'t send you the transcript. Please enable DMs from server members.')], + embeds: [errorEmbed('Error', 'Failed to pin/unpin the ticket.')], flags: MessageFlags.Ephemeral }); } - - } catch (error) { - logger.error('Error creating transcript:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'Failed to create ticket transcript.')], - flags: MessageFlags.Ephemeral - }); } } }; @@ -522,7 +579,23 @@ const unclaimTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'unclaim tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'unclaim tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -553,10 +626,17 @@ const unclaimTicketHandler = { } } catch (error) { logger.error('Error unclaiming ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while unclaiming the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -567,7 +647,23 @@ const reopenTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'reopen tickets'))) return; + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'reopen tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -603,10 +699,17 @@ const reopenTicketHandler = { } } catch (error) { logger.error('Error reopening ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while reopening the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -617,7 +720,24 @@ const deleteTicketHandler = { try { if (!(await ensureGuildContext(interaction))) return; - if (!(await ensureTicketPermission(interaction, client, 'delete tickets'))) return; + // Use timeout-aware permission check + const permissionCheck = await checkTicketPermissionWithTimeout( + interaction, + client, + 'delete tickets', + {}, + 2000 + ); + + if (!permissionCheck.success) { + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed(permissionCheck.error, permissionCheck.details)], + flags: MessageFlags.Ephemeral + }); + } + return; + } const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); if (!deferSuccess) return; @@ -648,10 +768,17 @@ const deleteTicketHandler = { } } catch (error) { logger.error('Error deleting ticket:', error); - await interaction.editReply({ - embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], - flags: MessageFlags.Ephemeral - }); + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], + flags: MessageFlags.Ephemeral + }); + } else if (interaction.deferred) { + await interaction.editReply({ + embeds: [errorEmbed('Error', 'An error occurred while deleting the ticket.')], + flags: MessageFlags.Ephemeral + }); + } } } }; @@ -663,7 +790,7 @@ export { closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler diff --git a/src/interactions/buttons/goodbyeConfig.js b/src/interactions/buttons/goodbyeConfig.js deleted file mode 100644 index 2016b03be6..0000000000 --- a/src/interactions/buttons/goodbyeConfig.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleGoodbyeConfigButton } from '../../handlers/interactionHandlers/goodbyeConfigButton.js'; - -export default { - name: 'goodbye_config', - async execute(interaction, client, args) { - return handleGoodbyeConfigButton(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/buttons/logging.js b/src/interactions/buttons/logging.js index 7ee27ae635..4c34e6e429 100644 --- a/src/interactions/buttons/logging.js +++ b/src/interactions/buttons/logging.js @@ -9,4 +9,12 @@ export default [ name: 'logging_refresh_status', execute: loggingButtonsHandler.execute, }, + { + name: 'log_dash_toggle', + execute: loggingButtonsHandler.execute, + }, + { + name: 'log_dash_refresh', + execute: loggingButtonsHandler.execute, + }, ]; \ No newline at end of file diff --git a/src/interactions/buttons/ticket.js b/src/interactions/buttons/ticket.js index b65be15517..bcc2521f71 100644 --- a/src/interactions/buttons/ticket.js +++ b/src/interactions/buttons/ticket.js @@ -2,7 +2,7 @@ import createTicketHandler, { closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, @@ -13,7 +13,7 @@ export default [ closeTicketHandler, claimTicketHandler, priorityTicketHandler, - transcriptTicketHandler, + pinTicketHandler, unclaimTicketHandler, reopenTicketHandler, deleteTicketHandler, diff --git a/src/interactions/buttons/welcomeConfig.js b/src/interactions/buttons/welcomeConfig.js deleted file mode 100644 index 84359d6e2e..0000000000 --- a/src/interactions/buttons/welcomeConfig.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleWelcomeConfigButton } from '../../handlers/interactionHandlers/welcomeConfigButton.js'; - -export default { - name: 'welcome_config', - async execute(interaction, client, args) { - return handleWelcomeConfigButton(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/modals/goodbyeConfigModal.js b/src/interactions/modals/goodbyeConfigModal.js deleted file mode 100644 index 0f329fc73b..0000000000 --- a/src/interactions/modals/goodbyeConfigModal.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleGoodbyeConfigModal } from '../../handlers/interactionHandlers/goodbyeConfigModal.js'; - -export default { - name: 'goodbye_config_modal', - async execute(interaction, client, args) { - return handleGoodbyeConfigModal(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/modals/welcomeConfigModal.js b/src/interactions/modals/welcomeConfigModal.js deleted file mode 100644 index 7f741259b9..0000000000 --- a/src/interactions/modals/welcomeConfigModal.js +++ /dev/null @@ -1,8 +0,0 @@ -import { handleWelcomeConfigModal } from '../../handlers/interactionHandlers/welcomeConfigModal.js'; - -export default { - name: 'welcome_config_modal', - async execute(interaction, client, args) { - return handleWelcomeConfigModal(interaction, client, args); - } -}; \ No newline at end of file diff --git a/src/interactions/selectMenus/ticketFeedback.js b/src/interactions/selectMenus/ticketFeedback.js new file mode 100644 index 0000000000..54524b449c --- /dev/null +++ b/src/interactions/selectMenus/ticketFeedback.js @@ -0,0 +1,144 @@ +import { EmbedBuilder } from 'discord.js'; +import { getTicketData, saveTicketData } from '../../utils/database.js'; +import { logger } from '../../utils/logger.js'; +import { getColor } from '../../config/bot.js'; +import { getGuildConfig } from '../../services/guildConfig.js'; + +const STAR_LABELS = { + '1': 'โญ 1 โ€” Poor', + '2': 'โญโญ 2 โ€” Below Average', + '3': 'โญโญโญ 3 โ€” Average', + '4': 'โญโญโญโญ 4 โ€” Good', + '5': 'โญโญโญโญโญ 5 โ€” Excellent', +}; + +export default { + name: 'ticket_feedback', + + async execute(interaction, client, args) { + // args = [guildId, channelId] from the customId split on ':' + const [guildId, channelId] = args; + + if (!guildId || !channelId) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Invalid Feedback Link') + .setDescription('This feedback link appears to be malformed.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + // Only the ticket creator should be able to submit + let ticketData; + try { + ticketData = await getTicketData(guildId, channelId); + } catch (err) { + logger.warn('ticketFeedback: failed to load ticket data', { guildId, channelId, error: err.message }); + } + + if (!ticketData) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โš ๏ธ Ticket Not Found') + .setDescription('Could not find the ticket associated with this survey.') + .setColor(getColor('error')), + ], + components: [], + }); + return; + } + + if (interaction.user.id !== ticketData.userId) { + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle('โŒ Not Allowed') + .setDescription('Only the ticket creator can submit feedback for this ticket.') + .setColor(getColor('error')), + ], + ephemeral: true, + }); + return; + } + + // Guard against duplicate submission + if (ticketData.feedback?.rating) { + await interaction.update({ + embeds: [ + new EmbedBuilder() + .setTitle('โœ… Already Submitted') + .setDescription(`You already rated this ticket **${STAR_LABELS[String(ticketData.feedback.rating)]}**.\nThank you for your feedback!`) + .setColor(getColor('success')), + ], + components: [], + }); + return; + } + + const rating = parseInt(interaction.values[0], 10); + const ratingLabel = STAR_LABELS[String(rating)] ?? `${rating} stars`; + + // Persist the feedback + try { + ticketData.feedback = { + rating, + submittedAt: new Date().toISOString(), + }; + await saveTicketData(guildId, channelId, ticketData); + } catch (err) { + logger.error('ticketFeedback: failed to save feedback', { guildId, channelId, rating, error: err.message }); + } + + // Send feedback to logs channel + try { + const guildConfig = await getGuildConfig(interaction.client, guildId); + if (guildConfig.ticketLogsChannelId) { + const logsChannel = await interaction.client.channels.fetch(guildConfig.ticketLogsChannelId).catch(() => null); + if (logsChannel && logsChannel.isSendable()) { + const feedbackEmbed = new EmbedBuilder() + .setTitle('๐Ÿ“‹ Ticket Feedback Received') + .setDescription(`User submitted feedback for a ticket`) + .setColor(getColor('info')) + .addFields( + { name: 'Ticket ID', value: `\`${channelId}\``, inline: true }, + { name: 'Rating', value: ratingLabel, inline: true }, + { name: 'User', value: `<@${interaction.user.id}>`, inline: true }, + { name: 'Submitted', value: ``, inline: true } + ) + .setThumbnail(interaction.user.displayAvatarURL()) + .setFooter({ text: `User ID: ${interaction.user.id}` }) + .setTimestamp(); + + await logsChannel.send({ embeds: [feedbackEmbed] }); + } + } + } catch (err) { + logger.warn('ticketFeedback: failed to send log', { guildId, channelId, error: err.message }); + } + + // Edit the DM message to remove the select and show thanks + const thankYouEmbed = new EmbedBuilder() + .setTitle('โœ… Thanks for your feedback!') + .setDescription(`You rated your support experience **${ratingLabel}**.\n\nYour feedback has been recorded and helps us improve!`) + .setColor(getColor('success')) + .setFooter({ text: 'Thank you for using our support system.' }) + .setTimestamp(); + + await interaction.update({ + embeds: [thankYouEmbed], + components: [], + }); + + logger.info('Ticket feedback submitted', { + guildId, + channelId, + userId: interaction.user.id, + rating, + }); + }, +}; diff --git a/src/services/joinToCreateService.js b/src/services/joinToCreateService.js index d51f84454d..6ce5dfbb05 100644 --- a/src/services/joinToCreateService.js +++ b/src/services/joinToCreateService.js @@ -262,7 +262,7 @@ export async function initializeJoinToCreate(client, guildId, channelId, options 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.', + 'This server already has a Join to Create channel configured. Use `/jointocreate dashboard` to modify it, or remove it before creating a new one.', { guildId, existingTriggerChannelId: config.triggerChannels[0], diff --git a/src/services/leveling.js b/src/services/leveling.js index 01d38f47e1..6989fcece0 100644 --- a/src/services/leveling.js +++ b/src/services/leveling.js @@ -101,7 +101,7 @@ export async function getLeaderboard(client, guildId, limit = 10) { if (member.user.bot) continue; const data = await getUserLevelData(client, guildId, userId); - if (data && data.totalXp > 0) { + if (data && (data.totalXp > 0 || data.level > 0)) { leaderboard.push({ userId, username: member.user.username, @@ -518,3 +518,26 @@ export async function setUserLevel(client, guildId, userId, level) { + +export async function deleteUserLevelData(client, guildId, userId) { + try { + if (!guildId || !userId) { + throw new TitanBotError( + 'Guild ID and User ID are required', + ErrorTypes.VALIDATION + ); + } + + const key = `${guildId}:leveling:users:${userId}`; + await client.db.delete(key); + + logger.debug(`Deleted level data for user ${userId} in guild ${guildId}`); + } catch (error) { + logger.error(`Error deleting level data for user ${userId}:`, error); + if (error instanceof TitanBotError) throw error; + logger.warn(`Could not delete level data for user ${userId} in guild ${guildId}`); + } +} + + + diff --git a/src/services/reactionRoleService.js b/src/services/reactionRoleService.js index 96601a43e5..35cff02367 100644 --- a/src/services/reactionRoleService.js +++ b/src/services/reactionRoleService.js @@ -290,12 +290,9 @@ export async function deleteReactionRoleMessage(client, guildId, messageId) { const data = await getReactionRoleMessage(client, guildId, messageId); if (!data) { - throw createError( - `Reaction role message not found: ${messageId}`, - ErrorTypes.CONFIGURATION, - 'No reaction role message found with that ID in this server.', - { guildId, messageId } - ); + // Data doesn't exist - this is fine, just return (idempotent delete) + logger.debug(`Reaction role message ${messageId} does not exist in guild ${guildId}, nothing to delete`); + return true; } await client.db.delete(key); @@ -427,8 +424,10 @@ export async function getAllReactionRoleMessages(client, guildId) { actualData = data; } - if (actualData) { + if (actualData && actualData.messageId && actualData.channelId) { messages.push(actualData); + } else if (actualData) { + logger.warn(`Skipping malformed reaction role data for guild ${guildId}:`, actualData); } } } catch (dataError) { @@ -437,7 +436,6 @@ export async function getAllReactionRoleMessages(client, guildId) { } } - logger.debug(`Retrieved ${messages.length} reaction role messages for guild ${guildId}`); return messages; } catch (error) { if (error.name === 'TitanBotError') { diff --git a/src/services/counterService.js b/src/services/serverstatsService.js similarity index 100% rename from src/services/counterService.js rename to src/services/serverstatsService.js diff --git a/src/services/ticket.js b/src/services/ticket.js index 8fd6b2bfb6..b38387bdbc 100644 --- a/src/services/ticket.js +++ b/src/services/ticket.js @@ -4,7 +4,10 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, - PermissionFlagsBits + PermissionFlagsBits, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + AttachmentBuilder, } from 'discord.js'; import { getGuildConfig } from './guildConfig.js'; import { getTicketData, saveTicketData, deleteTicketData, getOpenTicketCountForUser } from '../utils/database.js'; @@ -134,15 +137,15 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso PermissionFlagsBits.ReadMessageHistory, ], }, - ...(ticketConfig.supportRoles?.map(roleId => ({ - id: roleId, + ...(config.ticketStaffRoleId ? [{ + id: config.ticketStaffRoleId, allow: [ PermissionFlagsBits.ViewChannel, PermissionFlagsBits.SendMessages, PermissionFlagsBits.AttachFiles, PermissionFlagsBits.ReadMessageHistory, ], - })) || []), + }] : []), ], }); @@ -184,10 +187,10 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso .setStyle(ButtonStyle.Primary) .setEmoji('๐Ÿ™‹'), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); if (ticketConfig.enablePriority) { @@ -205,13 +208,16 @@ export async function createTicket(guild, member, categoryId, reason = 'No reaso ); } - const messageContent = `${member.toString()}${ticketConfig.supportRoles?.length ? ' ' + ticketConfig.supportRoles.map(r => `<@&${r}>`).join(' ') : ''}`; + const staffMention = config.ticketStaffRoleId ? ` <@&${config.ticketStaffRoleId}>` : ''; + const messageContent = `${member.toString()}${staffMention}`; - await channel.send({ + const ticketMessage = await channel.send({ content: messageContent, embeds: [embed], components: [row] }); + + await ticketMessage.pin().catch(() => {}); await logTicketEvent({ client: guild.client, @@ -300,8 +306,36 @@ export async function closeTicket(channel, closer, reason = 'No reason provided' color: '#e74c3c', footer: { text: `Ticket ID: ${ticketData.id}` } }); - + await ticketCreator.send({ embeds: [dmEmbed] }); + + // Post-close feedback survey โ€” separate DM message so it can be updated on submit + try { + const feedbackEmbed = createEmbed({ + title: 'โญ How was your support experience?', + description: `We'd love to know how we did with **${channel.name}**.\nSelect a rating below โ€” it only takes a second!`, + color: '#F1C40F', + footer: { text: 'Your feedback helps us improve.' }, + }); + + const feedbackSelect = new StringSelectMenuBuilder() + .setCustomId(`ticket_feedback:${channel.guild.id}:${channel.id}`) + .setPlaceholder('Select a rating...') + .addOptions( + new StringSelectMenuOptionBuilder().setLabel('โญ 1 โ€” Poor').setValue('1').setDescription('The support was unhelpful or slow.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญ 2 โ€” Below Average').setValue('2').setDescription('There was room for improvement.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญ 3 โ€” Average').setValue('3').setDescription('Support was okay.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญ 4 โ€” Good').setValue('4').setDescription('Support was helpful and friendly.'), + new StringSelectMenuOptionBuilder().setLabel('โญโญโญโญโญ 5 โ€” Excellent').setValue('5').setDescription('Outstanding support experience!'), + ); + + await ticketCreator.send({ + embeds: [feedbackEmbed], + components: [new ActionRowBuilder().addComponents(feedbackSelect)], + }); + } catch (feedbackError) { + logger.warn(`Could not send feedback survey to ticket creator ${ticketData.userId}: ${feedbackError.message}`); + } } } catch (dmError) { logger.warn(`Could not send DM to ticket creator ${ticketData.userId}: ${dmError.message}`); @@ -469,10 +503,10 @@ export async function claimTicket(channel, claimer) { .setEmoji('๐Ÿ™‹') .setDisabled(true), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ @@ -631,10 +665,10 @@ export async function reopenTicket(channel, reopener) { .setEmoji('๐Ÿ™‹') .setDisabled(!!ticketData.claimedBy), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ @@ -692,6 +726,103 @@ export async function reopenTicket(channel, reopener) { } } +// Helper function to escape HTML special characters +function escapeHtml(text) { + if (!text) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +async function generateTranscript(channel) { + try { + logger.debug('Generating transcript for channel', { + channelId: channel.id, + channelName: channel.name + }); + + // Fetch all messages by paginating + const messages = []; + let before = undefined; + let batch; + do { + batch = await channel.messages.fetch({ limit: 100, ...(before ? { before } : {}) }); + if (batch.size === 0) break; + messages.push(...batch.values()); + before = batch.last()?.id; + } while (batch.size === 100); + + messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp); + + const escape = (str) => + String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const rows = messages.map((msg) => { + const ts = new Date(msg.createdTimestamp).toISOString().replace('T', ' ').slice(0, 19); + const author = escape(msg.author?.tag ?? msg.author?.username ?? 'Unknown'); + const content = escape(msg.content || (msg.embeds.length ? '[embed]' : '[attachment]')); + return `${ts}${author}${content}`; + }).join('\n'); + + const html = ` + + + + +Transcript โ€“ #${escape(channel.name)} + + + +

๐Ÿ“œ Transcript โ€“ #${escape(channel.name)}

+

${messages.length} message(s) exported on ${new Date().toUTCString()}

+ + + +${rows} + +
Timestamp (UTC)AuthorMessage
+ +`; + + const buffer = Buffer.from(html, 'utf8'); + const attachment = new AttachmentBuilder(buffer, { name: `ticket-${channel.id}.html` }); + + logger.info('โœ… Successfully generated transcript', { + channelId: channel.id, + channelName: channel.name, + messageCount: messages.length, + size: buffer.length + }); + + return attachment; + } catch (error) { + logger.error('โŒ Failed to generate transcript:', { + channelId: channel.id, + channelName: channel.name, + errorMessage: error.message, + errorName: error.name, + errorStack: error.stack + }); + return null; + } +} + export async function deleteTicket(channel, deleter) { try { const ticketData = await getTicketData(channel.guild.id, channel.id); @@ -722,13 +853,127 @@ export async function deleteTicket(channel, deleter) { } } }); - + + // Generate and send transcript if transcript channel is configured setTimeout(async () => { try { - await channel.delete('Ticket deleted permanently'); - logger.info(`Deleted ticket channel ${channel.name} (${channel.id})`); - } catch (deleteError) { - logger.error(`Failed to delete ticket channel ${channel.id}:`, deleteError); + logger.debug('Starting ticket deletion process', { + channelId: channel.id, + ticketId: ticketData.id + }); + + // Generate transcript + let attachment = null; + try { + attachment = await generateTranscript(channel); + if (attachment) { + logger.info('Transcript generated successfully, attempting to send', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } else { + logger.warn('Transcript generation returned null', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } + } catch (transcriptError) { + logger.error('Error during transcript generation', { + channelId: channel.id, + ticketNumber: ticketData.id, + error: transcriptError.message + }); + } + + // Send transcript to configured channel if it was generated + if (attachment) { + try { + const guildConfig = await getGuildConfig(channel.client, channel.guild.id); + if (!guildConfig.ticketTranscriptChannelId) { + logger.warn('No transcript channel configured, skipping transcript send', { + channelId: channel.id, + ticketNumber: ticketData.id + }); + } else { + const transcriptChannel = await channel.client.channels.fetch(guildConfig.ticketTranscriptChannelId).catch(() => null); + + if (!transcriptChannel) { + logger.error('Could not fetch transcript channel', { + channelId: channel.id, + transcriptChannelId: guildConfig.ticketTranscriptChannelId + }); + } else if (!transcriptChannel.isSendable()) { + logger.error('Transcript channel exists but is not sendable', { + channelId: channel.id, + transcriptChannelId: transcriptChannel.id + }); + } else { + // Send transcript with embed + const transcriptEmbed = new EmbedBuilder() + .setTitle('๐Ÿ“œ Ticket Transcript') + .setDescription(`Transcript for ticket #${ticketData.id}`) + .setColor('#3498db') + .addFields( + { name: 'Ticket ID', value: `\`${ticketData.id}\``, inline: true }, + { name: 'Channel', value: `#${channel.name}`, inline: true }, + { name: 'Generated', value: ``, inline: false } + ); + + if (deleter?.username) { + transcriptEmbed.setFooter({ + text: `Deleted by: ${deleter.username}`, + iconURL: deleter.displayAvatarURL?.() + }); + } + + await transcriptChannel.send({ + embeds: [transcriptEmbed], + files: [attachment] + }); + + logger.info('โœ… Transcript sent successfully', { + channelId: channel.id, + ticketNumber: ticketData.id, + transcriptChannelId: transcriptChannel.id + }); + } + } + } catch (sendError) { + logger.error('Failed to send transcript to channel:', { + channelId: channel.id, + ticketNumber: ticketData.id, + error: sendError.message + }); + } + } + + // Delete the channel (regardless of transcript success) + try { + await channel.delete('Ticket deleted permanently'); + logger.info('โœ… Channel deleted', { + channelId: channel.id, + channelName: channel.name, + ticketNumber: ticketData.id + }); + } catch (deleteError) { + logger.error('โŒ Failed to delete ticket channel:', { + channelId: channel.id, + channelName: channel.name, + ticketNumber: ticketData.id, + errorMessage: deleteError.message, + errorCode: deleteError.code, + errorName: deleteError.name + }); + } + } catch (error) { + logger.error('โŒ Unexpected error during ticket deletion:', { + channelId: channel.id, + channelName: channel?.name, + ticketNumber: ticketData?.id, + errorMessage: error.message, + errorName: error.name, + errorStack: error.stack + }); } }, TICKET_DELETE_DELAY_MS); @@ -810,10 +1055,10 @@ export async function unclaimTicket(channel, unclaimer) { .setStyle(ButtonStyle.Primary) .setEmoji('๐Ÿ™‹'), new ButtonBuilder() - .setCustomId('ticket_transcript') - .setLabel('Transcript') + .setCustomId('ticket_pin') + .setLabel('Pin') .setStyle(ButtonStyle.Secondary) - .setEmoji('๐Ÿ“œ') + .setEmoji('๐Ÿ“Œ') ); await ticketMessage.edit({ diff --git a/src/utils/abuseProtection.js b/src/utils/abuseProtection.js index 0e5f7237bb..a6786daff9 100644 --- a/src/utils/abuseProtection.js +++ b/src/utils/abuseProtection.js @@ -32,7 +32,7 @@ const RISKY_COMMAND_NAMES = new Set([ 'lock', 'unlock', 'ticket', - 'rsetup' + 'reactroles' ]); const blockedAttemptStore = new Map(); diff --git a/src/utils/database.js b/src/utils/database.js index 50ab53a2ae..f59c240f07 100644 --- a/src/utils/database.js +++ b/src/utils/database.js @@ -630,7 +630,7 @@ export async function getOpenTicketCountForUser(guildId, userId) { `SELECT COUNT(*)::int AS count FROM ${pgConfig.tables.tickets} WHERE guild_id = $1 AND data->>'userId' = $2 - AND COALESCE(data->>'status', 'open') = 'open'`, + AND data->>'status' = 'open'`, [guildId, userId] ); @@ -1338,6 +1338,59 @@ export async function saveApplicationSettings(client, guildId, settings) { } } +// โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Per-Application Settings (Questions & Log Channel) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function getApplicationRoleSettingsKey(guildId, roleId) { + return `guild:${guildId}:applications:role:${roleId}:settings`; +} + +export async function getApplicationRoleSettings(client, guildId, roleId) { + try { + if (!client.db || typeof client.db.get !== "function") { + return { questions: null, logChannelId: null }; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + const settings = await client.db.get(key, {}); + return unwrapReplitData(settings) || { questions: null, logChannelId: null }; + } catch (error) { + logger.error(`Error getting application role settings for ${guildId}:${roleId}:`, error); + return { questions: null, logChannelId: null }; + } +} + +export async function saveApplicationRoleSettings(client, guildId, roleId, settings) { + try { + if (!client.db || typeof client.db.set !== "function") { + logger.error("Database client is not available for saveApplicationRoleSettings."); + return false; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + await client.db.set(key, settings); + return true; + } catch (error) { + logger.error(`Error saving application role settings for ${guildId}:${roleId}:`, error); + return false; + } +} + +export async function deleteApplicationRoleSettings(client, guildId, roleId) { + try { + if (!client.db || typeof client.db.delete !== "function") { + logger.error("Database client is not available for deleteApplicationRoleSettings."); + return false; + } + + const key = getApplicationRoleSettingsKey(guildId, roleId); + await client.db.delete(key); + return true; + } catch (error) { + logger.error(`Error deleting application role settings for ${guildId}:${roleId}:`, error); + return false; + } +} + diff --git a/src/utils/interactionValidator.js b/src/utils/interactionValidator.js new file mode 100644 index 0000000000..e46470a204 --- /dev/null +++ b/src/utils/interactionValidator.js @@ -0,0 +1,158 @@ +/** + * Interaction Validator & Recovery System + * + * Prevents and handles "Unknown Interaction" (error 10062) errors + * by validating interaction state before responding and gracefully + * handling expired interactions. + */ + +import { logger } from './logger.js'; + +// Error code for expired/unknown interactions +const EXPIRED_INTERACTION_CODE = 10062; +const INTERACTION_NOT_REPLIED_CODE = 40060; + +/** + * Check if an interaction is still valid and can be responded to + * @param {Interaction} interaction - The interaction to validate + * @returns {boolean} True if the interaction can be responded to + */ +export function isInteractionValid(interaction) { + if (!interaction || !interaction.id || !interaction.token) { + return false; + } + + // Check if interaction is already handled + if (interaction.deferred || interaction.replied) { + return true; // Can still edit + } + + // Check timestamp - interactions expire after ~3 seconds without a response + const ageMs = Date.now() - interaction.createdTimestamp; + if (ageMs > 2800) { // 2.8 seconds buffer for safety + return false; + } + + return true; +} + +/** + * Safely defer an interaction with error recovery + * @param {Interaction} interaction - The interaction to defer + * @param {Object} options - Defer options + * @returns {Promise} True if deferral was successful + */ +export async function safeDeferInteraction(interaction, options = {}) { + try { + if (!isInteractionValid(interaction)) { + logger.warn('Interaction expired before deferral', { + event: 'interaction.expired_before_defer', + interactionId: interaction?.id, + age: Date.now() - (interaction?.createdTimestamp || 0) + }); + return false; + } + + if (interaction.deferred) { + return true; + } + + await interaction.deferUpdate(options); + return true; + } catch (error) { + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + logger.warn('Interaction expired during deferral', { + event: 'interaction.expired_during_defer', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id + }); + return false; + } + throw error; + } +} + +/** + * Safely show a modal on an interaction with error recovery + * @param {Interaction} interaction - The interaction to show modal on + * @param {Modal} modal - The modal to display + * @returns {Promise} True if modal was successfully shown + */ +export async function safeShowModal(interaction, modal) { + try { + if (!isInteractionValid(interaction)) { + logger.warn('Interaction expired before modal show', { + event: 'interaction.expired_before_modal', + interactionId: interaction?.id, + modalId: modal?.data?.custom_id + }); + return false; + } + + if (interaction.deferred || interaction.replied) { + logger.warn('Attempted to show modal on already-responded interaction', { + event: 'interaction.already_responded_modal', + customId: interaction?.customId + }); + return false; + } + + await interaction.showModal(modal); + return true; + } catch (error) { + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + logger.warn('Interaction expired during modal show', { + event: 'interaction.expired_during_modal', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id + }); + return false; + } + throw error; + } +} + +/** + * Wrapper for interaction handlers to catch expired interactions silently + * @param {Function} handler - The handler function + * @returns {Function} Wrapped handler that catches expired interactions + */ +export function withExpiredInteractionHandler(handler) { + return async (...args) => { + try { + return await handler(...args); + } catch (error) { + // Check if it's an expired interaction error + if (error.code === EXPIRED_INTERACTION_CODE || error.code === INTERACTION_NOT_REPLIED_CODE) { + const interaction = args.find(arg => + arg && typeof arg === 'object' && (arg.id && arg.token) + ); + + logger.warn('Handler failed due to expired interaction', { + event: 'interaction.handler_expired', + errorCode: error.code, + customId: interaction?.customId, + userId: interaction?.user?.id, + handlerName: handler.name || 'anonymous' + }); + + // Silently return instead of crashing + return null; + } + + // Re-throw non-expired-interaction errors + throw error; + } + }; +} + +export default { + isInteractionValid, + safeDeferInteraction, + safeShowModal, + withExpiredInteractionHandler, + EXPIRED_INTERACTION_CODE, + INTERACTION_NOT_REPLIED_CODE +}; diff --git a/src/utils/loggingUi.js b/src/utils/loggingUi.js index d460194071..5355ae7d3a 100644 --- a/src/utils/loggingUi.js +++ b/src/utils/loggingUi.js @@ -10,6 +10,61 @@ const EVENT_TYPES_BY_CATEGORY = Object.values(EVENT_TYPES).reduce((accumulator, return accumulator; }, {}); +const DASHBOARD_CATEGORY_EMOJIS = { + moderation: '๐Ÿ”จ', + ticket: '๐ŸŽซ', + message: 'โœ‰๏ธ', + role: '๐Ÿท๏ธ', + member: '๐Ÿ‘ฅ', + leveling: '๐Ÿ“ˆ', + reactionrole: '๐ŸŽญ', + giveaway: '๐ŸŽ', + counter: '๐Ÿ“Š', +}; + +function createDashboardCategoryButtons(enabledEvents = {}, loggingEnabled = false) { + const categories = ['moderation', 'ticket', 'message', 'role', 'member', 'leveling', 'reactionrole', 'giveaway', 'counter']; + const buttons = categories.map((category) => { + const wildcardDisabled = enabledEvents[`${category}.*`] === false; + const categoryEvents = EVENT_TYPES_BY_CATEGORY[category] || []; + const allEnabled = categoryEvents.length === 0 + ? true + : categoryEvents.every((t) => enabledEvents[t] !== false); + const isEnabled = loggingEnabled && !wildcardDisabled && allEnabled; + const emoji = DASHBOARD_CATEGORY_EMOJIS[category] || '๐Ÿ“Œ'; + const label = `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`; + return new ButtonBuilder() + .setCustomId(`log_dash_toggle:${category}.*`) + .setLabel(label) + .setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Danger); + }); + + const rows = []; + for (let i = 0; i < buttons.length; i += 5) { + rows.push(new ActionRowBuilder().addComponents(buttons.slice(i, i + 5))); + } + return rows; +} + +export function createLoggingDashboardComponents(enabledEvents, loggingEnabled = false) { + const categoryRows = createDashboardCategoryButtons(enabledEvents, loggingEnabled); + const actionRow = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('log_dash_toggle:audit_enabled') + .setLabel(loggingEnabled ? '๐Ÿงพ Audit: ON' : '๐Ÿงพ Audit: OFF') + .setStyle(loggingEnabled ? ButtonStyle.Success : ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId('log_dash_toggle:all') + .setLabel('Toggle All') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('log_dash_refresh') + .setLabel('๐Ÿ”„ Refresh') + .setStyle(ButtonStyle.Primary), + ); + return [...categoryRows, actionRow]; +} + diff --git a/src/utils/ticketLogging.js b/src/utils/ticketLogging.js index 3521bf5d40..5df01b1e44 100644 --- a/src/utils/ticketLogging.js +++ b/src/utils/ticketLogging.js @@ -29,19 +29,6 @@ export async function logTicketEvent({ client, guildId, event }) { } const config = await getGuildConfig(client, guildId); - - const unifiedEventType = mapTicketEventType(event.type); - if (config.logging?.enabled === false) { - return; - } - if (unifiedEventType) { - if (config.logging?.enabledEvents?.[unifiedEventType] === false) { - return; - } - if (config.logging?.enabledEvents?.['ticket.*'] === false) { - return; - } - } const logChannelId = getLogChannelForEventType(config, event.type); if (!logChannelId) { @@ -83,24 +70,20 @@ export async function logTicketEvent({ client, guildId, event }) { function getLogChannelForEventType(config, eventType) { - const ticketLogging = config.ticketLogging || {}; - switch (eventType) { case 'transcript': - return ticketLogging.transcriptChannelId || config.logChannelId; - + return config.ticketTranscriptChannelId || null; + case 'open': case 'close': case 'delete': - return ticketLogging.lifecycleChannelId || config.logChannelId; - case 'claim': case 'unclaim': case 'priority': - return ticketLogging.lifecycleChannelId || config.logChannelId; - + return config.ticketLogsChannelId || null; + default: - return config.logChannelId; + return null; } } @@ -292,10 +275,9 @@ function getEventDisplayInfo(event) { export async function getTicketLoggingConfig(client, guildId) { const config = await getGuildConfig(client, guildId); return { - enabled: !!(config.ticketLogging?.lifecycleChannelId || config.ticketLogging?.transcriptChannelId), - lifecycleChannelId: config.ticketLogging?.lifecycleChannelId || null, - transcriptChannelId: config.ticketLogging?.transcriptChannelId || null, - fallbackChannelId: config.logChannelId || null + enabled: !!(config.ticketLogsChannelId || config.ticketTranscriptChannelId), + lifecycleChannelId: config.ticketLogsChannelId || null, + transcriptChannelId: config.ticketTranscriptChannelId || null, }; }