diff --git a/Anti-sys b/Anti-sys new file mode 100644 index 0000000000..5029881c90 --- /dev/null +++ b/Anti-sys @@ -0,0 +1,354 @@ +/** + * antiNuke.js + * Discord.js v14 Anti-Nuke Module + * + * Protects against: + * - Mass channel deletion + * - Mass role deletion + * - Mass bans/kicks + * - Unauthorized bot additions + * - Webhook abuse + * - Dangerous permission changes + * + * Default punishment: remove dangerous roles/permissions. + */ + +const { + AuditLogEvent, + PermissionsBitField, +} = require("discord.js"); + +const DEFAULT_CONFIG = { + enabled: true, + + // Number of suspicious actions allowed + // before protection triggers. + threshold: 3, + + // Time window in milliseconds. + // 10 seconds = 10000. + windowMs: 10000, + + // "strip" = remove dangerous permissions + // "kick" = kick the user if possible + punishment: "strip", + + // Optional channel ID for alerts. + logChannelId: null, +}; + +const DANGEROUS_EVENTS = new Set([ + AuditLogEvent.ChannelDelete, + AuditLogEvent.RoleDelete, + AuditLogEvent.BanAdd, + AuditLogEvent.Kick, + AuditLogEvent.BotAdd, + AuditLogEvent.WebhookCreate, + AuditLogEvent.WebhookDelete, + AuditLogEvent.GuildUpdate, + AuditLogEvent.RoleUpdate, + AuditLogEvent.ChannelOverwriteCreate, + AuditLogEvent.ChannelOverwriteUpdate, + AuditLogEvent.ChannelOverwriteDelete, +]); + +class AntiNuke { + constructor(client) { + this.client = client; + + // Guild configuration + this.configs = new Map(); + + // Whitelisted users + this.whitelists = new Map(); + + // Recent suspicious actions + this.activity = new Map(); + + this.boundHandler = (entry, guild) => { + this.handle(entry, guild); + }; + } + + enable() { + this.client.on( + "guildAuditLogEntryCreate", + this.boundHandler + ); + + console.log("āœ… Anti-nuke protection enabled."); + + return this; + } + + disable() { + this.client.off( + "guildAuditLogEntryCreate", + this.boundHandler + ); + + console.log("šŸ›‘ Anti-nuke protection disabled."); + + return this; + } + + setConfig(guildId, changes = {}) { + const current = + this.configs.get(guildId) || + { ...DEFAULT_CONFIG }; + + const updated = { + ...current, + ...changes, + }; + + this.configs.set(guildId, updated); + + return updated; + } + + getConfig(guildId) { + return { + ...(this.configs.get(guildId) || + DEFAULT_CONFIG), + }; + } + + addWhitelist(guildId, userId) { + if (!this.whitelists.has(guildId)) { + this.whitelists.set(guildId, new Set()); + } + + this.whitelists + .get(guildId) + .add(userId); + } + + removeWhitelist(guildId, userId) { + this.whitelists + .get(guildId) + ?.delete(userId); + } + + isWhitelisted(guildId, userId) { + return ( + this.whitelists + .get(guildId) + ?.has(userId) || false + ); + } + + async handle(entry, guild) { + try { + if (!guild) return; + + const config = this.getConfig(guild.id); + + // Anti-nuke disabled + if (!config.enabled) return; + + // Ignore actions that aren't dangerous + if (!DANGEROUS_EVENTS.has(entry.action)) { + return; + } + + const executorId = entry.executorId; + + if (!executorId) return; + + // Never punish the bot itself. + if ( + executorId === this.client.user?.id + ) { + return; + } + + // Never punish the server owner. + if (executorId === guild.ownerId) { + return; + } + + // Ignore whitelisted users. + if ( + this.isWhitelisted( + guild.id, + executorId + ) + ) { + return; + } + + const key = + `${guild.id}:${executorId}`; + + const now = Date.now(); + + // Get recent actions. + const recent = + this.activity.get(key) || []; + + // Remove actions outside the time window. + const validActions = + recent.filter( + timestamp => + now - timestamp <= + config.windowMs + ); + + validActions.push(now); + + this.activity.set( + key, + validActions + ); + + console.log( + `[AntiNuke] ${executorId} ` + + `performed ${validActions.length} ` + + `dangerous actions in guild ${guild.id}` + ); + + // Threshold not reached yet. + if ( + validActions.length < + config.threshold + ) { + return; + } + + // Reset counter so the same user + // isn't punished repeatedly. + this.activity.set(key, []); + + const member = + await guild.members + .fetch(executorId) + .catch(() => null); + + if (!member) return; + + console.log( + `🚨 ANTI-NUKE TRIGGERED: ` + + `${member.user.tag}` + ); + + // ------------------------- + // KICK PUNISHMENT + // ------------------------- + + if ( + config.punishment === "kick" + ) { + if (member.kickable) { + await member + .kick( + "Anti-nuke: suspicious destructive activity" + ) + .catch(console.error); + } + } + + // ------------------------- + // STRIP PERMISSIONS + // ------------------------- + + else { + const dangerousPermissions = + new PermissionsBitField([ + PermissionsBitField.Flags.Administrator, + + PermissionsBitField.Flags.ManageGuild, + + PermissionsBitField.Flags.ManageChannels, + + PermissionsBitField.Flags.ManageRoles, + + PermissionsBitField.Flags.ManageWebhooks, + + PermissionsBitField.Flags.BanMembers, + + PermissionsBitField.Flags.KickMembers, + + PermissionsBitField.Flags.ManageMessages, + + PermissionsBitField.Flags.ManageThreads, + + PermissionsBitField.Flags.ManageEmojisAndStickers, + ]); + + const roles = + member.roles.cache.filter( + role => + role.id !== guild.id && + role.editable && + role.permissions.any( + dangerousPermissions.toArray() + ) + ); + + for (const role of roles.values()) { + await member.roles + .remove( + role, + "Anti-nuke: suspicious destructive activity" + ) + .catch(console.error); + } + } + + // Send alert. + await this.sendLog( + guild, + entry, + executorId, + config + ); + + } catch (error) { + console.error( + "[AntiNuke Error]", + error + ); + } + } + + async sendLog( + guild, + entry, + executorId, + config + ) { + if (!config.logChannelId) { + return; + } + + const channel = + guild.channels.cache.get( + config.logChannelId + ); + + if ( + !channel || + !channel.isTextBased() + ) { + return; + } + + await channel + .send({ + content: + `🚨 **ANTI-NUKE TRIGGERED**\n\n` + + `šŸ‘¤ User: <@${executorId}>\n` + + `āš ļø Action: \`${entry.action}\`\n` + + `šŸ”¢ Threshold: \`${config.threshold}\`\n` + + `ā±ļø Window: \`${config.windowMs / 1000}s\`\n` + + `šŸ›”ļø Protection has been activated.`, + }) + .catch(console.error); + } +} + +module.exports = { + AntiNuke, + DEFAULT_CONFIG, +}; diff --git a/src/commands/Birthday/birthday.js b/src/commands/Birthday/birthday.js deleted file mode 100644 index c1f8672d00..0000000000 --- a/src/commands/Birthday/birthday.js +++ /dev/null @@ -1,97 +0,0 @@ -import { SlashCommandBuilder, MessageFlags, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import birthdaySet from './modules/birthday_set.js'; -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 { - data: new SlashCommandBuilder() - .setName('birthday') - .setDescription('Birthday system commands') - .addSubcommand(subcommand => - subcommand - .setName('set') - .setDescription('Set your birthday') - .addIntegerOption(option => - option - .setName('month') - .setDescription('Birth month (1-12)') - .setRequired(true) - .setMinValue(1) - .setMaxValue(12) - ) - .addIntegerOption(option => - option - .setName('day') - .setDescription('Birth day (1-31)') - .setRequired(true) - .setMinValue(1) - .setMaxValue(31) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('info') - .setDescription('View birthday information') - .addUserOption(option => - option - .setName('user') - .setDescription('User to check birthday for') - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('list') - .setDescription('List all birthdays in the server') - ) - .addSubcommand(subcommand => - subcommand - .setName('remove') - .setDescription('Remove your birthday') - ) - .addSubcommand(subcommand => - 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) { - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'set': - return await birthdaySet.execute(interaction, config, client); - case 'info': - return await birthdayInfo.execute(interaction, config, client); - case 'list': - return await birthdayList.execute(interaction, config, client); - case 'remove': - 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 await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_info.js b/src/commands/Birthday/modules/birthday_info.js deleted file mode 100644 index 07b8c20020..0000000000 --- a/src/commands/Birthday/modules/birthday_info.js +++ /dev/null @@ -1,44 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getUserBirthday } from '../../../services/birthdayService.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const targetUser = interaction.options.getUser("user") || interaction.user; - const userId = targetUser.id; - const guildId = interaction.guildId; - - const birthdayData = await getUserBirthday(client, guildId, userId); - - if (!birthdayData) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription(targetUser.id === interaction.user.id - ? "You haven't set your birthday yet. Use `/birthday set` to add it!" - : `${targetUser.username} hasn't set their birthday yet.`); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Information') - .setDescription(`**Date:** ${birthdayData.monthName} ${birthdayData.day}\n**User:** ${targetUser.toString()}`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Birthday info retrieved successfully', { - userId: interaction.user.id, - targetUserId: targetUser.id, - guildId, - commandName: 'birthday_info' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_list.js b/src/commands/Birthday/modules/birthday_list.js deleted file mode 100644 index ce7ee064da..0000000000 --- a/src/commands/Birthday/modules/birthday_list.js +++ /dev/null @@ -1,76 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getAllBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const guildId = interaction.guildId; - - const sortedBirthdays = await getAllBirthdays(client, guildId); - - if (sortedBirthdays.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set in this server yet.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - 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`; - } - - if (fetchedMembers && staleUserIds.length > 0) { - for (const userId of staleUserIds) { - deleteBirthday(client, guildId, userId).catch(() => null); - } - } - - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays') - .setDescription('No birthdays have been set by current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - birthdayList = `**${displayIndex} birthday${displayIndex !== 1 ? 's' : ''} in ${interaction.guild.name}**\n\n` + birthdayList; - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Server Birthdays') - .setDescription(`${birthdayList}\n\nTotal: ${displayIndex} birthday${displayIndex !== 1 ? 's' : ''}`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Birthday list retrieved successfully', { - userId: interaction.user.id, - guildId, - birthdayCount: displayIndex, - staleRemoved: staleUserIds.length, - commandName: 'birthday_list' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_remove.js b/src/commands/Birthday/modules/birthday_remove.js deleted file mode 100644 index 2494c42768..0000000000 --- a/src/commands/Birthday/modules/birthday_remove.js +++ /dev/null @@ -1,33 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { deleteBirthday } from '../../../services/birthdayService.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - const result = await deleteBirthday(client, guildId, userId); - - if (result.status === 'not_found') { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthday Found') - .setDescription('You don\'t have a birthday set to remove.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - return; - } - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Removed') - .setDescription('Your birthday has been successfully removed from the server.'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_set.js b/src/commands/Birthday/modules/birthday_set.js deleted file mode 100644 index bf968b065d..0000000000 --- a/src/commands/Birthday/modules/birthday_set.js +++ /dev/null @@ -1,25 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { setBirthday } from '../../../services/birthdayService.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const month = interaction.options.getInteger("month"); - const day = interaction.options.getInteger("day"); - const userId = interaction.user.id; - const guildId = interaction.guildId; - - const result = await setBirthday(client, guildId, userId, month, day); - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Set!') - .setDescription(`Your birthday has been set to **${result.data.monthName} ${result.data.day}**!`); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/birthday_setchannel.js b/src/commands/Birthday/modules/birthday_setchannel.js deleted file mode 100644 index 09083bfa9f..0000000000 --- a/src/commands/Birthday/modules/birthday_setchannel.js +++ /dev/null @@ -1,59 +0,0 @@ -import { PermissionsBitField, EmbedBuilder, MessageFlags } from 'discord.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/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)) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('Permission Denied') - .setDescription('You need **Manage Server** permissions to configure the birthday channel.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - 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); - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Birthday Announcements Enabled') - .setDescription(`Birthday announcements will now be posted in ${channel}.`); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } else { - guildConfig.birthdayChannelId = null; - await setGuildConfig(client, guildId, guildConfig); - const embed = new EmbedBuilder() - .setColor(0xFFFF00) - .setTitle('Birthday Announcements Disabled') - .setDescription('No channel provided — birthday announcements have been disabled.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } - } catch (error) { - logger.error('birthday_setchannel error:', error); - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('āš ļø Configuration Error') - .setDescription('Could not save the birthday channel configuration.'); - return InteractionHelper.safeReply(interaction, { - embeds: [embed], - flags: MessageFlags.Ephemeral, - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Birthday/modules/next_birthdays.js b/src/commands/Birthday/modules/next_birthdays.js deleted file mode 100644 index 0695347c16..0000000000 --- a/src/commands/Birthday/modules/next_birthdays.js +++ /dev/null @@ -1,91 +0,0 @@ -import { EmbedBuilder } from 'discord.js'; -import { getUpcomingBirthdays } from '../../../services/birthdayService.js'; -import { deleteBirthday } from '../../../utils/database.js'; -import { logger } from '../../../utils/logger.js'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const next5 = await getUpcomingBirthdays(client, interaction.guildId, 5); - - if (next5.length === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Birthdays Found') - .setDescription('No birthdays have been set up in this server yet. Use `/birthday set` to add birthdays!'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - let displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - deleteBirthday(client, interaction.guildId, birthday.userId).catch(() => null); - continue; - } - displayIndex++; - - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = 'šŸŽ‰ **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = 'šŸ“… **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } - } - - if (displayIndex === 0) { - const embed = new EmbedBuilder() - .setColor(0xFF0000) - .setTitle('No Upcoming Birthdays') - .setDescription('No upcoming birthdays found for current server members.'); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - } - - let birthdayList = `šŸŽ‚ **Next 5 Upcoming Birthdays**\n\nHere are the next 5 birthdays in ${interaction.guild.name}:\n\n`; - displayIndex = 0; - for (const birthday of next5) { - const member = await interaction.guild.members.fetch(birthday.userId).catch(() => null); - if (!member) { - continue; - } - displayIndex++; - - let timeUntil = ''; - if (birthday.daysUntil === 0) { - timeUntil = 'šŸŽ‰ **Today!**'; - } else if (birthday.daysUntil === 1) { - timeUntil = 'šŸ“… **Tomorrow!**'; - } else { - timeUntil = `In ${birthday.daysUntil} day${birthday.daysUntil > 1 ? 's' : ''}`; - } - - birthdayList += `${displayIndex}. **${member.displayName}**\n<@${birthday.userId}>\nšŸ“… **Date:** ${birthday.monthName} ${birthday.day}\nā° **Time:** ${timeUntil}\n\n`; - } - - birthdayList += `Use /birthday set to add your birthday!`; - - const embed = new EmbedBuilder() - .setColor(0x00FF00) - .setTitle('Next 5 Upcoming Birthdays') - .setDescription(birthdayList); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed] - }); - - logger.info('Next birthdays retrieved successfully', { - userId: interaction.user.id, - guildId: interaction.guildId, - upcomingCount: displayIndex, - commandName: 'next_birthdays' - }); - } -}; \ No newline at end of file diff --git a/src/commands/Community/app-admin.js b/src/commands/Community/app-admin.js deleted file mode 100644 index 6ffc02b3f8..0000000000 --- a/src/commands/Community/app-admin.js +++ /dev/null @@ -1,583 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType, LabelBuilder, RoleSelectMenuBuilder } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { getColor, getApplicationStatusColor } from '../../config/bot.js'; -import { logger } from '../../utils/logger.js'; -import { withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplication, - getApplications, - updateApplication, - getApplicationRoles, - 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'; - 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") - .setDescription("Manage staff applications") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new application") - ) - .addSubcommand((subcommand) => - subcommand - .setName("review") - .setDescription("Approve or deny an application") - .addStringOption((option) => - option - .setName("id") - .setDescription("The application ID") - .setRequired(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List all applications") - .addStringOption((option) => - option - .setName("status") - .setDescription("Filter by status") - .addChoices( - { name: "Pending", value: "pending" }, - { name: "Approved", value: "approved" }, - { name: "Denied", value: "denied" }, - ), - ) - .addStringOption((option) => - option.setName("role").setDescription("Filter by role ID"), - ) - .addUserOption((option) => - option.setName("user").setDescription("Filter by user"), - ) - .addNumberOption((option) => - option - .setName("limit") - .setDescription( - "Maximum number of applications to show (default: 10)", - ) - .setMinValue(1) - .setMaxValue(25), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the applications configuration dashboard") - .addStringOption((option) => - option - .setName("application") - .setDescription("Select an application to configure") - .setRequired(false) - .setAutocomplete(true), - ), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== 'dashboard' && subcommand !== 'setup') { - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - } - - logger.info(`App-admin command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - await ApplicationService.checkManagerPermission(interaction.client, guild.id, member); - - if (subcommand === "setup") { - await handleSetup(interaction); - } else if (subcommand === "review") { - await handleReview(interaction); - } else if (subcommand === "list") { - await handleList(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) { - - if (interaction.deferred || interaction.replied) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This interaction has already been processed. Please try the command again.' }); - } - - const modal = new ModalBuilder() - .setCustomId('app_setup_modal') - .setTitle('Set Up New Application'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('role_id') - .setPlaceholder('Select the role users will apply for') - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('The role that users will be applying for') - .setRoleSelectMenuComponent(roleSelect); - - const appNameInput = new TextInputBuilder() - .setCustomId('app_name') - .setStyle(TextInputStyle.Short) - .setPlaceholder('e.g., Moderator, Helper, Developer') - .setMaxLength(50) - .setMinLength(1) - .setRequired(true); - - const appNameLabel = new LabelBuilder() - .setLabel('Application Name') - .setTextInputComponent(appNameInput); - - const q1Input = new TextInputBuilder() - .setCustomId('app_question_1') - .setStyle(TextInputStyle.Short) - .setPlaceholder('Why do you want this role?') - .setMaxLength(100) - .setMinLength(1) - .setRequired(true); - - const q1Label = new LabelBuilder() - .setLabel('Question 1 (required)') - .setTextInputComponent(q1Input); - - const q2Input = new TextInputBuilder() - .setCustomId('app_question_2') - .setStyle(TextInputStyle.Short) - .setPlaceholder('What experience do you have?') - .setMaxLength(100) - .setRequired(false); - - const q2Label = new LabelBuilder() - .setLabel('Question 2 (optional)') - .setTextInputComponent(q2Input); - - const q3Input = new TextInputBuilder() - .setCustomId('app_question_3') - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setRequired(false); - - const q3Label = new LabelBuilder() - .setLabel('Question 3 (optional)') - .setTextInputComponent(q3Input); - - modal.addLabelComponents(roleLabel, appNameLabel, q1Label, q2Label, q3Label); - - await interaction.showModal(modal); - - const submitted = await interaction.awaitModalSubmit({ - time: 15 * 60 * 1000, - filter: (i) => - i.customId === 'app_setup_modal' && - i.user.id === interaction.user.id, - }).catch(() => null); - - if (!submitted) { - logger.info('App setup modal dismissed or timed out', { guildId: interaction.guild.id, userId: interaction.user.id }); - return; - } - - const appName = submitted.fields.getTextInputValue('app_name').trim(); - const selectedRoles = submitted.fields.getSelectedRoles('role_id'); - const roleId = selectedRoles.first()?.id; - - if (!roleId) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'You must select a role for the application.' }); - return; - } - - 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); - - const role = await interaction.guild.roles.fetch(roleId).catch(() => null); - if (!role) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'The selected role could not be found.' }); - return; - } - - const existingRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - if (existingRoles.some(r => r.roleId === roleId)) { - await replyUserError(submitted, { type: ErrorTypes.CONFIGURATION, message: `The role ${role} is already configured as an application.` }); - return; - } - - existingRoles.push({ - roleId: roleId, - name: appName, - enabled: true, - }); - - await saveApplicationRoles(interaction.client, interaction.guild.id, existingRoles); - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - if (!settings.enabled) { - await ApplicationService.updateSettings(interaction.client, interaction.guild.id, { enabled: true }); - } - - 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'], - }); - - setTimeout(() => { - appDashboard.execute(submitted, null, interaction.client, appName); - }, 500); -} - -async function handleReview(interaction) { - const appId = interaction.options.getString("id"); - - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - if (!application) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Application not found.' }); - } - - if (application.status !== "pending") { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This application has already been processed.' }); - } - - const appEmbed = createEmbed({ - title: `Review Application`, - description: `**User:** <@${application.userId}>\n**Application:** ${application.roleName}\n**Application ID:** \`${appId}\``, - color: 'info', - }); - - 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), - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [appEmbed], - components: [buttonRow], - flags: ["Ephemeral"], - }); - - 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, - max: 1, - }); - - collector.on('collect', async buttonInteraction => { - const isApprove = buttonInteraction.customId.includes('approve'); - - 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); - - try { - const reasonSubmit = await buttonInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - 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 - } - ); - - try { - const user = await reasonSubmit.client.users.fetch(application.userId); - const statusColor = getApplicationStatusColor(status); - const reviewStatus = getApplicationStatusPresentation(status); - const dmEmbed = createEmbed({ - title: `${reviewStatus.statusEmoji} Application ${reviewStatus.statusLabel}`, - description: `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 - }); - } - - if (application.logMessageId && application.logChannelId) { - try { - const statusColor = getApplicationStatusColor(status); - 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 - }); - } - } - - 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 - }); - } - } - - await reasonSubmit.reply({ - embeds: [ - successEmbed( - `Application ${status}`, - `The application has been **${status}**.`, - ), - ], - flags: ["Ephemeral"], - }); - - } catch (error) { - logger.error('Error reviewing application:', error); - await replyUserError(buttonInteraction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while reviewing the application.' }); - } - }); - - 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(() => {}); - } - }); -} - -async function handleList(interaction) { - const status = interaction.options.getString("status"); - const user = interaction.options.getUser("user"); - const limit = interaction.options.getNumber("limit") || 10; - - const filters = {}; - - if (status) { - filters.status = status; - } else { - filters.status = 'pending'; - } - - let applications = await getApplications( - interaction.client, - interaction.guild.id, - filters, - ); - - if (!user) { - applications = await Promise.all( - applications.map(async (app) => { - try { - await interaction.guild.members.fetch(app.userId); - return app; - } catch { - - await deleteApplication(interaction.client, interaction.guild.id, app.id, app.userId); - return null; - } - }) - ).then(results => results.filter(Boolean)); - } - - if (user) { - applications = applications.filter((app) => app.userId === user.id); - } - - if (applications.length === 0) { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - if (applicationRoles.length > 0) { - const embed = createEmbed({ - title: "No Applications Found", - description: "No submitted applications found matching the specified criteria.\n\nHowever, the following application roles are configured:" - }); - - applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n**Available for applications:** Yes`, - inline: false - }); - }); - - embed.setFooter({ - text: "Users can apply with /apply submit or see available roles with /apply list" - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - } else { - return await replyUserError(interaction, { - type: ErrorTypes.CONFIGURATION, - message: 'No applications found and no application roles configured.\n' + - 'Use `/app-admin roles add` to configure application roles first.' - }); - } - } - - applications = applications - .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) - .slice(0, limit); - - const embed = createEmbed({ title: "Submitted Applications", description: `Showing ${applications.length} applications.`, }); - - applications.forEach((app) => { - 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: `${statusView.statusEmoji} ${roleName} - ${username}`, - value: - `**ID:** \`${app.id}\`\n` + - `**Status:** ${statusView.statusEmoji} ${statusView.statusLabel}\n` + - `**Date:** ${createdAtDisplay}`, - inline: true, - }); - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - flags: ["Ephemeral"], - }); -} - diff --git a/src/commands/Community/apply.js b/src/commands/Community/apply.js deleted file mode 100644 index 0e2c2c9249..0000000000 --- a/src/commands/Community/apply.js +++ /dev/null @@ -1,399 +0,0 @@ -import { getColor, getDefaultApplicationQuestions } from '../../config/bot.js'; -import { SlashCommandBuilder, ActionRowBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import ApplicationService from '../../services/applicationService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logEvent, EVENT_TYPES, resolveApplicationLogChannel } from '../../services/loggingService.js'; -import { formatLogLine, resolveUserAuthor } from '../../utils/logging/logEmbeds.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; -import { - getApplicationSettings, - getUserApplications, - createApplication, - getApplication, - getApplicationRoles, - updateApplication, - getApplicationRoleSettings -} 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 { - slashOnly: true, - data: new SlashCommandBuilder() - .setName("apply") - .setDescription("Manage role applications") - .addSubcommand((subcommand) => - subcommand - .setName("submit") - .setDescription("Submit an application for a role") - .addStringOption((option) => - option - .setName("application") - .setDescription("The application you want to submit") - .setRequired(true) - .setAutocomplete(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("status") - .setDescription("Check the status of your application") - .addStringOption((option) => - option - .setName("id") - .setDescription("Application ID (leave empty to see all)") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("list") - .setDescription("List available applications to apply for"), - ), - - category: "Community", - - execute: withErrorHandling(async (interaction) => { - if (!interaction.inGuild()) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'This command can only be used in a server.' }); - } - - const { options, guild, member } = interaction; - const subcommand = options.getSubcommand(); - - if (subcommand !== "submit") { - const isListCommand = subcommand === "list"; - await InteractionHelper.safeDefer(interaction, { flags: isListCommand ? [] : ["Ephemeral"] }); - } - - logger.info(`Apply command executed: ${subcommand}`, { - userId: interaction.user.id, - guildId: guild.id, - subcommand - }); - - const settings = await getApplicationSettings( - interaction.client, - guild.id, - ); - - if (!settings.enabled) { - throw createError( - 'Applications are disabled', - ErrorTypes.CONFIGURATION, - 'Applications are currently disabled in this server.', - { guildId: guild.id } - ); - } - - if (subcommand === "submit") { - await handleSubmit(interaction, settings); - } else if (subcommand === "status") { - await handleStatus(interaction); - } else if (subcommand === "list") { - await handleList(interaction); - } - }, { type: 'command', commandName: 'apply' }) -}; - -export async function handleApplicationModal(interaction) { - if (!interaction.isModalSubmit()) return; - - const customId = interaction.customId; - if (!customId.startsWith('app_modal_')) return; - - const roleId = customId.split('_')[2]; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - const applicationRole = applicationRoles.find(appRole => appRole.roleId === roleId); - - if (!applicationRole) { - return await replyUserError(interaction, { type: ErrorTypes.CONFIGURATION, message: 'Application configuration not found.' }); - } - - const role = interaction.guild.roles.cache.get(roleId); - - if (!role) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Role not found.' }); - } - - const answers = []; - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - - let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); - 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}`); - answers.push({ - question: questions[i], - answer: answer - }); - } - - try { - const application = await ApplicationService.submitApplication(interaction.client, { - guildId: interaction.guild.id, - userId: interaction.user.id, - roleId: roleId, - roleName: applicationRole.name, - username: interaction.user.tag, - avatar: interaction.user.displayAvatarURL(), - answers: answers - }); - - const embed = successEmbed( - 'Application Submitted', - `Your application for **${applicationRole.name}** has been submitted successfully!\n\n` + - `Application ID: \`${application.id}\`\n` + - `You can check the status with \`/apply status id:${application.id}\`` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: ["Ephemeral"] }); - - const settings = await getApplicationSettings(interaction.client, interaction.guild.id); - const roleSettings = await getApplicationRoleSettings(interaction.client, interaction.guild.id, roleId); - const guildConfig = await getGuildConfig(interaction.client, interaction.guild.id); - - const logChannelId = resolveApplicationLogChannel(guildConfig, roleSettings, settings); - - if (logChannelId) { - const logMessage = await logEvent({ - client: interaction.client, - guildId: interaction.guild.id, - eventType: EVENT_TYPES.APPLICATION_SUBMIT, - channelId: logChannelId, - data: { - title: 'Application Submitted', - lines: [ - formatLogLine('Applicant', `<@${interaction.user.id}> (${interaction.user.tag})`), - formatLogLine('Application', applicationRole.name), - formatLogLine('Role', role.name), - formatLogLine('Application ID', `\`${application.id}\``), - ], - inlineFields: [ - { name: 'Status', value: '🟔 In Progress', inline: true }, - ], - author: await resolveUserAuthor(interaction.client, interaction.user.id), - }, - }); - - if (logMessage) { - await updateApplication(interaction.client, interaction.guild.id, application.id, { - logMessageId: logMessage.id, - logChannelId, - }); - } - } - - } catch (error) { - logger.error('Error creating application:', { - error: error.message, - userId: interaction.user.id, - guildId: interaction.guild.id, - roleId, - stack: error.stack - }); - - await handleInteractionError(interaction, error, { - type: 'modal', - handler: 'application_submission' - }); - } -} - -async function handleList(interaction) { - try { - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - if (applicationRoles.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'No applications are currently available.' }); - } - - const embed = createEmbed({ - title: "Available Applications", - description: "Here are the roles you can apply for:" - }); - - applicationRoles.forEach((appRole, index) => { - const role = interaction.guild.roles.cache.get(appRole.roleId); - embed.addFields({ - name: `${index + 1}. ${appRole.name}`, - value: `**Role:** ${role ?`<@&${appRole.roleId}>`: 'Role not found'}\n` + - `**Apply with:** \`/apply submit application:"${appRole.name}"\``, - inline: false - }); - }); - - embed.setFooter({ - text: "Use /apply submit application: to apply for any of these roles." - }); - - return InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (error) { - logger.error('Error listing applications:', { - error: error.message, - guildId: interaction.guild.id, - stack: error.stack - }); - - throw createError( - 'Failed to load applications', - ErrorTypes.DATABASE, - 'Failed to load applications. Please try again later.', - { guildId: interaction.guild.id } - ); - } -} - -async function handleSubmit(interaction, settings) { - const applicationName = interaction.options.getString("application"); - const member = interaction.member; - - const applicationRoles = await getApplicationRoles(interaction.client, interaction.guild.id); - - const applicationRole = applicationRoles.find(appRole => - appRole.name.toLowerCase() === applicationName.toLowerCase() - ); - - if (!applicationRole) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'Use `/apply list` to see available applications.' }); - } - - const userApps = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - const pendingApp = userApps.find((app) => app.status === "pending"); - - if (pendingApp) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You already have a pending application. Please wait for it to be reviewed.' }); - } - - const role = interaction.guild.roles.cache.get(applicationRole.roleId); - if (!role) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: 'The role for this application no longer exists.' }); - } - - const modal = new ModalBuilder() - .setCustomId(`app_modal_${applicationRole.roleId}`) - .setTitle(`Application for ${applicationRole.name}`); - - let questions = settings.questions?.length ? settings.questions : getDefaultApplicationQuestions(); - 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() - .setCustomId(`q${index}`) - .setLabel( - question.length > 45 - ? `${question.substring(0, 42)}...` - : question, - ) - .setStyle(TextInputStyle.Paragraph) - .setRequired(true) - .setMaxLength(1000); - - const row = new ActionRowBuilder().addComponents(input); - modal.addComponents(row); - }); - - await interaction.showModal(modal); -} - -async function handleStatus(interaction) { - const appId = interaction.options.getString("id"); - - if (appId) { - const application = await getApplication( - interaction.client, - interaction.guild.id, - appId, - ); - - if (!application || application.userId !== interaction.user.id) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'Application not found or you do not have permission to view it.' }); - } - - 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 { - const applications = await getUserApplications( - interaction.client, - interaction.guild.id, - interaction.user.id, - ); - - if (applications.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You have not submitted any applications yet.' }); - } - - 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"] }); - } -} \ No newline at end of file diff --git a/src/commands/Community/modules/app_dashboard.js b/src/commands/Community/modules/app_dashboard.js deleted file mode 100644 index 856a28b918..0000000000 --- a/src/commands/Community/modules/app_dashboard.js +++ /dev/null @@ -1,1154 +0,0 @@ -import { getColor, getDefaultApplicationQuestions, botConfig } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - RoleSelectMenuBuilder, - ButtonBuilder, - ButtonStyle, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - LabelBuilder, - CheckboxBuilder, - TextDisplayBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { safeDeferInteraction } from '../../../utils/interactionValidator.js'; -import { - getApplicationSettings, - saveApplicationSettings, - getApplicationRoles, - saveApplicationRoles, - getApplicationRoleSettings, - saveApplicationRoleSettings, - deleteApplicationRoleSettings, - getApplications, - deleteApplication, -} from '../../../utils/database.js'; -import { getGuildConfig } from '../../../services/config/guildConfig.js'; -import { setLogChannel, resolveApplicationLogChannel, resolveLogChannel } from '../../../services/loggingService.js'; - -async function buildDashboardEmbed(settings, roles, guild, client) { - const guildConfig = await getGuildConfig(client, guild.id); - const applicationsChannel = resolveLogChannel(guildConfig, 'applications') || settings.logChannelId; - const logChannel = applicationsChannel ? `<#${applicationsChannel}>` : '`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), - ); -} - -async function refreshDashboard(rootInteraction, settings, roles, guildId, client) { - const selectMenu = buildSelectMenu(guildId); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [await buildDashboardEmbed(settings, roles, rootInteraction.guild, client)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -export default { - prefixOnly: false, - async execute(interaction, config, client, selectedAppName = null) { - try { - const guildId = interaction.guild.id; - - await InteractionHelper.safeDefer(interaction, { flags: ['Ephemeral'] }); - - const [settings, roles] = await Promise.all([ - getApplicationSettings(client, guildId), - getApplicationRoles(client, guildId), - ]); - - const guildConfig = await getGuildConfig(client, guildId); - const applicationsChannel = resolveLogChannel(guildConfig, 'applications') || settings.logChannelId; - - const isCompletelyUnconfigured = - !applicationsChannel && - !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 (roles.length === 0) { - await showGlobalDashboard(interaction, settings, roles, guildId, client); - return; - } - - if (selectedAppName) { - const selectedRole = roles.find(r => r.name.toLowerCase() === selectedAppName.toLowerCase()); - if (selectedRole) { - await showApplicationDashboard(interaction, selectedRole, settings, roles, guildId, client); - return; - } - - } - - 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.', - ); - } - }, -}; - -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) { - replyUserError(interaction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No selection was made. The dashboard has closed.', - }).catch(() => {}); - } - }); -} - -async function showGlobalDashboard(interaction, settings, roles, guildId, client) { - const selectMenu = buildSelectMenu(guildId); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [await buildDashboardEmbed(settings, roles, interaction.guild, client)], - components: [ - buildButtonRow(settings, guildId), - new ActionRowBuilder().addComponents(selectMenu), - ], - }); - - setupCollectors(interaction, settings, roles, guildId, client, null); -} - -async function showApplicationDashboard(rootInteraction, selectedRole, settings, roles, guildId, client) { - const roleObj = rootInteraction.guild.roles.cache.get(selectedRole.roleId); - - const guildConfig = await getGuildConfig(client, guildId); - const appSettings = await getApplicationRoleSettings(client, guildId, selectedRole.roleId); - const questions = appSettings.questions || settings.questions || []; - const appLogChannelId = resolveApplicationLogChannel(guildConfig, appSettings, settings); - const isEnabled = selectedRole.enabled !== false; - - 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(); - - const configMenu = buildApplicationSelectMenu(guildId, selectedRole.roleId); - - 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); -} - -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 { - - 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 replyUserError(selectInteraction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage, - }).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(() => {}); - } - }); - - 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; - - await saveApplicationSettings(interaction.client, guildId, settings); - - 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 replyUserError(toggleInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while toggling the application status.', - }); - } - }); - - 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(() => {}); - } - }); - } - - 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 => { - - const appRoleForDelete = roles.find(r => r.roleId === selectedRoleId); - const appNameForDelete = appRoleForDelete?.name ?? 'this application'; - - const confirmModal = new ModalBuilder() - .setCustomId('app_delete_confirm') - .setTitle('Confirm Application Deletion'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`āš ļø You are about to permanently delete **${appNameForDelete}**. All stored applications and settings for this role will be removed and cannot be recovered.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('confirm_delete') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - confirmModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); - - try { - await btnInteraction.showModal(confirmModal); - } catch (error) { - logger.error('Error showing delete confirmation modal:', error); - await replyUserError(btnInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'Failed to show confirmation modal. Please try again.', - }).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 replyUserError(btnInteraction, { - type: ErrorTypes.VALIDATION, - message: 'Application deletion was cancelled.', - }); - return; - } - - const confirmed = confirmSubmit.fields.getCheckbox('confirm_delete'); - if (!confirmed) { - await replyUserError(confirmSubmit, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the application.' }); - return; - } - - await handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client); - collector.stop(); - btnCollector.stop(); - - } catch (error) { - logger.error('Error confirming application deletion:', error); - await replyUserError(btnInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while deleting the application.', - }); - } - }); - - 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(() => {}); - } - }); - - 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 { - - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await replyUserError(toggleInteraction, { - type: ErrorTypes.USER_INPUT, - message: 'Application role not found.', - }); - return; - } - - const wasEnabled = roles[roleIndex].enabled !== false; - roles[roleIndex].enabled = !wasEnabled; - - await saveApplicationRoles(interaction.client, guildId, roles); - - 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 replyUserError(toggleInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while toggling the application status.', - }); - } - }); - - 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(() => {}); - } - }); - } -} - -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('šŸ—‘ļø'), - ); -} - -async function handleLogChannel(selectInteraction, rootInteraction, settings, roles, guildId, client, selectedRoleId) { - let currentChannel = settings.logChannelId; - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - currentChannel = roleSettings.logChannelId || settings.logChannelId; - } - - const modal = new ModalBuilder() - .setCustomId(`app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`) - .setTitle('Configure Log Channel'); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('log_channel') - .setPlaceholder('Select a text channel...') - .setMinValues(1) - .setMaxValues(1) - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true); - - const channelLabel = new LabelBuilder() - .setLabel('Log Channel') - .setDescription('Channel where new applications will be logged') - .setChannelSelectMenuComponent(channelSelect); - - modal.addLabelComponents(channelLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_log_channel_modal_${guildId}_${selectedRoleId || 'global'}`, - }); - - const channelId = modalSubmission.fields.getField('log_channel').values[0]; - const channel = selectInteraction.guild.channels.cache.get(channelId); - - if (selectedRoleId) { - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.logChannelId = channelId; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - await setLogChannel(client, guildId, 'applications', channelId); - settings.logChannelId = channelId; - await saveApplicationSettings(client, guildId, settings); - } - - await modalSubmission.reply({ - embeds: [successEmbed('Log Channel Updated', `Application logs will now be sent to ${channel ?? `<#${channelId}>`}.\nYou can also manage this from \`/logging dashboard\`.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId, client); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in log channel modal:', error); - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while updating the log channel.', - }); - } -} - -async function handleManagerRole(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId(`app_cfg_manager_role_modal_${guildId}`) - .setTitle('Configure Manager Roles'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('manager_roles') - .setPlaceholder('Select roles to grant manager access...') - .setMinValues(1) - .setMaxValues(5) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Manager Roles') - .setDescription('Selected roles will be toggled on/off as manager roles') - .setRoleSelectMenuComponent(roleSelect); - - modal.addLabelComponents(roleLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_manager_role_modal_${guildId}`, - }); - - const selectedRoleIds = modalSubmission.fields.getField('manager_roles').values; - const roleSet = new Set(settings.managerRoles ?? []); - - for (const roleId of selectedRoleIds) { - if (roleSet.has(roleId)) { - roleSet.delete(roleId); - } else { - roleSet.add(roleId); - } - } - - settings.managerRoles = Array.from(roleSet); - await saveApplicationSettings(client, guildId, settings); - - const finalList = settings.managerRoles.length > 0 - ? settings.managerRoles.map(id => `<@&${id}>`).join(',') - : '`None`'; - - await modalSubmission.reply({ - embeds: [successEmbed('Manager Roles Updated', `Current manager roles: ${finalList}`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId, client); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in manager role modal:', error); - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while updating manager roles.', - }); - } -} - -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 replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'At least one question is required.' }); - return; - } - - if (selectedRoleId) { - - const roleSettings = await getApplicationRoleSettings(client, guildId, selectedRoleId); - roleSettings.questions = newQuestions; - await saveApplicationRoleSettings(client, guildId, selectedRoleId, roleSettings); - } else { - - 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, client); -} - -async function handleRoleAdd(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId(`app_cfg_role_add_modal_${guildId}`) - .setTitle('Add Application Role'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('application_role') - .setPlaceholder('Select the role members can apply for...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Application Role') - .setDescription('Select the Discord role members will be applying for') - .setRoleSelectMenuComponent(roleSelect); - - const nameInput = new TextInputBuilder() - .setCustomId('role_name') - .setLabel('Display name (leave blank to use role name)') - .setStyle(TextInputStyle.Short) - .setMaxLength(50) - .setRequired(false); - - modal.addLabelComponents(roleLabel); - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_add_modal_${guildId}`, - }); - - const roleId = modalSubmission.fields.getField('application_role').values[0]; - const role = selectInteraction.guild.roles.cache.get(roleId); - const customName = modalSubmission.fields.getTextInputValue('role_name').trim() || role?.name || roleId; - - if (roles.some(r => r.roleId === roleId)) { - await replyUserError(modalSubmission, { type: ErrorTypes.UNKNOWN, message: `${role ?? roleId} is already an application role.` }); - return; - } - - roles.push({ roleId, name: customName }); - await saveApplicationRoles(client, guildId, roles); - await saveApplicationRoleSettings(client, guildId, roleId, { - questions: getDefaultApplicationQuestions(), - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Role Added', `${role ?? roleId} added as **${customName}**.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId, client); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in role add modal:', error); - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while adding the application role.', - }); - } -} - -async function handleRoleRemove(selectInteraction, rootInteraction, settings, roles, guildId, client) { - if (roles.length === 0) { - await replyUserError(selectInteraction, { - type: ErrorTypes.USER_INPUT, - message: 'There are no application roles configured to remove.', - }); - return; - } - - const modal = new ModalBuilder() - .setCustomId(`app_cfg_role_remove_modal_${guildId}`) - .setTitle('Remove Application Role'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('remove_role') - .setPlaceholder('Select the role to remove...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Remove Application Role') - .setDescription('Select the role to remove from the applications list') - .setRoleSelectMenuComponent(roleSelect); - - modal.addLabelComponents(roleLabel); - - await selectInteraction.showModal(modal); - - try { - const modalSubmission = await selectInteraction.awaitModalSubmit({ - time: 5 * 60 * 1000, - filter: i => i.user.id === selectInteraction.user.id && i.customId === `app_cfg_role_remove_modal_${guildId}`, - }); - - const roleId = modalSubmission.fields.getField('remove_role').values[0]; - const index = roles.findIndex(r => r.roleId === roleId); - - if (index === -1) { - await replyUserError(modalSubmission, { type: ErrorTypes.USER_INPUT, message: `<@&${roleId}> is not in the application roles list.` }); - return; - } - - roles.splice(index, 1); - await saveApplicationRoles(client, guildId, roles); - - await modalSubmission.reply({ - embeds: [successEmbed('Role Removed', `<@&${roleId}> has been removed from the application roles.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, settings, roles, guildId, client); - } catch (error) { - if (error.code === 'INTERACTION_TIMEOUT') return; - logger.error('Error in role remove modal:', error); - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'An error occurred while removing the application role.', - }); - } -} - -async function handleRetention(selectInteraction, rootInteraction, settings, roles, guildId, client) { - const modal = new ModalBuilder() - .setCustomId('app_cfg_retention') - .setTitle('Application Retention Periods'); - - const retentionInfo = new TextDisplayBuilder() - .setContent( - '**Pending** — how long unanswered/in-progress applications are kept before being automatically removed.\n' + - '**Reviewed** — how long approved or denied applications are kept.\n' + - '-# Enter a whole number between 1 and 3650 (max 10 years).', - ); - - const pendingLabel = new LabelBuilder() - .setLabel('Pending retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('pending_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.pendingApplicationRetentionDays ?? 30)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - const reviewedLabel = new LabelBuilder() - .setLabel('Reviewed retention (days)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('reviewed_days') - .setStyle(TextInputStyle.Short) - .setValue(String(settings.reviewedApplicationRetentionDays ?? 14)) - .setMaxLength(4) - .setMinLength(1) - .setRequired(true), - ); - - modal - .addTextDisplayComponents(retentionInfo) - .addLabelComponents(pendingLabel, reviewedLabel); - - 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 replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Pending retention must be a whole number between **1** and **3650** days.' }); - return; - } - - if (isNaN(reviewedDays) || reviewedDays < 1 || reviewedDays > 3650) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Reviewed retention must be a whole number between **1** and **3650** days.' }); - 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, client); -} - -async function handleDeleteApplication(confirmSubmit, selectedRoleId, guildId, roles, client) { - try { - - const roleIndex = roles.findIndex(r => r.roleId === selectedRoleId); - if (roleIndex === -1) { - await replyUserError(confirmSubmit, { type: ErrorTypes.USER_INPUT, message: 'Application role not found.' }); - return; - } - - const deletedRole = roles[roleIndex]; - - roles.splice(roleIndex, 1); - - await saveApplicationRoles(client, guildId, roles); - - await deleteApplicationRoleSettings(client, guildId, selectedRoleId); - - const allApplications = await getApplications(client, guildId); - const applicationsToDelete = allApplications.filter(app => app.roleId === selectedRoleId); - - for (const app of applicationsToDelete) { - await deleteApplication(client, guildId, app.id, app.userId); - } - - 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 replyUserError(confirmSubmit, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while deleting the application. Please try again.' }); - } -} \ No newline at end of file diff --git a/src/commands/Core/commands.js b/src/commands/Core/commands.js deleted file mode 100644 index 579cbf2bd3..0000000000 --- a/src/commands/Core/commands.js +++ /dev/null @@ -1,264 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - MessageFlags, -} from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { - disableCategory, - enableCategory, - disableCommand, - enableCommand, - resolveCategoryChoice, - buildCommandRegistry, - isProtectedCommand, -} from '../../services/commandAccessService.js'; -import { - buildDashboardView, - handleDashboardComponent, - createDashboardCollectorFilter, - isCommandAccessCustomId, -} from './modules/commands_dashboard.js'; - -const DASHBOARD_TIMEOUT_MS = 10 * 60 * 1000; - -function buildCategoryChoices(client) { - const registry = buildCommandRegistry(client); - return [...registry.values()] - .sort((a, b) => a.displayName.localeCompare(b.displayName)) - .slice(0, 25) - .map((category) => ({ - name: `${category.icon} ${category.displayName}`.slice(0, 100), - value: category.key, - })); -} - -async function ensureManageGuild(interaction) { - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to manage commands.' }); - return false; - } - - return true; -} - -export default { - data: new SlashCommandBuilder() - .setName('commands') - .setDescription('Enable or disable bot commands and categories for this server') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName('dashboard') - .setDescription('Open the interactive command access dashboard'), - ) - .addSubcommand((subcommand) => - subcommand - .setName('disable') - .setDescription('Disable a command or entire category') - .addStringOption((option) => - option - .setName('scope') - .setDescription('Disable a single command or a whole category') - .setRequired(true) - .addChoices( - { name: 'Category', value: 'category' }, - { name: 'Command', value: 'command' }, - ), - ) - .addStringOption((option) => - option - .setName('target') - .setDescription('Category or command name') - .setRequired(true) - .setAutocomplete(true), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName('enable') - .setDescription('Enable a command or entire category') - .addStringOption((option) => - option - .setName('scope') - .setDescription('Enable a single command or a whole category') - .setRequired(true) - .addChoices( - { name: 'Category', value: 'category' }, - { name: 'Command', value: 'command' }, - ), - ) - .addStringOption((option) => - option - .setName('target') - .setDescription('Category or command name') - .setRequired(true) - .setAutocomplete(true), - ), - ), - category: 'Core', - - async autocomplete(interaction) { - const focused = interaction.options.getFocused(true); - - if (focused.name !== 'target') { - return interaction.respond([]); - } - - const scope = interaction.options.getString('scope'); - const query = focused.value.toLowerCase(); - - if (scope === 'category') { - const choices = buildCategoryChoices(interaction.client) - .filter((choice) => choice.name.toLowerCase().includes(query) || choice.value.includes(query)) - .slice(0, 25); - return interaction.respond(choices); - } - - // For command scope, get all commands including subcommands - const registry = buildCommandRegistry(interaction.client); - const allCommands = []; - - // Check if the query matches a category name - if so, show commands from that category - const matchedCategory = resolveCategoryChoice(interaction.client, query); - - if (matchedCategory) { - // Show commands from the matched category - for (const command of matchedCategory.commands) { - if (!isProtectedCommand(command.name)) { - allCommands.push(command.name); - } - } - } else { - // Show all commands - for (const category of registry.values()) { - for (const command of category.commands) { - // Include both base commands and subcommands - if (!isProtectedCommand(command.name)) { - allCommands.push(command.name); - } - } - } - } - - const choices = allCommands - .filter((name) => name.includes(query)) - .slice(0, 25) - .map((name) => ({ name: `/${name}`, value: name })); - - return interaction.respond(choices); - }, - - async execute(interaction, config, client) { - if (!(await ensureManageGuild(interaction))) { - return; - } - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const view = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); - await InteractionHelper.safeEditReply(interaction, { - embeds: [view.embed], - components: view.components, - }); - - const replyMessage = await interaction.fetchReply().catch(() => null); - if (!replyMessage) { - return; - } - - const collector = replyMessage.createMessageComponentCollector({ - filter: createDashboardCollectorFilter(interaction.user.id, interaction.guildId), - time: DASHBOARD_TIMEOUT_MS, - }); - - collector.on('collect', async (componentInteraction) => { - try { - if (!isCommandAccessCustomId(componentInteraction.customId)) { - return; - } - await handleDashboardComponent(componentInteraction, client); - } catch (error) { - logger.error('Command access dashboard interaction failed', { - error: error.message, - customId: componentInteraction.customId, - guildId: interaction.guildId, - }); - await replyUserError(componentInteraction, { - type: ErrorTypes.UNKNOWN, - message: error.message || 'Failed to update command access.', - }).catch(() => {}); - } - }); - - collector.on('end', async () => { - const finalView = await buildDashboardView(client, interaction.guildId, interaction.guild, 'overview'); - const disabledComponents = finalView.components.map((row) => { - const newRow = row.toJSON(); - newRow.components = newRow.components.map((component) => ({ ...component, disabled: true })); - return newRow; - }); - - await replyMessage.edit({ components: disabledComponents }).catch(() => {}); - }); - - return; - } - - const scope = interaction.options.getString('scope'); - const target = interaction.options.getString('target'); - const isDisable = subcommand === 'disable'; - - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - if (scope === 'category') { - const category = resolveCategoryChoice(client, target); - if (!category) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `No category matched \`${target}\`. Use \`/commands dashboard\` to browse categories.` }); - } - - if (isDisable) { - await disableCategory(client, interaction.guildId, category.key); - return InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Category Disabled', - `All **${category.displayName}** commands are now disabled.\nProtected commands remain available.`, - ), - ], - }); - } - - await enableCategory(client, interaction.guildId, category.key); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Category Enabled', `**${category.displayName}** commands are now enabled (except individually disabled commands).`)], - }); - } - - const commandName = target.toLowerCase(); - if (isDisable) { - await disableCommand(client, interaction.guildId, commandName); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Disabled', `\`/${commandName}\` is now disabled in this server.`)], - }); - } - - await enableCommand(client, interaction.guildId, commandName); - return InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Command Enabled', `\`/${commandName}\` is now enabled in this server.`)], - }); - }, -}; diff --git a/src/commands/Core/configWizard.js b/src/commands/Core/configWizard.js deleted file mode 100644 index 22ac013ec0..0000000000 --- a/src/commands/Core/configWizard.js +++ /dev/null @@ -1,687 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - MessageFlags, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ChannelSelectMenuBuilder, - RoleSelectMenuBuilder, - LabelBuilder, - ChannelType, -} from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildConfig, setConfigValue } from '../../services/config/guildConfig.js'; -import ConfigService from '../../services/config/configService.js'; -import { logger } from '../../utils/logger.js'; -import { botConfig, getCommandPrefix } from '../../config/bot.js'; - -const DASHBOARD_CUSTOM_ID = 'config_select'; -const WIZARD_BUTTON_ID = 'config_wizard'; -const activeWizardSessions = new Set(); - -const DM_DISABLED_HELP = [ - '1. Right-click this server\'s name (mobile: tap the server name at the top).', - '2. Open **Privacy Settings**.', - '3. Turn on **Allow direct messages from server members**.', - '4. Click **Start Setup Wizard** again.', -].join('\n'); - -async function notifyWizardStarted(buttonInteraction) { - await buttonInteraction.followUp({ - embeds: [infoEmbed( - 'Setup Wizard Started', - 'Check your DMs — I sent you the first setup question there.\n\nAnswer each question in that DM. Type `skip` to keep the current value.', - )], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); -} - -async function notifyWizardDmBlocked(buttonInteraction) { - await replyUserError(buttonInteraction, { - type: ErrorTypes.USER_INPUT, - message: `I couldn't send you a DM. Enable DMs from this server, then try again.\n\n${DM_DISABLED_HELP}`, - }).catch(() => {}); -} - -function formatChannelMention(guild, channelId) { - if (!channelId) { - return '`Not set`'; - } - const channel = guild.channels.cache.get(channelId); - return channel ? `<#${channelId}>` : `#${channelId}`; -} - -function formatRoleMention(guild, roleId) { - if (!roleId) { - return '`Not set`'; - } - const role = guild.roles.cache.get(roleId); - return role ? `<@&${roleId}>` : `@${roleId}`; -} - -function getBotPresenceText() { - const activity = botConfig.presence?.activities?.[0]; - if (!activity?.name) { - return '`Not configured`'; - } - - const typeLabels = ['Playing', 'Streaming', 'Listening to', 'Watching', '', 'Competing in']; - const typeLabel = typeLabels[activity.type]; - if (!typeLabel) { - return activity.name; - } - - return `${typeLabel} **${activity.name}**`; -} - -function getThemeColorLines() { - const colors = botConfig.embeds.colors; - return [ - `šŸŽØ Primary \`${colors.primary}\` Ā· Success \`${colors.success}\``, - `āš ļø Warning \`${colors.warning}\` Ā· Error \`${colors.error}\``, - ].join('\n'); -} - -function buildDashboardEmbed(config, guild) { - const setupDone = config.setupWizardCompleted; - - return createEmbed({ - title: 'āš™ļø Server Configuration', - description: `Core settings for **${guild.name}**. Pick an option below or run the setup wizard.`, - color: 'info', - fields: [ - { - name: 'āŒØļø Server Prefix', - value: `\`${config.prefix || getCommandPrefix()}\``, - inline: true, - }, - { - name: 'šŸ›”ļø Moderator Role', - value: formatRoleMention(guild, config.modRole), - inline: true, - }, - { - name: 'šŸ“‹ Log Channel', - value: formatChannelMention(guild, config.logging?.channels?.audit), - inline: true, - }, - { - name: 'šŸ’š Bot Status', - value: getBotPresenceText(), - inline: false, - }, - { - name: 'šŸŽØ Embed Theme', - value: `${getThemeColorLines()}\n-# Colors are set in bot config and apply globally.`, - inline: false, - }, - { - name: '⚔ Command Access', - value: 'Use `/commands dashboard` to enable or disable commands and subcommands.', - inline: false, - }, - { - name: `${setupDone ? 'āœ…' : 'šŸ“'} Setup`, - value: setupDone - ? 'Setup wizard completed — re-run anytime to update settings.' - : 'Run the setup wizard to configure your server quickly.', - inline: false, - }, - ], - footer: 'Dashboard closes after 10 minutes of inactivity', - }); -} - -function buildSettingsSelect(guildId) { - return new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId(`${DASHBOARD_CUSTOM_ID}:${guildId}`) - .setPlaceholder('āš™ļø Select a setting to edit...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Server Prefix') - .setDescription('Change the text command prefix') - .setValue('prefix') - .setEmoji('āŒØļø'), - new StringSelectMenuOptionBuilder() - .setLabel('Moderator Role') - .setDescription('Role used for moderation commands') - .setValue('modRole') - .setEmoji('šŸ›”ļø'), - new StringSelectMenuOptionBuilder() - .setLabel('Log Channel') - .setDescription('Channel for system log messages') - .setValue('logChannelId') - .setEmoji('šŸ“‹'), - ), - ); -} - -function buildButtonRow(config, guildId) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${WIZARD_BUTTON_ID}:${guildId}`) - .setLabel(config.setupWizardCompleted ? 'Re-run Setup Wizard' : 'Start Setup Wizard') - .setEmoji('šŸ“') - .setStyle(config.setupWizardCompleted ? ButtonStyle.Secondary : ButtonStyle.Success), - ); -} - -function extractId(value) { - if (!value || typeof value !== 'string') return null; - - const channelMention = value.match(/<#!?(\d{17,19})>/); - if (channelMention) return channelMention[1]; - - const roleMention = value.match(/<@&(\d{17,19})>/); - if (roleMention) return roleMention[1]; - - const digits = value.match(/^(\d{17,19})$/); - if (digits) return digits[1]; - - return null; -} - -async function askQuestion(dmChannel, userId, prompt, stepNumber, totalSteps) { - await dmChannel.send({ - embeds: [createEmbed({ - title: `Setup Question ${stepNumber}/${totalSteps}`, - description: prompt, - color: 'primary', - })], - }); - - const collected = await dmChannel.awaitMessages({ - filter: (message) => message.author.id === userId && !message.author.bot, - max: 1, - time: 180_000, - }).catch(() => null); - - if (!collected || !collected.size) { - await dmChannel.send({ - embeds: [buildUserErrorEmbed(ErrorTypes.RATE_LIMIT, 'You did not answer in time. Run the setup wizard again when ready.')], - }); - return null; - } - - const answer = collected.first().content.trim(); - if (answer.toLowerCase() === 'cancel') { - await dmChannel.send({ - embeds: [infoEmbed('Setup Cancelled', 'Setup wizard stopped. Your saved answers are still applied.')], - }); - return { cancelled: true }; - } - - return { answer }; -} - -function formatSavedAck(key, value, guild) { - if (key === 'prefix') { - return `Server prefix saved as \`${value}\`.`; - } - - if (key === 'logChannelId') { - if (value === null) { - return 'Log channel cleared.'; - } - const channel = guild.channels.cache.get(value); - return `Log channel saved as ${channel ?? `<#${value}>`}.`; - } - - if (key === 'modRole') { - if (value === null) { - return 'Moderator role cleared.'; - } - const role = guild.roles.cache.get(value); - return `Moderator role saved as ${role ?? `<@&${value}>`}.`; - } - - return 'Setting saved.'; -} - -async function validateGuildChannelId(guild, channelId) { - const channel = guild.channels.cache.get(channelId) ?? await guild.channels.fetch(channelId).catch(() => null); - if (!channel || !channel.isTextBased()) { - throw new Error('That channel was not found in this server or is not a text channel.'); - } - return channel.id; -} - -async function validateGuildRoleId(guild, roleId) { - const role = guild.roles.cache.get(roleId) ?? await guild.roles.fetch(roleId).catch(() => null); - if (!role) { - throw new Error('That role was not found in this server.'); - } - return role.id; -} - -async function refreshDashboard(rootInteraction, config, guild) { - const embed = buildDashboardEmbed(config, guild); - const components = [buildButtonRow(config, guild.id), buildSettingsSelect(guild.id)]; - await InteractionHelper.safeEditReply(rootInteraction, { embeds: [embed], components }).catch(() => {}); -} - -async function runSetupWizard(buttonInteraction, config, guild, client, rootInteraction) { - const user = buttonInteraction.user; - - if (activeWizardSessions.has(user.id)) { - await buttonInteraction.followUp({ - embeds: [warningEmbed('Setup Already Running', 'You already have a setup wizard open in your DMs. Reply there to continue, or type `cancel` to stop it.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - return; - } - - activeWizardSessions.add(user.id); - - let dmChannel; - - try { - dmChannel = await user.createDM(); - } catch (error) { - logger.warn('Failed to create DM channel for setup wizard', { userId: user.id, error: error.message }); - await notifyWizardDmBlocked(buttonInteraction); - return; - } finally { - if (!dmChannel) { - activeWizardSessions.delete(user.id); - } - } - - const prompts = [ - { - key: 'prefix', - skipMessage: 'Keeping the current server prefix.', - question: 'What command prefix should this server use?\nCurrent: `' + (config.prefix || getCommandPrefix()) + '`\nReply `skip` to keep it, or `cancel` to stop.', - parse: async (answer) => { - const normalized = answer.trim(); - if (normalized.toLowerCase() === 'skip') return undefined; - if (/\s/.test(normalized) || normalized.length < 1 || normalized.length > 10) { - throw new Error('Prefix must be 1-10 characters with no spaces.'); - } - return normalized; - }, - }, - { - key: 'logChannelId', - skipMessage: 'Keeping the current log channel.', - question: 'Which channel should receive bot logs?\nSend a channel mention, channel ID, `none` to clear, `skip` to keep the current value, or `cancel` to stop.', - parse: async (answer) => { - const normalized = answer.trim(); - if (normalized.toLowerCase() === 'skip') return undefined; - if (normalized.toLowerCase() === 'none') return null; - const id = extractId(normalized); - if (!id) throw new Error('Provide a valid channel mention or ID from this server.'); - return validateGuildChannelId(guild, id); - }, - }, - { - key: 'modRole', - skipMessage: 'Keeping the current moderator role.', - question: 'What role should moderators have?\nSend a role mention, role ID, `none` to clear, `skip` to keep the current value, or `cancel` to stop.', - parse: async (answer) => { - const normalized = answer.trim(); - if (normalized.toLowerCase() === 'skip') return undefined; - if (normalized.toLowerCase() === 'none') return null; - const id = extractId(normalized); - if (!id) throw new Error('Provide a valid role mention or ID from this server.'); - return validateGuildRoleId(guild, id); - }, - }, - ]; - - const changes = {}; - const errors = []; - let wizardCancelled = false; - - try { - try { - await dmChannel.send({ - embeds: [createEmbed({ - title: 'šŸ“ Setup Wizard', - description: 'Answer each question in this DM.\n\n• Type `skip` to keep the current value\n• Type `cancel` to stop the wizard', - color: 'info', - })], - }); - } catch (error) { - logger.warn('Failed to send setup wizard DM', { userId: user.id, error: error.message }); - await notifyWizardDmBlocked(buttonInteraction); - return; - } - - await notifyWizardStarted(buttonInteraction); - - for (let index = 0; index < prompts.length; index++) { - const prompt = prompts[index]; - let answered = false; - - while (!answered) { - const result = await askQuestion( - dmChannel, - user.id, - prompt.question, - index + 1, - prompts.length, - ); - - if (result === null) { - wizardCancelled = true; - answered = true; - break; - } - - if (result.cancelled) { - wizardCancelled = true; - answered = true; - break; - } - - try { - const value = await prompt.parse(result.answer); - - if (value === undefined) { - await dmChannel.send({ - embeds: [infoEmbed('Skipped', prompt.skipMessage)], - }); - } else { - await ConfigService.updateSetting(client, guild.id, prompt.key, value, user.id); - changes[prompt.key] = value; - await dmChannel.send({ - embeds: [successEmbed('Saved', formatSavedAck(prompt.key, value, guild))], - }); - - try { - const updatedConfig = await getGuildConfig(client, guild.id); - await refreshDashboard(rootInteraction, updatedConfig, guild); - } catch (refreshError) { - logger.debug('Failed to refresh dashboard during setup wizard', { error: refreshError.message }); - } - } - - answered = true; - } catch (error) { - errors.push(`• ${prompt.key}: ${error.message}`); - await dmChannel.send({ - embeds: [buildUserErrorEmbed(ErrorTypes.VALIDATION, `${error.message}\n\nPlease reply again with a valid answer, \`skip\`, or \`cancel\`.`)], - }); - } - } - - if (wizardCancelled) { - break; - } - } - - if (!wizardCancelled) { - try { - await setConfigValue(client, guild.id, 'setupWizardCompleted', true); - } catch (error) { - logger.warn('Failed to persist setupWizardCompleted flag', { guildId: guild.id, error: error.message }); - } - } - - const summaryTitle = wizardCancelled - ? (Object.keys(changes).length > 0 ? 'Setup Stopped' : 'Setup Cancelled') - : (errors.length > 0 ? 'Setup Complete' : 'Setup Complete'); - - const summaryBody = wizardCancelled - ? (Object.keys(changes).length > 0 - ? `Setup stopped early. Saved **${Object.keys(changes).length}** setting(s) before stopping.` - : 'Setup wizard stopped before any changes were saved.') - : (Object.keys(changes).length > 0 - ? `Updated **${Object.keys(changes).length}** setting(s).${errors.length > 0 ? ' Some answers needed retries.' : ''}` - : 'No changes were applied.'); - - const summaryEmbed = createEmbed({ - title: wizardCancelled ? `āš ļø ${summaryTitle}` : `āœ… ${summaryTitle}`, - description: summaryBody, - color: wizardCancelled ? 'warning' : (errors.length > 0 ? 'warning' : 'success'), - }); - - if (errors.length > 0) { - const uniqueErrors = [...new Set(errors)]; - summaryEmbed.addFields({ name: 'Issues', value: uniqueErrors.join('\n').slice(0, 1024) }); - } - - await dmChannel.send({ embeds: [summaryEmbed] }); - - try { - const updatedConfig = await getGuildConfig(client, guild.id); - await refreshDashboard(rootInteraction, updatedConfig, guild); - } catch (error) { - logger.debug('Failed to refresh dashboard after wizard completion', { error: error.message }); - } - } finally { - activeWizardSessions.delete(user.id); - } -} - -async function showSettingModal(selectInteraction, guildId, setting) { - const modalCustomId = `config_wizard_modal:${setting}:${guildId}`; - - if (setting === 'logChannelId') { - const modal = new ModalBuilder() - .setCustomId(modalCustomId) - .setTitle('šŸ“‹ Update Log Channel'); - - const channelSelect = new ChannelSelectMenuBuilder() - .setCustomId('log_channel') - .setPlaceholder('Select a text channel...') - .setMinValues(1) - .setMaxValues(1) - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .setRequired(true); - - const channelLabel = new LabelBuilder() - .setLabel('Log Channel') - .setDescription('Channel where system log messages will be sent') - .setChannelSelectMenuComponent(channelSelect); - - modal.addLabelComponents(channelLabel); - await selectInteraction.showModal(modal); - return; - } - - if (setting === 'modRole') { - const modal = new ModalBuilder() - .setCustomId(modalCustomId) - .setTitle('šŸ›”ļø Update Moderator Role'); - - const roleSelect = new RoleSelectMenuBuilder() - .setCustomId('mod_role') - .setPlaceholder('Select a moderator role...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const roleLabel = new LabelBuilder() - .setLabel('Moderator Role') - .setDescription('Role used for moderation commands') - .setRoleSelectMenuComponent(roleSelect); - - modal.addLabelComponents(roleLabel); - await selectInteraction.showModal(modal); - return; - } - - const modal = new ModalBuilder() - .setCustomId(modalCustomId) - .setTitle('Update Server Prefix'); - - const textInput = new TextInputBuilder() - .setCustomId('value') - .setLabel('New prefix (1-10 characters, no spaces)') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(10); - - modal.addComponents(new ActionRowBuilder().addComponents(textInput)); - await selectInteraction.showModal(modal); -} - -function resolveSettingModalValue(setting, submitted) { - if (setting === 'logChannelId') { - const channelId = submitted.fields.getField('log_channel')?.values?.[0]; - if (!channelId) { - throw new Error('Please select a log channel.'); - } - return channelId; - } - - if (setting === 'modRole') { - const roleId = submitted.fields.getField('mod_role')?.values?.[0]; - if (!roleId) { - throw new Error('Please select a moderator role.'); - } - return roleId; - } - - const prefix = submitted.fields.getTextInputValue('value')?.trim(); - if (!prefix || prefix.length < 1 || prefix.length > 10 || /\s/.test(prefix)) { - throw new Error('Prefix must be 1-10 characters with no spaces.'); - } - return prefix; -} - -function buildSettingSuccessMessage(setting, value, guild) { - if (setting === 'logChannelId') { - const channel = guild.channels.cache.get(value); - return `Log channel set to ${channel ?? `<#${value}>`}.`; - } - - if (setting === 'modRole') { - const role = guild.roles.cache.get(value); - return `Moderator role set to ${role ?? `<@&${value}>`}.`; - } - - return `Server prefix set to \`${value}\`.`; -} - -async function handleSettingModalSubmit(selectInteraction, rootInteraction, setting, guildId, client) { - const modalCustomId = `config_wizard_modal:${setting}:${guildId}`; - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: (modalInteraction) => - modalInteraction.customId === modalCustomId && - modalInteraction.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) { - return; - } - - try { - const value = resolveSettingModalValue(setting, submitted); - await ConfigService.updateSetting(client, guildId, setting, value, submitted.user.id); - - await submitted.reply({ - embeds: [successEmbed('Configuration Updated', buildSettingSuccessMessage(setting, value, submitted.guild))], - flags: MessageFlags.Ephemeral, - }); - - const updatedConfig = await getGuildConfig(client, guildId); - await refreshDashboard(rootInteraction, updatedConfig, submitted.guild); - } catch (error) { - logger.error('Config wizard modal submit error:', error); - await replyUserError(submitted, { - type: ErrorTypes.CONFIGURATION, - message: error.message || 'Please try again.', - }).catch(() => {}); - } -} - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('configwizard') - .setDescription('Open the server configuration dashboard and setup wizard') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false), - category: 'Core', - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) { - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return replyUserError(interaction, { - type: ErrorTypes.PERMISSION, - message: 'You need the **Manage Server** permission to use this command.', - }); - } - - const guildConfig = await getGuildConfig(interaction.client, interaction.guildId); - const embed = buildDashboardEmbed(guildConfig, interaction.guild); - const components = [buildButtonRow(guildConfig, interaction.guildId), buildSettingsSelect(interaction.guildId)]; - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components }); - - const replyMessage = await interaction.fetchReply().catch(() => null); - if (!replyMessage) { - return; - } - - const collectorFilter = (componentInteraction) => - componentInteraction.user.id === interaction.user.id && - componentInteraction.customId.includes(`:${interaction.guildId}`); - - const componentCollector = replyMessage.createMessageComponentCollector({ - filter: collectorFilter, - time: 600_000, - }); - - componentCollector.on('collect', async (componentInteraction) => { - try { - if (componentInteraction.isButton()) { - await componentInteraction.deferUpdate(); - - if (componentInteraction.customId.startsWith(`${WIZARD_BUTTON_ID}:`)) { - const latestConfig = await getGuildConfig(interaction.client, interaction.guildId); - await runSetupWizard(componentInteraction, latestConfig, interaction.guild, interaction.client, interaction); - } - return; - } - - if (componentInteraction.isStringSelectMenu()) { - const selected = componentInteraction.values[0]; - await showSettingModal(componentInteraction, interaction.guildId, selected); - await handleSettingModalSubmit( - componentInteraction, - interaction, - selected, - interaction.guildId, - interaction.client, - ); - } - } catch (error) { - logger.error('Config dashboard interaction error:', error); - await replyUserError(componentInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'Failed to process your selection. Please try again.', - }).catch(() => {}); - } - }); - } catch (error) { - logger.error('Config command error:', error); - await replyUserError(interaction, { - type: ErrorTypes.CONFIGURATION, - message: 'Failed to open configuration dashboard. Please try again.', - }); - } - }, -}; diff --git a/src/commands/Core/help.js b/src/commands/Core/help.js deleted file mode 100644 index 3d9d7eb783..0000000000 --- a/src/commands/Core/help.js +++ /dev/null @@ -1,181 +0,0 @@ -import { - SlashCommandBuilder, - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, -} from "discord.js"; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createEmbed } from "../../utils/embeds.js"; -import { - createSelectMenu, -} from "../../utils/components.js"; -import fs from "fs/promises"; -import path from "path"; -import { fileURLToPath } from "url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const CATEGORY_SELECT_ID = "help-category-select"; -const ALL_COMMANDS_ID = "help-all-commands"; -const BUG_REPORT_BUTTON_ID = "help-bug-report"; -const HELP_MENU_TIMEOUT_MS = 5 * 60 * 1000; - -const CATEGORY_ICONS = { - Core: "ā„¹ļø", - Moderation: "šŸ›”ļø", - Economy: "šŸ’°", - Music: "šŸŽµ", - Fun: "šŸŽ®", - Leveling: "šŸ“Š", - Utility: "šŸ”§", - Ticket: "šŸŽ«", - Welcome: "šŸ‘‹", - Giveaway: "šŸŽ‰", - Counter: "šŸ”¢", - Tools: "šŸ› ļø", - Search: "šŸ”", - "Reaction Roles": "šŸŽ­", - Community: "šŸ‘„", - Birthday: "šŸŽ‚", - "Join To Create": "šŸ”Œ", - Verification: "āœ…", -}; - -function formatCategoryName(rawCategory) { - return rawCategory - .replace(/_/g, '') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .replace(/\b\w/g, (char) => char.toUpperCase()); -} - -export async function createInitialHelpMenu(client) { - const commandsPath = path.join(__dirname, "../../commands"); - const categoryDirs = ( - await fs.readdir(commandsPath, { withFileTypes: true }) - ) - .filter((dirent) => dirent.isDirectory()) - .map((dirent) => dirent.name) - .sort(); - - const options = [ - { - label: "šŸ“‹ All Commands", - description: "Browse every available command in a single list", - value: ALL_COMMANDS_ID, - }, - ...categoryDirs.map((category) => { - const categoryName = formatCategoryName(category); - const icon = CATEGORY_ICONS[categoryName] || "šŸ”"; - return { - label: `${icon} ${categoryName}`, - description: `View commands in the ${categoryName} category`, - value: category, - }; - }), - ]; - - const botName = client?.user?.username || "Bot"; - const embed = createEmbed({ - title: `šŸ“– ${botName} Help`, - description: 'Set up your server, pick what to enable, then browse commands below.', - color: 'primary', - thumbnail: client.user?.displayAvatarURL?.({ size: 1024 }), - fields: [ - { - name: 'šŸš€ Getting Started', - value: [ - '**1. Launch setup** — Run `/configwizard` to configure prefix, mod role, and logs.', - '**2. Enable systems** — Use `/commands dashboard` to turn categories on or off.', '**3. Browse commands** — Use the menu below to view categories and commands.', - ].join('\n'), - inline: false, - }, - { - name: 'ā„¹ļø How It Works', - value: [ - '• Dashboard commands manage each feature visually', - '• Settings are saved per server', - '• Slash commands and prefixes both work once enabled', - ].join('\n'), - inline: false, - }, - { - name: '\u200B', - value: `-# ${botName} is [open source](https://youtu.be/1jCZX8s3bJE?si=NPOYx-vxVE1I5vJK)`, - inline: false, - }, - ], - }); - - embed.setFooter({ - text: "Made with ā¤ļø" - }); - embed.setTimestamp(); - - const bugReportButton = new ButtonBuilder() - .setCustomId(BUG_REPORT_BUTTON_ID) - .setLabel("Report Bug") - .setStyle(ButtonStyle.Danger); - - const supportButton = new ButtonBuilder() - .setLabel("Support Server") - .setURL("https://discord.gg/QnWNz2dKCE") - .setStyle(ButtonStyle.Link); - - const selectRow = createSelectMenu( - CATEGORY_SELECT_ID, - "Select to view the commands", - options, - ); - - const buttonRow = new ActionRowBuilder().addComponents([ - bugReportButton, - supportButton, - ]); - - return { - embeds: [embed], - components: [buttonRow, selectRow], - }; -} - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName("help") - .setDescription("Displays the help menu with all available commands"), - - async execute(interaction, guildConfig, client) { - - const { MessageFlags } = await import('discord.js'); - await InteractionHelper.safeDefer(interaction); - - const { embeds, components } = await createInitialHelpMenu(client); - - await InteractionHelper.safeEditReply(interaction, { - embeds, - components, - }); - - setTimeout(async () => { - try { - if (!InteractionHelper.isInteractionValid(interaction)) { - return; - } - - const closedEmbed = createEmbed({ - title: "Help menu closed", - description: "Help menu has been closed, use /help again.", - color: "secondary", - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [closedEmbed], - components: [], - }); - } catch (error) { - logger.debug('Help menu close edit failed (interaction may have expired):', error?.message); - } - }, HELP_MENU_TIMEOUT_MS); - }, -}; \ No newline at end of file diff --git a/src/commands/Core/modules/commands_dashboard.js b/src/commands/Core/modules/commands_dashboard.js deleted file mode 100644 index 88a8865a50..0000000000 --- a/src/commands/Core/modules/commands_dashboard.js +++ /dev/null @@ -1,387 +0,0 @@ -import { - ActionRowBuilder, - ButtonBuilder, - ButtonStyle, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, -} from 'discord.js'; -import { createEmbed } from '../../../utils/embeds.js'; -import { - getCommandAccessSnapshot, - disableCategory, - enableCategory, - disableCommand, - enableCommand, - resetCategoryCommands, -} from '../../../services/commandAccessService.js'; -import { getGuildConfig } from '../../../services/config/guildConfig.js'; - -export const DASHBOARD_CATEGORY_SELECT = 'cmdaccess_category'; -export const DASHBOARD_COMMAND_SELECT = 'cmdaccess_command'; -export const DASHBOARD_TOGGLE_CATEGORY = 'cmdaccess_toggle_category'; -export const DASHBOARD_ENABLE_ALL = 'cmdaccess_enable_all'; -export const DASHBOARD_DISABLE_ALL = 'cmdaccess_disable_all'; -export const DASHBOARD_RESET_COMMANDS = 'cmdaccess_reset_commands'; -export const DASHBOARD_REFRESH = 'cmdaccess_refresh'; -export const DASHBOARD_HOME = 'cmdaccess_home'; - -const STATUS = { - enabled: '🟢', - partial: '🟔', - disabled: 'šŸ”“', -}; - -function customId(base, guildId, suffix = '') { - return suffix ? `${base}:${guildId}:${suffix}` : `${base}:${guildId}`; -} - -function getCategoryStatus(category) { - if (category.categoryDisabled) { - return STATUS.disabled; - } - if (category.disabledCount === 0) { - return STATUS.enabled; - } - return STATUS.partial; -} - -function formatCommandLabel(command) { - if (command.isSubcommand) { - return `\`${command.name.replace(/ /g, ' ')}\``; - } - return `\`${command.name}\``; -} - -function chunkLines(lines, maxLength = 980) { - const chunks = []; - let current = ''; - - for (const line of lines) { - const next = current ? `${current}\n${line}` : line; - if (next.length > maxLength && current) { - chunks.push(current); - current = line; - } else { - current = next; - } - } - - if (current) { - chunks.push(current); - } - - return chunks; -} - -export function buildOverviewEmbed(snapshot, guild) { - const fullyEnabled = snapshot.categories.filter((c) => !c.categoryDisabled && c.disabledCount === 0).length; - const partial = snapshot.categories.filter((c) => !c.categoryDisabled && c.disabledCount > 0).length; - const disabled = snapshot.categories.filter((c) => c.categoryDisabled).length; - - const categoryLines = snapshot.categories.map((category) => { - const icon = getCategoryStatus(category); - const subcommandNote = category.commands.some((c) => c.isSubcommand) ? ' Ā· incl. subcommands' : ''; - return `${icon} ${category.icon} **${category.displayName}** — ${category.enabledCount}/${category.totalCount}${subcommandNote}`; - }); - - const fields = [ - { - name: 'šŸ“Š Summary', - value: [ - `**${snapshot.enabledTotal}/${snapshot.totalCommands}** entries enabled`, - `${STATUS.enabled} ${fullyEnabled} fully on Ā· ${STATUS.partial} ${partial} partial Ā· ${STATUS.disabled} ${disabled} off`, - ].join('\n'), - inline: false, - }, - { - name: 'šŸ”‘ Legend', - value: `${STATUS.enabled} All enabled Ā· ${STATUS.partial} Some disabled Ā· ${STATUS.disabled} Category off`, - inline: false, - }, - ]; - - const chunks = chunkLines(categoryLines); - chunks.forEach((chunk, index) => { - fields.push({ - name: index === 0 ? 'šŸ“ Categories' : 'šŸ“ Categories (cont.)', - value: chunk, - inline: false, - }); - }); - - fields.push({ - name: 'How to Use', - value: [ - '• Select a category below to manage commands and subcommands', - '• `/commands disable` — turn off a category or specific command', - '• `/commands enable` — turn something back on', - ].join('\n'), - }); - - return createEmbed({ - title: 'āš™ļø Command Access', - description: `Manage slash and prefix commands for **${guild.name}**. Subcommands (e.g. \`birthday list\`) are listed separately.`, - color: 'info', - fields, - footer: 'šŸ”’ commands & configwizard always stay available', - }); -} - -export function buildCategoryEmbed(category, guild) { - const statusIcon = getCategoryStatus(category); - const statusText = category.categoryDisabled - ? 'Category disabled' - : category.disabledCount === 0 - ? 'All entries enabled' - : `${category.disabledCount} of ${category.totalCount} disabled`; - - const commandLines = category.commands.map((command) => { - const enabled = category.enabledCommands.includes(command.name); - const icon = enabled ? STATUS.enabled : STATUS.disabled; - const lock = command.protected ? ' šŸ”’' : ''; - return `${icon} ${formatCommandLabel(command)}${lock}`; - }); - - const fields = [ - { - name: `${statusIcon} Status`, - value: statusText, - inline: true, - }, - { - name: 'šŸ“ˆ Count', - value: `${category.enabledCount}/${category.totalCount} enabled`, - inline: true, - }, - ]; - - const chunks = chunkLines(commandLines); - chunks.forEach((chunk, index) => { - fields.push({ - name: index === 0 ? 'šŸ“‹ Commands & Subcommands' : 'šŸ“‹ (cont.)', - value: chunk, - inline: false, - }); - }); - - fields.push({ - name: 'How to Use', - value: [ - '• Use the dropdown to toggle individual commands or subcommands', - '• **Disable All** turns off the whole category', - '• **Clear Overrides** re-enables individually disabled entries', - ].join('\n'), - }); - - return createEmbed({ - title: `${category.icon} ${category.displayName}`, - description: `Command access for **${guild.name}**.`, - color: category.categoryDisabled ? 'error' : category.disabledCount > 0 ? 'warning' : 'success', - fields, - footer: 'šŸ”’ Protected entries cannot be disabled', - }); -} - -export function buildOverviewComponents(guildId, snapshot) { - const categoryOptions = snapshot.categories.slice(0, 25).map((category) => { - const status = getCategoryStatus(category); - return new StringSelectMenuOptionBuilder() - .setLabel(`${category.displayName}`.slice(0, 100)) - .setDescription(`${status} ${category.enabledCount}/${category.totalCount} enabled`.slice(0, 100)) - .setValue(category.key) - .setEmoji(category.icon); - }); - - return [ - new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId(customId(DASHBOARD_CATEGORY_SELECT, guildId)) - .setPlaceholder('šŸ“ Select a category...') - .addOptions(categoryOptions), - ), - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_REFRESH, guildId)) - .setLabel('Refresh') - .setEmoji('šŸ”„') - .setStyle(ButtonStyle.Secondary), - ), - ]; -} - -export function buildCategoryComponents(guildId, category) { - const toggleableCommands = category.commands.filter((command) => !command.protected); - const commandOptions = toggleableCommands.slice(0, 25).map((command) => { - const enabled = category.enabledCommands.includes(command.name); - const label = command.isSubcommand - ? command.name.replace(' ', ' Ā· ').slice(0, 100) - : command.name.slice(0, 100); - - return new StringSelectMenuOptionBuilder() - .setLabel(label) - .setDescription((enabled ? '🟢 Enabled — click to disable' : 'šŸ”“ Disabled — click to enable').slice(0, 100)) - .setValue(command.name); - }); - - const rows = [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_HOME, guildId)) - .setLabel('Back') - .setEmoji('ā—€ļø') - .setStyle(ButtonStyle.Secondary), - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_TOGGLE_CATEGORY, guildId, category.key)) - .setLabel(category.categoryDisabled ? 'Enable Category' : 'Disable Category') - .setEmoji(category.categoryDisabled ? '🟢' : 'šŸ”“') - .setStyle(category.categoryDisabled ? ButtonStyle.Success : ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_ENABLE_ALL, guildId, category.key)) - .setLabel('Enable All') - .setEmoji('āœ…') - .setStyle(ButtonStyle.Success), - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_DISABLE_ALL, guildId, category.key)) - .setLabel('Disable All') - .setEmoji('ā›”') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(customId(DASHBOARD_RESET_COMMANDS, guildId, category.key)) - .setLabel('Clear Overrides') - .setEmoji('🧹') - .setStyle(ButtonStyle.Secondary), - ), - ]; - - if (commandOptions.length > 0) { - rows.unshift( - new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId(customId(DASHBOARD_COMMAND_SELECT, guildId, category.key)) - .setPlaceholder('Toggle a command or subcommand...') - .addOptions(commandOptions), - ), - ); - } - - return rows; -} - -export async function buildDashboardView(client, guildId, guild, view = 'overview', categoryKey = null) { - const config = await getGuildConfig(client, guildId); - const snapshot = getCommandAccessSnapshot(client, config); - - if (view === 'category' && categoryKey) { - const category = snapshot.categories.find((entry) => entry.key === categoryKey); - if (!category) { - return { - embed: buildOverviewEmbed(snapshot, guild), - components: buildOverviewComponents(guildId, snapshot), - }; - } - - return { - embed: buildCategoryEmbed(category, guild), - components: buildCategoryComponents(guildId, category), - categoryKey, - }; - } - - return { - embed: buildOverviewEmbed(snapshot, guild), - components: buildOverviewComponents(guildId, snapshot), - }; -} - -export async function handleDashboardComponent(interaction, client) { - const parts = interaction.customId.split(':'); - const action = parts[0]; - const guildId = parts[1]; - const suffix = parts[2] || null; - - if (guildId !== interaction.guildId) { - return interaction.reply({ - content: 'This dashboard belongs to another server.', - ephemeral: true, - }); - } - - if (action === DASHBOARD_COMMAND_SELECT) { - const categoryKey = suffix; - const commandName = interaction.values[0]; - const config = await getGuildConfig(client, guildId); - const snapshot = getCommandAccessSnapshot(client, config); - const category = snapshot.categories.find((entry) => entry.key === categoryKey); - const enabled = category?.enabledCommands.includes(commandName); - - if (enabled) { - await disableCommand(client, guildId, commandName); - } else { - await enableCommand(client, guildId, commandName); - } - - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); - return interaction.update({ embeds: [view.embed], components: view.components }); - } - - if (action === DASHBOARD_CATEGORY_SELECT) { - const categoryKey = interaction.values[0]; - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); - return interaction.update({ embeds: [view.embed], components: view.components }); - } - - await interaction.deferUpdate(); - - if (action === DASHBOARD_REFRESH || action === DASHBOARD_HOME) { - const view = await buildDashboardView(client, guildId, interaction.guild, 'overview'); - return interaction.editReply({ embeds: [view.embed], components: view.components }); - } - - if (action === DASHBOARD_TOGGLE_CATEGORY) { - const categoryKey = suffix; - const config = await getGuildConfig(client, guildId); - const snapshot = getCommandAccessSnapshot(client, config); - const category = snapshot.categories.find((entry) => entry.key === categoryKey); - - if (category?.categoryDisabled) { - await enableCategory(client, guildId, categoryKey); - } else { - await disableCategory(client, guildId, categoryKey); - } - - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', categoryKey); - return interaction.editReply({ embeds: [view.embed], components: view.components }); - } - - if (action === DASHBOARD_ENABLE_ALL) { - await enableCategory(client, guildId, suffix); - await resetCategoryCommands(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); - return interaction.editReply({ embeds: [view.embed], components: view.components }); - } - - if (action === DASHBOARD_DISABLE_ALL) { - await disableCategory(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); - return interaction.editReply({ embeds: [view.embed], components: view.components }); - } - - if (action === DASHBOARD_RESET_COMMANDS) { - await enableCategory(client, guildId, suffix); - await resetCategoryCommands(client, guildId, suffix); - const view = await buildDashboardView(client, guildId, interaction.guild, 'category', suffix); - return interaction.editReply({ embeds: [view.embed], components: view.components }); - } - - return interaction.editReply({ content: 'Unknown dashboard action.', embeds: [], components: [] }); -} - -export function isCommandAccessCustomId(customIdValue) { - return customIdValue.startsWith('cmdaccess_'); -} - -export function createDashboardCollectorFilter(userId, guildId) { - return (componentInteraction) => - componentInteraction.user.id === userId && - componentInteraction.customId.includes(`:${guildId}`); -} diff --git a/src/commands/Core/ping.js b/src/commands/Core/ping.js deleted file mode 100644 index 8341f780d0..0000000000 --- a/src/commands/Core/ping.js +++ /dev/null @@ -1,81 +0,0 @@ -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() - .setName("ping") - .setDescription("Checks the bot's latency and API speed"), - - async prefixExecute(interaction) { - try { - const startTime = Date.now(); - const pingingMessage = await interaction.reply({ content: 'Pinging...' }); - - const latency = Date.now() - startTime; - const apiLatency = Math.max(0, Math.round(interaction.client.ws.ping)); - - const embed = createEmbed({ title: 'Pong!', description: null }).addFields( - { name: 'Bot Latency', value: `${latency}ms`, inline: true }, - { name: 'API Latency', value: `${apiLatency}ms`, inline: true }, - ); - - await pingingMessage.edit({ content: null, embeds: [embed] }); - } catch (error) { - logger.error('Ping prefix command error:', error); - if (!interaction.replied && !interaction._replyMessage) { - await interaction.channel.send({ - embeds: [createEmbed({ title: 'System Error', description: 'Could not determine latency at this time.', color: 'error' })], - }).catch(() => {}); - } - } - }, - - async execute(interaction) { - logger.info('execute called - checking if slash command or prefix command'); - logger.info(`execute - has _commandStartTime: ${!!interaction._commandStartTime}, createdTimestamp: ${interaction.createdTimestamp}`); - - 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 { - await InteractionHelper.safeEditReply(interaction, { - content: "Pinging...", - }); - - const startTime = interaction._commandStartTime || interaction.createdTimestamp; - logger.info(`execute - using startTime: ${startTime}, type: ${interaction._commandStartTime ? 'prefix' : 'slash'}`); - const latency = Math.max(0, Date.now() - startTime); - const apiLatency = Math.max(0, Math.round(interaction.client.ws.ping)); - logger.info(`execute - calculated latency: ${latency}ms, apiLatency: ${apiLatency}ms`); - - const embed = createEmbed({ title: "Pong!", description: null }).addFields( - { name: "Bot Latency", value: `${latency}ms`, inline: true }, - { name: "API Latency", value: `${apiLatency}ms`, inline: true }, - ); - - await InteractionHelper.safeEditReply(interaction, { - content: null, - embeds: [embed], - }); - } catch (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) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Core/stats.js b/src/commands/Core/stats.js deleted file mode 100644 index fc6dc3d392..0000000000 --- a/src/commands/Core/stats.js +++ /dev/null @@ -1,43 +0,0 @@ -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 { - data: new SlashCommandBuilder() - .setName("stats") - .setDescription("View bot statistics"), - - async execute(interaction) { - try { - await InteractionHelper.safeDefer(interaction); - - const totalGuilds = interaction.client.guilds.cache.size; - const totalMembers = interaction.client.guilds.cache.reduce( - (acc, guild) => acc + guild.memberCount, - 0, - ); - const nodeVersion = process.version; - - const embed = createEmbed({ title: "System Statistics", description: "Real-time performance metrics." }).addFields( - { name: "Servers", value: `${totalGuilds}`, inline: true }, - { name: "Users", value: `${totalMembers}`, inline: true }, - { name: "Node.js", value: `${nodeVersion}`, inline: true }, - { name: "Discord.js", value: `v${version}`, inline: true }, - { - name: "Memory Usage", - value: `${(process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2)} MB`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } catch (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, - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Core/support.js b/src/commands/Core/support.js deleted file mode 100644 index 032f3c894d..0000000000 --- a/src/commands/Core/support.js +++ /dev/null @@ -1,41 +0,0 @@ -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"; -export default { - data: new SlashCommandBuilder() - .setName("support") - .setDescription("Get link to the support server"), - - async execute(interaction) { - try { - const supportButton = new ButtonBuilder() - .setLabel("Join Support Server") - .setStyle(ButtonStyle.Link) - .setURL(SUPPORT_SERVER_URL); - - const actionRow = new ActionRowBuilder().addComponents(supportButton); - - 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!" }), - ], - components: [actionRow], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.error('Support command error:', error); - - try { - return await InteractionHelper.safeReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not display support information.', color: 'error' })], - flags: MessageFlags.Ephemeral, - }); - } catch (replyError) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Core/uptime.js b/src/commands/Core/uptime.js deleted file mode 100644 index a3fb52487c..0000000000 --- a/src/commands/Core/uptime.js +++ /dev/null @@ -1,44 +0,0 @@ -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() - .setName("uptime") - .setDescription("Check how long the bot has been online"), - - async execute(interaction) { - try { - await InteractionHelper.safeDefer(interaction); - - let totalSeconds = interaction.client.uptime / 1000; - let days = Math.floor(totalSeconds / 86400); - totalSeconds %= 86400; - let hours = Math.floor(totalSeconds / 3600); - totalSeconds %= 3600; - let minutes = Math.floor(totalSeconds / 60); - let seconds = Math.floor(totalSeconds % 60); - - const uptimeStr = `${days}d ${hours}h ${minutes}m ${seconds}s`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ - title: "System Uptime", - description: `\`\`\`${uptimeStr}\`\`\`` - })], - }); - } catch (error) { - logger.error('Uptime command error:', error); - - try { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [createEmbed({ title: 'System Error', description: 'Could not compute uptime.', color: 'error' })], - flags: MessageFlags.Ephemeral, - }); - } catch (replyError) { - logger.error('Failed to send error reply:', replyError); - } - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/balance.js b/src/commands/Economy/balance.js deleted file mode 100644 index 95528ecd9c..0000000000 --- a/src/commands/Economy/balance.js +++ /dev/null @@ -1,87 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('balance') - .setDescription("Check your or someone else's balance") - .addUserOption(option => - option - .setName('user') - .setDescription('User to check balance for') - .setRequired(false) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userOption = interaction.options.getUser("user"); - const targetUser = userOption || interaction.user; - const guildId = interaction.guildId; - - logger.info(`[ECONOMY] Balance check - userOption: ${userOption?.id || 'null'}, targetUser: ${targetUser.id}, guildId: ${guildId}, isPrefix: ${!!interaction._commandStartTime}`); - - logger.debug(`[ECONOMY] Balance check for ${targetUser.id}`, { userId: targetUser.id, guildId }); - - if (targetUser.bot) { - throw createError( - "Bot user queried for balance", - ErrorTypes.VALIDATION, - "Bots don't have an economy balance." - ); - } - - const userData = await getEconomyData(client, guildId, targetUser.id); - - logger.info(`[ECONOMY] Economy data retrieved - userData:`, userData); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { userId: targetUser.id, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - - const wallet = typeof userData.wallet === 'number' ? userData.wallet : 0; - const bank = typeof userData.bank === 'number' ? userData.bank : 0; - - const embed = createEmbed({ - title: `${targetUser.username}'s Balance`, - description: `Here is the current financial status for ${targetUser.username}.`, - }) - .addFields( - { - name: "šŸ’µ Cash", - value: `$${wallet.toLocaleString()}`, - inline: true, - }, - { - name: "šŸ¦ Bank", - value: `$${bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - { - name: "šŸ’° Total", - value: `$${(wallet + bank).toLocaleString()}`, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - logger.info(`[ECONOMY] Balance retrieved`, { userId: targetUser.id, wallet, bank }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'balance' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/beg.js b/src/commands/Economy/beg.js deleted file mode 100644 index 9b2fc717f9..0000000000 --- a/src/commands/Economy/beg.js +++ /dev/null @@ -1,99 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { botConfig } from '../../config/bot.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const COOLDOWN = 30 * 60 * 1000; -const MIN_WIN = Number(botConfig?.economy?.begMin) || 50; -const MAX_WIN = Number(botConfig?.economy?.begMax) || 200; -const SUCCESS_CHANCE = 0.7; - -export default { - data: new SlashCommandBuilder() - .setName('beg') - .setDescription('Beg for a small amount of money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - let userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastBeg = userData.lastBeg || 0; - const remainingTime = lastBeg + COOLDOWN - Date.now(); - - if (remainingTime > 0) { - const minutes = Math.floor(remainingTime / 60000); - const seconds = Math.floor((remainingTime % 60000) / 1000); - - let timeMessage = - minutes > 0 ? `${minutes} minute(s)` : `${seconds} second(s)`; - - throw createError( - "Beg cooldown active", - ErrorTypes.RATE_LIMIT, - `You are tired from begging! Try again in **${timeMessage}**.`, - { remainingTime, minutes, seconds, cooldownType: 'beg' } - ); - } - - const success = Math.random() < SUCCESS_CHANCE; - - let replyEmbed; - let newCash = userData.wallet; - - if (success) { - const amountWon = - Math.floor(Math.random() * (MAX_WIN - MIN_WIN + 1)) + MIN_WIN; - - newCash += amountWon; - - const successMessages = [ - `A kind stranger drops **$${amountWon.toLocaleString()}** into your cup.`, - `You spotted an unattended wallet! You grab **$${amountWon.toLocaleString()}** and run.`, - `Someone took pity on you and gave you **$${amountWon.toLocaleString()}**!`, - `You found **$${amountWon.toLocaleString()}** under a park bench.`, - ]; - - replyEmbed = successEmbed( - 'Begging Successful', - successMessages[ - Math.floor(Math.random() * successMessages.length) - ] - ); - } else { - const failMessages = [ - "The police chased you off. You got nothing.", - "Someone yelled, 'Get a job!' and walked past.", - "A squirrel stole the single coin you had.", - "You tried to beg, but you were too embarrassed and gave up.", - ]; - - replyEmbed = warningEmbed( - 'Insufficient Funds', - failMessages[Math.floor(Math.random() * failMessages.length)] - ); - } - - userData.wallet = newCash; -userData.lastBeg = Date.now(); - - await setEconomyData(client, guildId, userId, userData); - - await InteractionHelper.safeEditReply(interaction, { embeds: [replyEmbed] }); - }, { command: 'beg' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/buy.js b/src/commands/Economy/buy.js deleted file mode 100644 index 1a90387315..0000000000 --- a/src/commands/Economy/buy.js +++ /dev/null @@ -1,160 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('buy') - .setDescription('Buy an item from the shop') - .addStringOption(option => - option - .setName('item_id') - .setDescription('ID of the item to buy') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('quantity') - .setDescription('Quantity to buy (default: 1)') - .setRequired(false) - .setMinValue(1) - .setMaxValue(10) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const itemId = interaction.options.getString("item_id").toLowerCase(); - const quantity = interaction.options.getInteger("quantity") || 1; - - const item = SHOP_ITEMS.find(i => i.id === itemId); - - if (!item) { - throw createError( - `Item ${itemId} not found`, - ErrorTypes.VALIDATION, - `The item ID \`${itemId}\` does not exist in the shop.`, - { itemId } - ); - } - - if (quantity < 1) { - throw createError( - "Invalid quantity", - ErrorTypes.VALIDATION, - "You must purchase a quantity of 1 or more.", - { quantity } - ); - } - - const totalCost = item.price * quantity; - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - const userData = await getEconomyData(client, guildId, userId); - - if (userData.wallet < totalCost) { - throw createError( - "Insufficient funds", - ErrorTypes.VALIDATION, - `You need **$${totalCost.toLocaleString()}** to purchase ${quantity}x **${item.name}**, but you only have **$${userData.wallet.toLocaleString()}** in cash.`, - { required: totalCost, current: userData.wallet, itemId, quantity } - ); - } - - if (item.type === "role" && itemId === "premium_role") { - if (!PREMIUM_ROLE_ID) { - throw createError( - "Premium role not configured", - ErrorTypes.CONFIGURATION, - "The **Premium Shop Role** has not been configured by a server administrator yet.", - { itemId } - ); - } - if (interaction.member.roles.cache.has(PREMIUM_ROLE_ID)) { - throw createError( - "Role already owned", - ErrorTypes.VALIDATION, - `You already have the **${item.name}** role.`, - { itemId, roleId: PREMIUM_ROLE_ID } - ); - } - if (quantity > 1) { - throw createError( - "Invalid quantity for role", - ErrorTypes.VALIDATION, - `You can only purchase the **${item.name}** role once.`, - { itemId, quantity } - ); - } - } - - userData.wallet -= totalCost; - - let successDescription = `You successfully purchased ${quantity}x **${item.name}** for **$${totalCost.toLocaleString()}**!`; - - if (item.type === "role" && itemId === "premium_role") { - const member = interaction.member; - - const role = interaction.guild.roles.cache.get(PREMIUM_ROLE_ID); - - if (!role) { - throw createError( - "Role not found", - ErrorTypes.CONFIGURATION, - "The configured premium role no longer exists in this guild.", - { roleId: PREMIUM_ROLE_ID } - ); - } - - try { - await member.roles.add( - role, - `Purchased role: ${item.name}`, - ); - successDescription += `\n\n**šŸ‘‘ The role ${role.toString()} has been granted to you!**`; - } catch (roleError) { - userData.wallet += totalCost; - await setEconomyData(client, guildId, userId, userData); - throw createError( - "Role assignment failed", - ErrorTypes.DISCORD_API, - "Successfully deducted money, but failed to grant the role. Your cash has been refunded.", - { roleId: PREMIUM_ROLE_ID, originalError: roleError.message } - ); - } - } else if (item.type === "upgrade") { - userData.upgrades[itemId] = true; - successDescription += `\n\n**✨ Your upgrade is now active!**`; - } else if (item.type === "consumable" || item.type === "tool") { - userData.inventory[itemId] = - (userData.inventory[itemId] || 0) + quantity; - if (item.type === "tool") { - successDescription += `\n\n**šŸ› ļø ${item.name} added to your inventory!**`; - } - } - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "šŸ’° Purchase Successful", - successDescription, - ).addFields({ - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], flags: [MessageFlags.Ephemeral] }); - }, { command: 'buy' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/crime.js b/src/commands/Economy/crime.js deleted file mode 100644 index 101dc5a575..0000000000 --- a/src/commands/Economy/crime.js +++ /dev/null @@ -1,119 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const CRIME_COOLDOWN = 60 * 60 * 1000; -const JAIL_TIME = 2 * 60 * 60 * 1000; -const FINE_RATE = 0.2; - -const CRIME_TYPES = [ - { name: "Pickpocketing", min: 100, max: 500, risk: 0.3 }, - { name: "Burglary", min: 300, max: 1000, risk: 0.4 }, - { name: "Bank Heist", min: 1000, max: 5000, risk: 0.6 }, - { name: "Art Theft", min: 2000, max: 10000, risk: 0.7 }, - { name: "Cybercrime", min: 5000, max: 20000, risk: 0.8 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName('crime') - .setDescription('Commit a crime to earn money (risky)') - .addStringOption(option => - option - .setName('type') - .setDescription('Type of crime to commit') - .setRequired(true) - .addChoices( - { name: 'Pickpocketing', value: 'pickpocketing' }, - { name: 'Burglary', value: 'burglary' }, - { name: 'Bank Heist', value: 'bank-heist' }, - { name: 'Art Theft', value: 'art-theft' }, - { name: 'Cybercrime', value: 'cybercrime' }, - ) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastCrime = userData.cooldowns?.crime || 0; - const isJailed = userData.jailedUntil && userData.jailedUntil > now; - - if (isJailed) { - const timeLeft = Math.ceil((userData.jailedUntil - now) / (1000 * 60)); - throw createError( - "User is in jail", - ErrorTypes.RATE_LIMIT, - `You're in jail for ${timeLeft} more minutes!`, - { jailTimeRemaining: userData.jailedUntil - now } - ); - } - - if (now < lastCrime + CRIME_COOLDOWN) { - const timeLeft = Math.ceil((lastCrime + CRIME_COOLDOWN - now) / (1000 * 60)); - throw createError( - "Crime cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait ${timeLeft} more minutes before committing another crime.`, - { remaining: lastCrime + CRIME_COOLDOWN - now, cooldownType: 'crime' } - ); - } - - const crimeType = interaction.options.getString("type").toLowerCase(); - const crime = CRIME_TYPES.find( - c => c.name.toLowerCase().replace(/\s+/g, '-') === crimeType - ); - - if (!crime) { - throw createError( - "Invalid crime type", - ErrorTypes.VALIDATION, - "Please select a valid crime type.", - { crimeType } - ); - } - - const isSuccess = Math.random() > crime.risk; - const amountEarned = isSuccess - ? Math.floor(Math.random() * (crime.max - crime.min + 1)) + crime.min - : 0; - - userData.cooldowns = userData.cooldowns || {}; - userData.cooldowns.crime = now; - - if (isSuccess) { - userData.wallet = (userData.wallet || 0) + amountEarned; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "šŸ•µļø Crime Successful!", - `You successfully committed ${crime.name} and earned **${amountEarned}** coins!` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - // Fine is based on the potential haul of the attempted crime - const potentialHaul = Math.floor((crime.min + crime.max) / 2); - const fine = Math.min(Math.floor(potentialHaul * FINE_RATE), userData.wallet || 0); - userData.wallet = Math.max(0, (userData.wallet || 0) - fine); - userData.jailedUntil = now + JAIL_TIME; - - await setEconomyData(client, guildId, userId, userData); - - const embed = warningEmbed( - "šŸš” Crime Failed!", - `You were caught while attempting ${crime.name} and have been sent to jail! ` + - `You were fined ${fine.toLocaleString()} coins and will be in jail for 2 hours.` - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, { command: 'crime' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/daily.js b/src/commands/Economy/daily.js deleted file mode 100644 index 4045d23ed0..0000000000 --- a/src/commands/Economy/daily.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { getGuildConfig } from '../../services/config/guildConfig.js'; -import { formatDuration } from '../../utils/embeds.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { botConfig } from '../../config/bot.js'; - -const DAILY_COOLDOWN = 24 * 60 * 60 * 1000; -const DAILY_AMOUNT = botConfig.economy?.dailyAmount ?? 100; -const PREMIUM_BONUS_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('daily') - .setDescription('Claim your daily cash reward'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Daily claimed started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for daily", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastDaily = userData.lastDaily || 0; - - if (now < lastDaily + DAILY_COOLDOWN) { - const timeRemaining = lastDaily + DAILY_COOLDOWN - now; - throw createError( - "Daily cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before claiming daily again. Try again in **${formatDuration(timeRemaining)}**.`, - { timeRemaining, cooldownType: 'daily' } - ); - } - - const guildConfig = await getGuildConfig(client, guildId); - const PREMIUM_ROLE_ID = guildConfig.premiumRoleId; - - let earned = DAILY_AMOUNT; - let bonusMessage = ""; - let hasPremiumRole = false; - - if ( - PREMIUM_ROLE_ID && - interaction.member && - interaction.member.roles.cache.has(PREMIUM_ROLE_ID) - ) { - const bonusAmount = Math.floor( - DAILY_AMOUNT * PREMIUM_BONUS_PERCENTAGE, - ); - earned += bonusAmount; - bonusMessage = `\n✨ **Premium Bonus:** +$${bonusAmount.toLocaleString()}`; - hasPremiumRole = true; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastDaily = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Daily claimed`, { - userId, - guildId, - amount: earned, - newWallet: userData.wallet, - hasPremium: hasPremiumRole, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "āœ… Daily Claimed!", - `You have claimed your daily **$${earned.toLocaleString()}**!${bonusMessage}` - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ - text: hasPremiumRole - ? `Next claim in 24 hours. (Premium Active)` - : `Next claim in 24 hours.`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'daily' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/deposit.js b/src/commands/Economy/deposit.js deleted file mode 100644 index 56015b5fb4..0000000000 --- a/src/commands/Economy/deposit.js +++ /dev/null @@ -1,138 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('deposit') - .setDescription('Deposit money from your wallet into your bank') - .addStringOption(option => - option - .setName('amount') - .setDescription('Amount to deposit (number or "all")') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getString("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const maxBank = getMaxBankCapacity(userData); - let depositAmount; - - if (amountInput.toLowerCase() === "all") { - depositAmount = userData.wallet; - } else { - depositAmount = parseInt(amountInput); - - if (isNaN(depositAmount) || depositAmount <= 0) { - throw createError( - "Invalid deposit amount", - ErrorTypes.VALIDATION, - `Please enter a valid number or 'all'. You entered: \`${amountInput}\``, - { amountInput, userId } - ); - } - } - - if (depositAmount === 0) { - throw createError( - "Zero deposit amount", - ErrorTypes.VALIDATION, - "You have no cash to deposit.", - { userId, walletBalance: userData.wallet } - ); - } - - if (depositAmount > userData.wallet) { - depositAmount = userData.wallet; - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You tried to deposit more than you have. Depositing your remaining cash: **$${depositAmount.toLocaleString()}**` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - - const availableSpace = maxBank - userData.bank; - - if (availableSpace <= 0) { - throw createError( - "Bank is full", - ErrorTypes.VALIDATION, - `Your bank is currently full (Max Capacity: $${maxBank.toLocaleString()}). Purchase a **Bank Upgrade** to increase your limit.`, - { maxBank, currentBank: userData.bank, userId } - ); - } - - if (depositAmount > availableSpace) { - const originalDepositAmount = depositAmount; - depositAmount = availableSpace; - - if (amountInput.toLowerCase() !== "all") { - await interaction.followUp({ - embeds: [ - buildUserErrorEmbed( - 'validation', - `You only had space for **$${depositAmount.toLocaleString()}** in your bank account (Max: $${maxBank.toLocaleString()}). The rest remains in your cash.` - ) - ], - flags: MessageFlags.Ephemeral, - }); - } - } - - if (depositAmount === 0) { - throw createError( - "No space or cash for deposit", - ErrorTypes.VALIDATION, - "The amount you tried to deposit was either 0 or exceeded your bank capacity after checking your cash balance.", - { depositAmount, availableSpace, walletBalance: userData.wallet } - ); - } - - userData.wallet -= depositAmount; - userData.bank += depositAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Deposit Successful', - `You successfully deposited **$${depositAmount.toLocaleString()}** into your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()} / $${maxBank.toLocaleString()}`, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'deposit' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/economy.js b/src/commands/Economy/economy.js deleted file mode 100644 index d702eb6157..0000000000 --- a/src/commands/Economy/economy.js +++ /dev/null @@ -1,32 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import economyDashboard from './modules/economy_dashboard.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('economy') - .setDescription('Economy management commands') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand(subcommand => - subcommand - .setName('dashboard') - .setDescription('Open the economy management dashboard') - ), - category: 'Economy', - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferred) return; - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'dashboard') { - await economyDashboard.execute(interaction, config, client); - } - } -}; \ No newline at end of file diff --git a/src/commands/Economy/eleaderboard.js b/src/commands/Economy/eleaderboard.js deleted file mode 100644 index e4603797a7..0000000000 --- a/src/commands/Economy/eleaderboard.js +++ /dev/null @@ -1,89 +0,0 @@ -import { SlashCommandBuilder } from 'discord.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'; -import { getEconomyPrefix } from '../../utils/database.js'; - -export default { - data: new SlashCommandBuilder() - .setName("eleaderboard") - .setDescription("View the server's top 10 richest users.") - .setDMPermission(false), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Leaderboard requested`, { guildId }); - - const prefix = getEconomyPrefix(guildId); - - let allKeys = await client.db.list(prefix); - - if (!Array.isArray(allKeys)) { - allKeys = []; - } - - if (allKeys.length === 0) { - throw createError( - "No economy data found", - ErrorTypes.VALIDATION, - "No economy data found for this server." - ); - } - - let allUserData = []; - - for (const key of allKeys) { - const userId = key.replace(prefix, ""); - const userData = await client.db.get(key); - - if (userData) { - allUserData.push({ - userId: userId, - net_worth: (userData.wallet || 0) + (userData.bank || 0), - }); - } - } - - allUserData.sort((a, b) => b.net_worth - a.net_worth); - - const topUsers = allUserData.slice(0, 10); - const userRank = - allUserData.findIndex((u) => u.userId === interaction.user.id) + - 1; - const rankEmoji = ["šŸ„‡", "🄈", "šŸ„‰"]; - const leaderboardEntries = []; - - for (let i = 0; i < topUsers.length; i++) { - const user = topUsers[i]; - const rank = i + 1; - const emoji = rankEmoji[i] || `**#${rank}**`; - - leaderboardEntries.push( - `${emoji} <@${user.userId}> - šŸ¦ ${user.net_worth.toLocaleString()}`, - ); - } - - logger.info(`[ECONOMY] Leaderboard generated`, { - guildId, - userCount: allUserData.length, - userRank - }); - - 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] }); - }, { command: 'eleaderboard' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/fish.js b/src/commands/Economy/fish.js deleted file mode 100644 index 5288f048bb..0000000000 --- a/src/commands/Economy/fish.js +++ /dev/null @@ -1,132 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const FISH_COOLDOWN = 45 * 60 * 1000; -const BASE_MIN_REWARD = 300; -const BASE_MAX_REWARD = 900; -const FISHING_ROD_MULTIPLIER = 1.5; - -const FISH_TYPES = [ - { name: 'Bass', emoji: '🐟', rarity: 'common' }, - { name: 'Salmon', emoji: '🐟', rarity: 'common' }, - { name: 'Trout', emoji: '🐟', rarity: 'common' }, - { name: 'Tuna', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Swordfish', emoji: '🐠', rarity: 'uncommon' }, - { name: 'Octopus', emoji: 'šŸ™', rarity: 'rare' }, - { name: 'Lobster', emoji: 'šŸ¦ž', rarity: 'rare' }, - { name: 'Shark', emoji: '🦈', rarity: 'epic' }, - { name: 'Whale', emoji: 'šŸ‹', rarity: 'legendary' }, -]; - -const CATCH_MESSAGES = [ - "You cast your line into the crystal clear waters...", - "You wait patiently as your bobber floats...", - "After a few minutes of waiting, you feel a tug...", - "The water ripples as something takes your bait...", - "You reel in your catch with expert precision...", -]; - -export default { - data: new SlashCommandBuilder() - .setName('fish') - .setDescription('Go fishing to catch fish and earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastFish = userData.lastFish || 0; - const hasFishingRod = userData.inventory["fishing_rod"] || 0; - - if (now < lastFish + FISH_COOLDOWN) { - const remaining = lastFish + FISH_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Fishing cooldown active", - ErrorTypes.RATE_LIMIT, - `You're too tired to fish right now. Rest for **${hours}h ${minutes}m** before fishing again.`, - { remaining, cooldownType: 'fish' } - ); - } - - const rand = Math.random(); - let fishCaught; - - if (rand < 0.5) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'common')[Math.floor(Math.random() * 3)]; - } else if (rand < 0.75) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'uncommon')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.9) { - - fishCaught = FISH_TYPES.filter(f => f.rarity === 'rare')[Math.floor(Math.random() * 2)]; - } else if (rand < 0.98) { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'epic'); - } else { - - fishCaught = FISH_TYPES.find(f => f.rarity === 'legendary'); - } - - const baseEarned = Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1) - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasFishingRod > 0) { - finalEarned = Math.floor(baseEarned * FISHING_ROD_MULTIPLIER); - multiplierMessage = `\nšŸŽ£ **Fishing Rod Bonus: +50%**`; - } - - const catchMessage = CATCH_MESSAGES[Math.floor(Math.random() * CATCH_MESSAGES.length)]; - - userData.wallet += finalEarned; - userData.lastFish = now; - - await setEconomyData(client, guildId, userId, userData); - - const rarityColors = { - common: '#95A5A6', - uncommon: '#2ECC71', - rare: '#3498DB', - epic: '#9B59B6', - legendary: '#F1C40F' - }; - - const embed = createEmbed({ - title: 'Fishing Success!', - description: `${catchMessage}\n\nYou caught a **${fishCaught.emoji} ${fishCaught.name}**! You sold it for **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - color: rarityColors[fishCaught.rarity] - }) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Rarity", - value: fishCaught.rarity.charAt(0).toUpperCase() + fishCaught.rarity.slice(1), - inline: true, - } - ) - .setFooter({ text: `Next fishing trip available in 45 minutes.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'fish' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/gamble.js b/src/commands/Economy/gamble.js deleted file mode 100644 index b4acf1745d..0000000000 --- a/src/commands/Economy/gamble.js +++ /dev/null @@ -1,131 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const BASE_WIN_CHANCE = 0.4; -const CLOVER_WIN_BONUS = 0.1; -const CHARM_WIN_BONUS = 0.08; -const PAYOUT_MULTIPLIER = 2.0; -const GAMBLE_COOLDOWN = 5 * 60 * 1000; - -export default { - data: new SlashCommandBuilder() - .setName('gamble') - .setDescription('Gamble your money for a chance to win more') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount of cash to gamble') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const betAmount = interaction.options.getInteger("amount"); - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastGamble = userData.lastGamble || 0; - let cloverCount = userData.inventory["lucky_clover"] || 0; - let charmCount = userData.inventory["lucky_charm"] || 0; - - if (now < lastGamble + GAMBLE_COOLDOWN) { - const remaining = lastGamble + GAMBLE_COOLDOWN - now; - const minutes = Math.floor(remaining / (1000 * 60)); - const seconds = Math.floor((remaining % (1000 * 60)) / 1000); - - throw createError( - "Gamble cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to cool down before gambling again. Wait **${minutes}m ${seconds}s**.`, - { remaining, cooldownType: 'gamble' } - ); - } - - if (userData.wallet < betAmount) { - throw createError( - "Insufficient cash for gamble", - ErrorTypes.VALIDATION, - `You only have $${userData.wallet.toLocaleString()} cash, but you are trying to bet $${betAmount.toLocaleString()}.`, - { required: betAmount, current: userData.wallet } - ); - } - - let winChance = BASE_WIN_CHANCE; - let cloverMessage = ""; - let usedClover = false; - let usedCharm = false; - - if (cloverCount > 0) { - winChance += CLOVER_WIN_BONUS; - userData.inventory["lucky_clover"] -= 1; - cloverMessage = `\nšŸ€ **Lucky Clover Consumed:** Your win chance was boosted!`; - usedClover = true; - } - - else if (charmCount > 0) { - winChance += CHARM_WIN_BONUS; - userData.inventory["lucky_charm"] -= 1; - cloverMessage = `\nšŸ€ **Lucky Charm Used (${charmCount - 1} uses remaining):** Your win chance was boosted!`; - usedCharm = true; - } - - const win = Math.random() < winChance; - let cashChange = 0; - let resultEmbed; - - if (win) { - const amountWon = Math.floor(betAmount * PAYOUT_MULTIPLIER); - // Net change: the bet is replaced by the payout (bet was at stake, not pre-deducted) - cashChange = amountWon - betAmount; - - resultEmbed = successEmbed( - "šŸŽ‰ You Won!", - `You successfully gambled and turned your **$${betAmount.toLocaleString()}** bet into **$${amountWon.toLocaleString()}**!${cloverMessage}`, - ); - } else { -cashChange = -betAmount; - - resultEmbed = warningEmbed( - "šŸ’” You Lost...", - `The dice rolled against you. You lost your **$${betAmount.toLocaleString()}** bet.`, - ); - } - - userData.wallet = (userData.wallet || 0) + cashChange; -userData.lastGamble = now; - - await setEconomyData(client, guildId, userId, userData); - - const newCash = userData.wallet; - - resultEmbed.addFields({ - name: "New Cash Balance", - value: `$${newCash.toLocaleString()}`, - inline: true, - }); - - if (usedClover) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_clover"]} Lucky Clovers left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else if (usedCharm) { - resultEmbed.setFooter({ - text: `You have ${userData.inventory["lucky_charm"]} Lucky Charm uses left. Win chance was ${Math.round(winChance * 100)}%.`, - }); - } else { - resultEmbed.setFooter({ - text: `Next gamble available in 5 minutes. Base win chance: ${Math.round(BASE_WIN_CHANCE * 100)}%.`, - }); - } - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'gamble' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/inventory.js b/src/commands/Economy/inventory.js deleted file mode 100644 index b7efcfec80..0000000000 --- a/src/commands/Economy/inventory.js +++ /dev/null @@ -1,70 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { shopItems } from '../../config/shop/items.js'; -import { getEconomyData } 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 SHOP_ITEMS = shopItems; - -export default { - data: new SlashCommandBuilder() - .setName('inventory') - .setDescription('View your economy inventory'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Inventory requested for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for inventory", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const inventory = userData.inventory || {}; - - let inventoryDescription = "Your inventory is currently empty."; - - if (Object.keys(inventory).length > 0) { - inventoryDescription = Object.entries(inventory) - .filter( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return quantity > 0 && item; - } - ) - .map( - ([itemId, quantity]) => { - const item = SHOP_ITEMS.find(i => i.id === itemId); - return `**${item.name}:** ${quantity}x`; - } - ) - .join("\n"); - } - - logger.info(`[ECONOMY] Inventory retrieved`, { - userId, - guildId, - itemCount: Object.keys(inventory).length - }); - - const embed = createEmbed({ - title: `šŸŽ’ ${interaction.user.username}'s Inventory`, - description: inventoryDescription, - }).setThumbnail(interaction.user.displayAvatarURL()); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'inventory' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/mine.js b/src/commands/Economy/mine.js deleted file mode 100644 index fda8ba1dda..0000000000 --- a/src/commands/Economy/mine.js +++ /dev/null @@ -1,93 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -const MINE_COOLDOWN = 60 * 60 * 1000; -const BASE_MIN_REWARD = 400; -const BASE_MAX_REWARD = 1200; -const PICKAXE_MULTIPLIER = 1.2; -const DIAMOND_PICKAXE_MULTIPLIER = 2.0; - -const MINE_LOCATIONS = [ - "abandoned gold mine", - "dark, damp cave", - "backyard rock quarry", - "volcanic obsidian vent", - "deep-sea mineral trench", -]; - -export default { - data: new SlashCommandBuilder() - .setName('mine') - .setDescription('Go mining to earn money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - const lastMine = userData.lastMine || 0; - const hasDiamondPickaxe = userData.inventory["diamond_pickaxe"] || 0; - const hasPickaxe = userData.inventory["pickaxe"] || 0; - - if (now < lastMine + MINE_COOLDOWN) { - const remaining = lastMine + MINE_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor( - (remaining % (1000 * 60 * 60)) / (1000 * 60), - ); - - throw createError( - "Mining cooldown active", - ErrorTypes.RATE_LIMIT, - `Your pickaxe is cooling down. Wait for **${hours}h ${minutes}m** before mining again.`, - { remaining, cooldownType: 'mine' } - ); - } - - const baseEarned = - Math.floor( - Math.random() * (BASE_MAX_REWARD - BASE_MIN_REWARD + 1), - ) + BASE_MIN_REWARD; - - let finalEarned = baseEarned; - let multiplierMessage = ""; - - if (hasDiamondPickaxe > 0) { - finalEarned = Math.floor(baseEarned * DIAMOND_PICKAXE_MULTIPLIER); - multiplierMessage = `\nšŸ’Ž **Diamond Pickaxe Bonus: +100%**`; - } else if (hasPickaxe > 0) { - finalEarned = Math.floor(baseEarned * PICKAXE_MULTIPLIER); - multiplierMessage = `\nā›ļø **Pickaxe Bonus: +20%**`; - } - - const location = - MINE_LOCATIONS[ - Math.floor(Math.random() * MINE_LOCATIONS.length) - ]; - - userData.wallet += finalEarned; -userData.lastMine = now; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - "šŸ’° Mining Expedition Successful!", - `You explored a **${location}** and managed to find minerals worth **$${finalEarned.toLocaleString()}**!${multiplierMessage}`, - ) - .addFields({ - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }) - .setFooter({ text: `Next mine available in 1 hour.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'mine' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/economy_dashboard.js b/src/commands/Economy/modules/economy_dashboard.js deleted file mode 100644 index 04f507ff97..0000000000 --- a/src/commands/Economy/modules/economy_dashboard.js +++ /dev/null @@ -1,527 +0,0 @@ -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - UserSelectMenuBuilder, - LabelBuilder, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - EmbedBuilder, -} from 'discord.js'; -import { getColor, BotConfig } from '../../../config/bot.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getEconomyPrefix } from '../../../utils/database.js'; -import { getEconomyData, addMoney, removeMoney, getMaxBankCapacity } from '../../../utils/economy.js'; -import fs from 'fs/promises'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -async function buildDashboardEmbed(guild, client) { - const currencySymbol = BotConfig.economy.currency.symbol; - const currencyName = BotConfig.economy.currency.name; - - let totalInCirculation = 0; - let userCount = 0; - - try { - const economyKeys = await client.db.list(getEconomyPrefix(guild.id)); - - if (economyKeys && economyKeys.length > 0) { - for (const key of economyKeys) { - const userId = key.split(':').pop(); - - const member = await guild.members.fetch(userId).catch(() => null); - if (member?.user?.bot) continue; - - const userData = await client.db.get(key, {}); - if (userData) { - totalInCirculation += (userData.wallet || 0) + (userData.bank || 0); - userCount++; - } - } - } - } catch (error) { - logger.error('Error calculating economy stats:', error); - } - - const avgBalance = userCount > 0 ? Math.floor(totalInCirculation / userCount) : 0; - - return new EmbedBuilder() - .setTitle('šŸ’° Economy Dashboard') - .setDescription(`Manage the economy system for **${guild.name}**.\nSelect an option below to perform an action.`) - .setColor(getColor('economy')) - .addFields( - { name: 'šŸ’° Total in Circulation', value: `\`${currencySymbol}${totalInCirculation.toLocaleString()}\``, inline: true }, - { name: 'šŸ‘„ Active Users', value: `\`${userCount.toLocaleString()}\``, inline: true }, - { name: 'šŸ“Š Average Balance', value: `\`${currencySymbol}${avgBalance.toLocaleString()}\``, inline: true }, - { name: 'šŸ’± Currency Symbol', value: `\`${currencySymbol}\``, inline: true }, - { name: 'šŸ“ Currency Name', value: `\`${currencyName}\``, inline: true }, - ) - .setFooter({ text: 'Dashboard closes after 10 minutes of inactivity' }) - .setTimestamp(); -} - -function buildSelectMenu(guildId) { - return new StringSelectMenuBuilder() - .setCustomId(`economy_dashboard_${guildId}`) - .setPlaceholder('Select an action...') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Add Currency') - .setDescription('Add currency to a user\'s wallet or bank') - .setValue('add_currency') - .setEmoji('šŸ’°'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove Currency') - .setDescription('Remove currency from a user\'s wallet or bank') - .setValue('remove_currency') - .setEmoji('šŸ’ø'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Symbol') - .setDescription('Change the currency symbol (e.g., $, €, Ā£)') - .setValue('change_currency') - .setEmoji('šŸ’±'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Currency Name') - .setDescription('Change the currency name (e.g., coins, credits)') - .setValue('change_name') - .setEmoji('šŸ“'), - ); -} - -async function refreshDashboard(rootInteraction, guild, client) { - const selectMenu = buildSelectMenu(guild.id); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [ - new ActionRowBuilder().addComponents(selectMenu), - ], - }).catch(() => {}); -} - -async function updateConfigFile(currencySymbol, currencyName) { - try { - const configPath = path.join(__dirname, '../../../config/bot.js'); - let configContent = await fs.readFile(configPath, 'utf-8'); - - configContent = configContent.replace( - /symbol:\s*"[^"]*"/, - `symbol: "${currencySymbol}"` - ); - - configContent = configContent.replace( - /name:\s*"[^"]*",\s*\/\/\s*Currency display name/, - `name: "${currencyName}", // Currency display name` - ); - - configContent = configContent.replace( - /namePlural:\s*"[^"]*",\s*\/\/\s*Plural display name/, - `namePlural: "${currencyName}s", // Plural display name` - ); - - await fs.writeFile(configPath, configContent, 'utf-8'); - logger.info('Config file updated successfully'); - return true; - } catch (error) { - logger.error('Error updating config file:', error); - return false; - } -} - -export default { - prefixOnly: false, - async execute(interaction, config, client) { - try { - const guild = interaction.guild; - const selectMenu = buildSelectMenu(guild.id); - const selectRow = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [await buildDashboardEmbed(guild, client)], - components: [selectRow], - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: i => - i.user.id === interaction.user.id && i.customId === `economy_dashboard_${guild.id}`, - time: 600_000, - }); - - collector.on('collect', async selectInteraction => { - const selectedOption = selectInteraction.values[0]; - try { - switch (selectedOption) { - case 'add_currency': - await handleAddCurrency(selectInteraction, interaction, guild, client); - break; - case 'remove_currency': - await handleRemoveCurrency(selectInteraction, interaction, guild, client); - break; - case 'change_currency': - await handleChangeCurrency(selectInteraction, interaction, guild); - break; - case 'change_name': - await handleChangeName(selectInteraction, interaction, guild); - break; - } - } catch (error) { - if (error instanceof TitanBotError) { - logger.debug(`Economy dashboard validation error: ${error.message}`); - } else { - logger.error('Unexpected economy dashboard error:', error); - } - - const errorMessage = - error instanceof TitanBotError - ? error.userMessage || 'An error occurred while processing your selection.' - : 'An unexpected error occurred while processing your request.'; - - if (!selectInteraction.replied && !selectInteraction.deferred) { - await selectInteraction.deferUpdate().catch(() => {}); - } - - await replyUserError(selectInteraction, { - type: ErrorTypes.UNKNOWN, - message: errorMessage, - }).catch(() => {}); - } - }); - - collector.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 economy_dashboard:', error); - throw new TitanBotError( - `Economy dashboard failed: ${error.message}`, - ErrorTypes.UNKNOWN, - 'Failed to open the economy dashboard.', - ); - } - }, -}; - -async function handleAddCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_add_currency_${guild.id}`) - .setTitle('Add Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to add currency to') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to add') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_add_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const { newBalance } = await addMoney(client, guild.id, userId, amount, type); - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Added', `Successfully added ${currencySymbol}${amount.toLocaleString()} to ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency added`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance, - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleRemoveCurrency(selectInteraction, rootInteraction, guild, client) { - const modal = new ModalBuilder() - .setCustomId(`economy_remove_currency_${guild.id}`) - .setTitle('Remove Currency'); - - const userSelect = new UserSelectMenuBuilder() - .setCustomId('target_user') - .setPlaceholder('Select a user...') - .setMinValues(1) - .setMaxValues(1) - .setRequired(true); - - const userLabel = new LabelBuilder() - .setLabel('Target User') - .setDescription('User to remove currency from') - .setUserSelectMenuComponent(userSelect); - - const amountInput = new TextInputBuilder() - .setCustomId('amount') - .setLabel('Amount to remove') - .setStyle(TextInputStyle.Short) - .setPlaceholder('100') - .setMinLength(1) - .setMaxLength(10) - .setRequired(true); - - const typeInput = new TextInputBuilder() - .setCustomId('type') - .setLabel('Type (wallet or bank)') - .setStyle(TextInputStyle.Short) - .setPlaceholder('wallet') - .setMinLength(1) - .setMaxLength(5) - .setRequired(true); - - modal.addLabelComponents(userLabel); - modal.addComponents( - new ActionRowBuilder().addComponents(amountInput), - new ActionRowBuilder().addComponents(typeInput), - ); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_remove_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const userId = submitted.fields.getField('target_user').values[0]; - const amount = parseInt(submitted.fields.getTextInputValue('amount').trim(), 10); - const type = submitted.fields.getTextInputValue('type').trim().toLowerCase(); - - if (isNaN(amount) || amount <= 0) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Amount must be a positive number.' }); - return; - } - - if (type !== 'wallet' && type !== 'bank') { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Type must be either "wallet" or "bank".' }); - return; - } - - const member = await guild.members.fetch(userId).catch(() => null); - if (!member) { - await replyUserError(submitted, { type: ErrorTypes.USER_INPUT, message: 'The specified user is not in this server.' }); - return; - } - - if (member.user.bot) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Bots do not have economy accounts.' }); - return; - } - - const { newBalance } = await removeMoney(client, guild.id, userId, amount, type); - - const currencySymbol = BotConfig.economy.currency.symbol; - - await submitted.reply({ - embeds: [successEmbed('Currency Removed', `Successfully removed ${currencySymbol}${amount.toLocaleString()} from ${member.user.tag}'s ${type}.\n**New Balance:** ${currencySymbol}${newBalance.toLocaleString()}`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency removed`, { - adminId: submitted.user.id, - targetUserId: userId, - amount, - type, - newBalance, - }); - - await refreshDashboard(rootInteraction, guild, client); -} - -async function handleChangeCurrency(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_currency_${guild.id}`) - .setTitle('Change Currency Symbol'); - - const symbolInput = new TextInputBuilder() - .setCustomId('currency_symbol') - .setLabel('New Currency Symbol') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.symbol) - .setPlaceholder('$') - .setMinLength(1) - .setMaxLength(3) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(symbolInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_currency_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newSymbol = submitted.fields.getTextInputValue('currency_symbol').trim(); - - if (newSymbol.length === 0 || newSymbol.length > 3) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency symbol must be 1-3 characters long.' }); - return; - } - - const success = await updateConfigFile(newSymbol, BotConfig.economy.currency.name); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Symbol Updated', `Currency symbol changed to **${newSymbol}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency symbol changed`, { - adminId: submitted.user.id, - oldSymbol: BotConfig.economy.currency.symbol, - newSymbol - }); -} - -async function handleChangeName(selectInteraction, rootInteraction, guild) { - const modal = new ModalBuilder() - .setCustomId(`economy_change_name_${guild.id}`) - .setTitle('Change Currency Name'); - - const nameInput = new TextInputBuilder() - .setCustomId('currency_name') - .setLabel('New Currency Name') - .setStyle(TextInputStyle.Short) - .setValue(BotConfig.economy.currency.name) - .setPlaceholder('coins') - .setMinLength(1) - .setMaxLength(20) - .setRequired(true); - - modal.addComponents(new ActionRowBuilder().addComponents(nameInput)); - - await selectInteraction.showModal(modal); - - const submitted = await selectInteraction - .awaitModalSubmit({ - filter: i => i.customId === `economy_change_name_${guild.id}` && i.user.id === selectInteraction.user.id, - time: 120_000, - }) - .catch(() => null); - - if (!submitted) return; - - const newName = submitted.fields.getTextInputValue('currency_name').trim(); - - if (newName.length === 0 || newName.length > 20) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'Currency name must be 1-20 characters long.' }); - return; - } - - const success = await updateConfigFile(BotConfig.economy.currency.symbol, newName); - - if (!success) { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'Could not update the config file. Please check the logs.' }); - return; - } - - await submitted.reply({ - embeds: [successEmbed('Currency Name Updated', `Currency name changed to **${newName}**.\n\n**Note:** The bot needs to be restarted for changes to take effect.`)], - flags: MessageFlags.Ephemeral, - }); - - logger.info(`[ECONOMY_DASHBOARD] Currency name changed`, { - adminId: submitted.user.id, - oldName: BotConfig.economy.currency.name, - newName - }); -} \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_browse.js b/src/commands/Economy/modules/shop_browse.js deleted file mode 100644 index b7adcf40dd..0000000000 --- a/src/commands/Economy/modules/shop_browse.js +++ /dev/null @@ -1,94 +0,0 @@ -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'; -import { handleInteractionError } from '../../../utils/errorHandler.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` 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 (error) { - logger.debug('shop_browse: could not disable components on collector end', { - error: error.message, - }); - } - }); - } catch (error) { - await handleInteractionError(interaction, error, { command: 'shop_browse' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/modules/shop_config_setrole.js b/src/commands/Economy/modules/shop_config_setrole.js deleted file mode 100644 index e917e9e69a..0000000000 --- a/src/commands/Economy/modules/shop_config_setrole.js +++ /dev/null @@ -1,31 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: '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); - - 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 await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the guild configuration.' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Economy/pay.js b/src/commands/Economy/pay.js deleted file mode 100644 index 5e8e904862..0000000000 --- a/src/commands/Economy/pay.js +++ /dev/null @@ -1,149 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, addMoney, removeMoney, 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'; -import EconomyService from '../../services/economyService.js'; - -export default { - data: new SlashCommandBuilder() - .setName('pay') - .setDescription('Pay another user some of your cash') - .addUserOption(option => - option - .setName('user') - .setDescription('User to pay') - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to pay') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const senderId = interaction.user.id; - const receiver = interaction.options.getUser("user"); - const amount = interaction.options.getInteger("amount"); - const guildId = interaction.guildId; - - logger.debug(`[ECONOMY] Pay command initiated`, { - senderId, - receiverId: receiver.id, - amount, - guildId - }); - - if (receiver.bot) { - throw createError( - "Cannot pay bot", - ErrorTypes.VALIDATION, - "You cannot pay a bot.", - { receiverId: receiver.id, isBot: true } - ); - } - - if (receiver.id === senderId) { - throw createError( - "Cannot pay self", - ErrorTypes.VALIDATION, - "You cannot pay yourself.", - { senderId, receiverId: receiver.id } - ); - } - - if (amount <= 0) { - throw createError( - "Invalid payment amount", - ErrorTypes.VALIDATION, - "Amount must be greater than zero.", - { amount, senderId } - ); - } - - const [senderData, receiverData] = await Promise.all([ - getEconomyData(client, guildId, senderId), - getEconomyData(client, guildId, receiver.id) - ]); - - if (!senderData) { - throw createError( - "Failed to load sender economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId: senderId, guildId } - ); - } - - if (!receiverData) { - throw createError( - "Failed to load receiver economy data", - ErrorTypes.DATABASE, - "Failed to load the receiver's economy data. Please try again later.", - { userId: receiver.id, guildId } - ); - } - - const result = await EconomyService.transferMoney( - client, - guildId, - senderId, - receiver.id, - amount - ); - - const updatedSenderData = await getEconomyData(client, guildId, senderId); - const updatedReceiverData = await getEconomyData(client, guildId, receiver.id); - - const embed = successEmbed( - 'Payment Successful', - `You successfully paid **${receiver.username}** the amount of **$${amount.toLocaleString()}**!` - ) - .addFields( - { - name: "Payment Amount", - value: `$${amount.toLocaleString()}`, - inline: true, - }, - { - name: "Your New Balance", - value: `$${updatedSenderData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ - text: `Paid to ${receiver.tag}`, - iconURL: receiver.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info(`[ECONOMY] Payment sent successfully`, { - senderId, - receiverId: receiver.id, - amount, - senderBalance: updatedSenderData.wallet, - receiverBalance: updatedReceiverData.wallet - }); - - try { - const receiverEmbed = createEmbed({ - title: "Incoming Payment!", - description: `${interaction.user.username} paid you **$${amount.toLocaleString()}**.` - }).addFields({ - name: "Your New Cash", - value: `$${updatedReceiverData.wallet.toLocaleString()}`, - inline: true, - }); - await receiver.send({ embeds: [receiverEmbed] }); - } catch (e) { - logger.warn(`Could not DM user ${receiver.id}: ${e.message}`); - } - }, { command: 'pay' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/rob.js b/src/commands/Economy/rob.js deleted file mode 100644 index 68f74fbfee..0000000000 --- a/src/commands/Economy/rob.js +++ /dev/null @@ -1,154 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed, buildUserErrorEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { BotConfig } from '../../config/bot.js'; - -const ROB_COOLDOWN = BotConfig.economy?.cooldowns?.rob ?? 4 * 60 * 60 * 1000; -const BASE_ROB_SUCCESS_CHANCE = BotConfig.economy?.robSuccessRate ?? 0.4; -const ROB_PERCENTAGE = 0.15; -const FINE_PERCENTAGE = 0.1; - -export default { - data: new SlashCommandBuilder() - .setName('rob') - .setDescription('Attempt to rob another user (very risky)') - .addUserOption(option => - option - .setName('user') - .setDescription('User to rob') - .setRequired(true) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const robberId = interaction.user.id; - const victimUser = interaction.options.getUser("user"); - const guildId = interaction.guildId; - const now = Date.now(); - - if (robberId === victimUser.id) { - throw createError( - "Cannot rob self", - ErrorTypes.VALIDATION, - "You cannot rob yourself.", - { robberId, victimId: victimUser.id } - ); - } - - if (victimUser.bot) { - throw createError( - "Cannot rob bot", - ErrorTypes.VALIDATION, - "You cannot rob a bot.", - { victimId: victimUser.id, isBot: true } - ); - } - - const robberData = await getEconomyData(client, guildId, robberId); - const victimData = await getEconomyData(client, guildId, victimUser.id); - - if (!robberData || !victimData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load economy data. Please try again later.", - { robberId: !!robberData, victimId: !!victimData, guildId } - ); - } - - const lastRob = robberData.lastRob || 0; - - if (now < lastRob + ROB_COOLDOWN) { - const remaining = lastRob + ROB_COOLDOWN - now; - const hours = Math.floor(remaining / (1000 * 60 * 60)); - const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); - - throw createError( - "Robbery cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to lay low. Wait **${hours}h ${minutes}m** before attempting another robbery.`, - { remaining, hours, minutes, cooldownType: 'rob' } - ); - } - - if (victimData.wallet < 500) { - throw createError( - "Victim too poor", - ErrorTypes.VALIDATION, - `${victimUser.username} is too poor. They need at least $500 cash to be worth robbing.`, - { victimWallet: victimData.wallet, required: 500 } - ); - } - - const hasSafe = victimData.inventory["personal_safe"] || 0; - - if (hasSafe > 0) { - robberData.lastRob = now; - await setEconomyData(client, guildId, robberId, robberData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - warningEmbed( - 'Robbery Blocked', - `${victimUser.username} was prepared! Your attempt failed because they own a **Personal Safe**. You got away clean but didn't gain anything.` - ) - ], - }); - } - - const isSuccessful = Math.random() < BASE_ROB_SUCCESS_CHANCE; - let resultEmbed; - - if (isSuccessful) { - const amountStolen = Math.floor(victimData.wallet * ROB_PERCENTAGE); - - robberData.wallet = (robberData.wallet || 0) + amountStolen; - victimData.wallet = (victimData.wallet || 0) - amountStolen; - - resultEmbed = successEmbed( - 'Robbery Successful', - `You successfully stole **$${amountStolen.toLocaleString()}** from ${victimUser.username}!` - ); - } else { - const fineAmount = Math.floor((robberData.wallet || 0) * FINE_PERCENTAGE); - - if ((robberData.wallet || 0) < fineAmount) { - robberData.wallet = 0; - } else { - robberData.wallet = (robberData.wallet || 0) - fineAmount; - } - - resultEmbed = buildUserErrorEmbed( - 'unknown', - `You failed the robbery and were caught! You were fined **$${fineAmount.toLocaleString()}** of your own cash.`, - { titleOverride: 'Robbery Failed' } - ); - } - - robberData.lastRob = now; - - await setEconomyData(client, guildId, robberId, robberData); - await setEconomyData(client, guildId, victimUser.id, victimData); - - resultEmbed - .addFields( - { - name: `Your New Cash (${interaction.user.username})`, - value: `$${robberData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: `Victim's New Cash (${victimUser.username})`, - value: `$${victimData.wallet.toLocaleString()}`, - inline: true, - }, - ) - .setFooter({ text: `Next robbery available in ${Math.ceil(ROB_COOLDOWN / (60 * 60 * 1000))} hours.` }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [resultEmbed] }); - }, { command: 'rob' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/shop-config.js b/src/commands/Economy/shop-config.js deleted file mode 100644 index fb3be41dc3..0000000000 --- a/src/commands/Economy/shop-config.js +++ /dev/null @@ -1,28 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopConfigSetrole from './modules/shop_config_setrole.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop-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) { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'setrole') { - return shopConfigSetrole.execute(interaction, config, client); - } - }, -}; diff --git a/src/commands/Economy/shop.js b/src/commands/Economy/shop.js deleted file mode 100644 index 16b2b62d96..0000000000 --- a/src/commands/Economy/shop.js +++ /dev/null @@ -1,13 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import shopBrowse from './modules/shop_browse.js'; - -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('shop') - .setDescription('Browse the economy shop.'), - - async execute(interaction, config, client) { - return shopBrowse.execute(interaction, config, client); - }, -}; diff --git a/src/commands/Economy/slut.js b/src/commands/Economy/slut.js deleted file mode 100644 index 98b83e369e..0000000000 --- a/src/commands/Economy/slut.js +++ /dev/null @@ -1,188 +0,0 @@ -import { SlashCommandBuilder } from 'discord.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 SLUT_ACTIVITIES = [ - { 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 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 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('Take a risky provocative job for random payout or loss'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - logger.debug(`[ECONOMY] Slut command started for ${userId}`, { userId, guildId }); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for slut command", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - const lastSlut = userData.lastSlut || 0; - - if (now - lastSlut < SLUT_COOLDOWN) { - const remainingTime = lastSlut + SLUT_COOLDOWN - now; - throw createError( - "Slut cooldown active", - ErrorTypes.RATE_LIMIT, - `You need to wait before you can work again! Try again in **${Math.ceil(remainingTime / 60000)}** minutes.`, - { timeRemaining: remainingTime, cooldownType: 'slut' } - ); - } - - const activity = randomChoice(SLUT_ACTIVITIES); - - const outcome = resolveOutcome(activity, userData.wallet || 0); - - 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); - - 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' }) -}; \ No newline at end of file diff --git a/src/commands/Economy/withdraw.js b/src/commands/Economy/withdraw.js deleted file mode 100644 index bf45af02ad..0000000000 --- a/src/commands/Economy/withdraw.js +++ /dev/null @@ -1,85 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getEconomyData, setEconomyData, getMaxBankCapacity } from '../../utils/economy.js'; -import { withErrorHandling, createError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('withdraw') - .setDescription('Withdraw money from your bank to your wallet') - .addIntegerOption(option => - option - .setName('amount') - .setDescription('Amount to withdraw') - .setRequired(true) - .setMinValue(1) - ), - - execute: withErrorHandling(async (interaction, config, client) => { - await InteractionHelper.safeDefer(interaction); - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const amountInput = interaction.options.getInteger("amount"); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - let withdrawAmount = amountInput; - - if (withdrawAmount <= 0) { - throw createError( - "Invalid withdrawal amount", - ErrorTypes.VALIDATION, - "You must withdraw a positive amount.", - { amount: withdrawAmount, userId } - ); - } - - if (withdrawAmount > userData.bank) { - withdrawAmount = userData.bank; - } - - if (withdrawAmount === 0) { - throw createError( - "Empty bank account", - ErrorTypes.VALIDATION, - "Your bank account is empty.", - { userId, bankBalance: userData.bank } - ); - } - - userData.wallet += withdrawAmount; - userData.bank -= withdrawAmount; - - await setEconomyData(client, guildId, userId, userData); - - const embed = successEmbed( - 'Withdrawal Successful', - `You successfully withdrew **$${withdrawAmount.toLocaleString()}** from your bank.` - ) - .addFields( - { - name: "New Cash Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "New Bank Balance", - value: `$${userData.bank.toLocaleString()}`, - inline: true, - }, - ); - - 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 deleted file mode 100644 index 775be897ad..0000000000 --- a/src/commands/Economy/work.js +++ /dev/null @@ -1,123 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } 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'; -import { botConfig } from '../../config/bot.js'; - -const WORK_COOLDOWN = botConfig.economy?.cooldowns?.work ?? 30 * 60 * 1000; -const MIN_WORK_AMOUNT = botConfig.economy?.workMin ?? 10; -const MAX_WORK_AMOUNT = botConfig.economy?.workMax ?? 100; -const LAPTOP_MULTIPLIER = 1.5; -const WORK_JOBS = [ - "Software Developer", - "Barista", - "Janitor", - "YouTuber", - "Discord Bot Developer", - "Cashier", - "Pizza Delivery Driver", - "Librarian", - "Gardener", - "Data Analyst", -]; - -export default { - data: new SlashCommandBuilder() - .setName('work') - .setDescription('Work to earn some money'), - - execute: withErrorHandling(async (interaction, config, client) => { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) return; - - const userId = interaction.user.id; - const guildId = interaction.guildId; - const now = Date.now(); - - const userData = await getEconomyData(client, guildId, userId); - - if (!userData) { - throw createError( - "Failed to load economy data for work", - ErrorTypes.DATABASE, - "Failed to load your economy data. Please try again later.", - { userId, guildId } - ); - } - - logger.debug(`[ECONOMY] Work command started for ${userId}`, { userId, guildId }); - - const lastWork = userData.lastWork || 0; - const inventory = userData.inventory || {}; - const extraWorkShifts = inventory["extra_work"] || 0; - const hasLaptop = inventory["laptop"] || 0; - - let cooldownActive = now < lastWork + WORK_COOLDOWN; - let usedConsumable = false; - - if (cooldownActive) { - if (extraWorkShifts > 0) { - inventory["extra_work"] = (inventory["extra_work"] || 0) - 1; - usedConsumable = true; - } else { - const remaining = lastWork + WORK_COOLDOWN - now; - throw createError( - "Work cooldown active", - ErrorTypes.RATE_LIMIT, - `You're working too fast! Wait **${Math.floor(remaining / 3600000)}h ${Math.floor((remaining % 3600000) / 60000)}m** before working again.`, - { timeRemaining: remaining, cooldownType: 'work' } - ); - } - } - - let earned = Math.floor(Math.random() * (MAX_WORK_AMOUNT - MIN_WORK_AMOUNT + 1)) + MIN_WORK_AMOUNT; - const job = WORK_JOBS[Math.floor(Math.random() * WORK_JOBS.length)]; - - let multiplierMessage = ""; - if (hasLaptop > 0) { - earned = Math.floor(earned * LAPTOP_MULTIPLIER); - multiplierMessage = "\nšŸ’» **Laptop Bonus:** +50% earnings!"; - } - - userData.wallet = (userData.wallet || 0) + earned; - userData.lastWork = now; - - await setEconomyData(client, guildId, userId, userData); - - logger.info(`[ECONOMY_TRANSACTION] Work completed`, { - userId, - guildId, - amount: earned, - job, - usedConsumable, - hasLaptop: hasLaptop > 0, - newWallet: userData.wallet, - timestamp: new Date().toISOString() - }); - - const embed = successEmbed( - "šŸ’¼ Work Complete!", - `You worked as a **${job}** and earned **$${earned.toLocaleString()}**!${multiplierMessage}` - ) - .addFields( - { - name: "New Balance", - value: `$${userData.wallet.toLocaleString()}`, - inline: true, - }, - { - name: "Next Work", - value: ``, - inline: true, - } - ) - .setFooter({ - text: `Requested by ${interaction.user.tag}`, - iconURL: interaction.user.displayAvatarURL(), - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - }, { command: 'work' }) -}; \ No newline at end of file diff --git a/src/commands/Fun/count.js b/src/commands/Fun/count.js deleted file mode 100644 index 1dbd6014b1..0000000000 --- a/src/commands/Fun/count.js +++ /dev/null @@ -1,176 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - getCountingGameConfig, - activateCountingGame, - disableCountingGame, - resetCountingGame, - buildCountingLeaderboard, - getCountingSystemChoices, - getCountingSystemLabel, - getExpectedCountValue, -} from '../../services/countingGameService.js'; -import { logger } from '../../utils/logger.js'; - -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName('count') - .setDescription('Manage the server counting game') - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName('setup') - .setDescription('Start a counting game in a text channel') - .addChannelOption((option) => - option - .setName('channel') - .setDescription('The channel where counting will take place') - .setRequired(true) - .addChannelTypes(ChannelType.GuildText), - ) - .addStringOption((option) => - option - .setName('system') - .setDescription('The counting system to use') - .setRequired(true) - .addChoices(...getCountingSystemChoices()), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName('disable').setDescription('Disable the counting game for this server'), - ) - .addSubcommand((subcommand) => - subcommand.setName('status').setDescription('View current counting game status'), - ) - .addSubcommand((subcommand) => - subcommand - .setName('reset') - .setDescription('Reset the current counting sequence') - .addIntegerOption((option) => - option - .setName('start') - .setDescription('The number to start at after reset') - .setMinValue(1), - ), - ) - .addSubcommand((subcommand) => - subcommand.setName('leaderboard').setDescription('Show the counting game leaderboard'), - ), - category: 'Fun', - - async execute(interaction) { - try { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferSuccess) { - logger.warn('Count command defer failed', { userId: interaction.user.id, guildId: interaction.guildId }); - return; - } - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the **Manage Server** permission to use this command.' }); - } - - const guildId = interaction.guildId; - const subcommand = interaction.options.getSubcommand(); - const config = await getCountingGameConfig(interaction.client, guildId); - - if (subcommand === 'setup') { - const channel = interaction.options.getChannel('channel'); - const system = interaction.options.getString('system'); - if (!channel || channel.type !== ChannelType.GuildText) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a text channel for the counting game.' }); - } - - if (config.enabled && config.channelId && config.channelId !== channel.id) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `This server already has an active counting channel configured: <#${config.channelId}>. Disable the current counting game first, or use that existing channel.` }); - } - - await activateCountingGame(interaction.client, guildId, channel.id, system); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Counting Game Enabled', - `The counting game is now active in ${channel} using the **${getCountingSystemLabel(system)}** system. Players must count up from **1** and may not post two numbers in a row.`, - ), - ], - }); - } - - if (subcommand === 'disable') { - if (!config.enabled) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [infoEmbed('Counting Game Disabled', 'The counting game is already disabled for this server.')], - }); - } - - await disableCountingGame(interaction.client, guildId); - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Counting Game Disabled', 'The counting game has been disabled.')], - }); - } - - if (subcommand === 'status') { - const fields = [ - { name: 'Enabled', value: config.enabled ? 'Yes' : 'No', inline: true }, - { name: 'Channel', value: config.channelId ? `<#${config.channelId}>` : 'Not configured', inline: true }, - { name: 'System', value: getCountingSystemLabel(config.system), inline: true }, - { name: 'Next count', value: getExpectedCountValue(config), inline: true }, - { name: 'Current streak', value: `${config.currentStreak}`, inline: true }, - { name: 'Best streak', value: `${config.bestStreak || 0}`, inline: true }, - { name: 'Last counter', value: config.lastUserId ? `<@${config.lastUserId}>` : 'None', inline: true }, - ]; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'Counting Game Status', - description: 'Overview of the currently configured counting game.', - fields, - color: 'primary', - }), - ], - }); - } - - if (subcommand === 'reset') { - if (!config.enabled) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Enable the counting game first with `/count setup`.' }); - } - - const startNumber = interaction.options.getInteger('start') || 1; - await resetCountingGame(interaction.client, guildId, startNumber); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Counting Game Reset', - `The counting sequence has been reset. Start again with **${startNumber}** in <#${config.channelId}>.`, - ), - ], - }); - } - - if (subcommand === 'leaderboard') { - const leaderboard = buildCountingLeaderboard(config, interaction.guild); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: 'Counting Game Leaderboard', - description: leaderboard.length > 0 ? leaderboard.join('\n') : 'No counts have been recorded yet.', - color: 'primary', - }), - ], - }); - } - - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please choose a valid counting game action.' }); - } catch (error) { - logger.error('Count command error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Something went wrong while managing the counting game.' }); - } - }, -}; diff --git a/src/commands/Fun/fight.js b/src/commands/Fun/fight.js deleted file mode 100644 index 3093af2419..0000000000 --- a/src/commands/Fun/fight.js +++ /dev/null @@ -1,83 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.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() - .setName("fight") - .setDescription("Starts a simulated 1v1 text-based battle.") - .addUserOption((option) => - option - .setName("opponent") - .setDescription("The user to fight.") - .setRequired(true), - ), - category: 'Fun', - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const challenger = interaction.user; - const opponent = interaction.options.getUser("opponent"); - - 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.` - ); - 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." - ); - return await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - - const winner = rand(0, 1) === 0 ? challenger : opponent; - const loser = winner.id === challenger.id ? opponent : challenger; - const rounds = rand(3, 7); - const damage = rand(10, 50); - - const log = []; - log.push( - `šŸ’„ **${challenger.username}** challenges **${opponent.username}** to a duel! (Best of ${rounds} rounds)`, - ); - - for (let i = 1; i <= rounds; i++) { - const attacker = rand(0, 1) === 0 ? challenger : opponent; - const target = attacker.id === challenger.id ? opponent : challenger; - const action = [ - "throws a wild punch", - "lands a critical hit", - "uses a weak spell", - "parries and counterattacks", - ][rand(0, 3)]; - log.push( - `\n**Round ${i}:** ${attacker.username} ${action} on ${target.username} for ${rand(1, damage)} damage!`, - ); - } - - 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!", - description - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Fight command executed between ${challenger.id} and ${opponent.id} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/flip.js b/src/commands/Fun/flip.js deleted file mode 100644 index 2eeef5cfa9..0000000000 --- a/src/commands/Fun/flip.js +++ /dev/null @@ -1,25 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("flip") - .setDescription("Flips a coin (Heads or Tails)."), - category: 'Fun', - - async execute(interaction, config, client) { - const result = Math.random() < 0.5 ? "Heads" : "Tails"; - const emoji = result === "Heads" ? "šŸŖ™" : "šŸ”®"; - - const embed = successEmbed( - "Heads or Tails?", - `The coin landed on... **${result}** ${emoji}!`, - ); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.debug(`Flip command executed by user ${interaction.user.id} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file diff --git a/src/commands/Fun/roll.js b/src/commands/Fun/roll.js deleted file mode 100644 index 988fe1b663..0000000000 --- a/src/commands/Fun/roll.js +++ /dev/null @@ -1,81 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("roll") - .setDescription("Rolls dice using standard notation (e.g., 2d20, 1d6 + 5).") - .addStringOption((option) => - option - .setName("notation") - .setDescription("The dice notation (e.g., 2d6, 1d20 + 4)") - .setRequired(true) - .setMaxLength(50), - ), - category: 'Fun', - - async execute(interaction, config, client) { - await InteractionHelper.safeDefer(interaction); - - const notation = interaction.options - .getString("notation") - .toLowerCase() - .replace(/\s/g, ""); - - const match = notation.match(/^(\d*)d(\d+)([\+\-]\d+)?$/); - - if (!match) { - throw new TitanBotError( - `Invalid dice notation: ${notation}`, - ErrorTypes.USER_INPUT, - 'Invalid notation. Use format like `1d20` or `3d6+5`.' - ); - } - - const numDice = parseInt(match[1] || "1", 10); - const numSides = parseInt(match[2], 10); - const modifier = parseInt(match[3] || "0", 10); - - if (numDice < 1 || numDice > 20) { - throw new TitanBotError( - `Too many dice requested: ${numDice}`, - ErrorTypes.VALIDATION, - 'Please keep the number of dice between 1 and 20.' - ); - } - - if (numSides < 1 || numSides > 1000) { - throw new TitanBotError( - `Invalid number of sides: ${numSides}`, - ErrorTypes.VALIDATION, - 'Please keep the number of sides between 1 and 1000.' - ); - } - - let rolls = []; - let totalRoll = 0; - - for (let i = 0; i < numDice; i++) { - const roll = Math.floor(Math.random() * numSides) + 1; - rolls.push(roll); - totalRoll += roll; - } - - const finalTotal = totalRoll + modifier; - - const resultsDetail = - numDice > 1 ? `**Rolls:** ${rolls.join(" + ")}\n` : ""; - const modifierText = modifier !== 0 ? `+ (${modifier})` : ""; - - const embed = successEmbed( - `šŸŽ² Rolling ${numDice}d${numSides}${modifier !== 0 ? match[3] : ""}`, - `${resultsDetail}**Total Roll:** ${totalRoll}${modifierText} = **${finalTotal}**`, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.debug(`Roll command executed by user ${interaction.user.id} with notation ${notation} in guild ${interaction.guildId}`); - }, -}; \ No newline at end of file diff --git a/src/commands/Giveaway/gcreate.js b/src/commands/Giveaway/gcreate.js deleted file mode 100644 index e5f19c048c..0000000000 --- a/src/commands/Giveaway/gcreate.js +++ /dev/null @@ -1,184 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { saveGiveaway } from '../../utils/giveaways.js'; -import { - parseDuration, - validatePrize, - validateWinnerCount, - createGiveawayEmbed, - createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -import { botConfig } from '../../config/bot.js'; - -const GIVEAWAY_MIN_WINNERS = botConfig.giveaways?.minimumWinners ?? 1; -const GIVEAWAY_MAX_WINNERS = botConfig.giveaways?.maximumWinners ?? 10; - -export default { - data: new SlashCommandBuilder() - .setName("gcreate") - .setDescription("Starts a new giveaway in a specified channel.") - .addStringOption((option) => - option - .setName("duration") - .setDescription( - "How long the giveaway should last (e.g., 1h, 30m, 5d).", - ) - .setRequired(true), - ) - .addIntegerOption((option) => - option - .setName("winners") - .setDescription("The number of winners to pick.") - .setMinValue(GIVEAWAY_MIN_WINNERS) - .setMaxValue(GIVEAWAY_MAX_WINNERS) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("prize") - .setDescription("The prize being given away.") - .setRequired(true), - ) - .addChannelOption((option) => - option - .setName("channel") - .setDescription("The channel to send the giveaway to (defaults to current channel).") - .addChannelTypes(ChannelType.GuildText) - .setRequired(false), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - // Defer up front: sending the giveaway message + DB write can exceed the 3s window - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to start a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway creation started by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const durationString = interaction.options.getString("duration"); - const winnerCount = interaction.options.getInteger("winners"); - const prize = interaction.options.getString("prize"); - const targetChannel = interaction.options.getChannel("channel") || interaction.channel; - - const durationMs = parseDuration(durationString); - validateWinnerCount(winnerCount); - const prizeName = validatePrize(prize); - - if (!targetChannel.isTextBased()) { - throw new TitanBotError( - 'Target channel is not text-based', - ErrorTypes.VALIDATION, - 'The channel must be a text channel.', - { channelId: targetChannel.id, channelType: targetChannel.type } - ); - } - - const endTime = Date.now() + durationMs; - - const initialGiveawayData = { - messageId: "placeholder", - channelId: targetChannel.id, - guildId: interaction.guildId, - prize: prizeName, - hostId: interaction.user.id, - endTime: endTime, - endsAt: endTime, - winnerCount: winnerCount, - participants: [], - isEnded: false, - ended: false, - createdAt: new Date().toISOString() - }; - - const embed = createGiveawayEmbed(initialGiveawayData, "active"); - const row = createGiveawayButtons(false); - - const giveawayMessage = await targetChannel.send({ - content: "šŸŽ‰ **NEW GIVEAWAY** šŸŽ‰", - embeds: [embed], - components: [row], - }); - - initialGiveawayData.messageId = giveawayMessage.id; - const saved = await saveGiveaway( - interaction.client, - interaction.guildId, - initialGiveawayData, - ); - - if (!saved) { - logger.warn(`Failed to save giveaway to database: ${giveawayMessage.id}`); - } - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_CREATE, - data: { - description: `Giveaway created: ${prizeName}`, - channelId: targetChannel.id, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: prizeName, - inline: true - }, - { - name: 'Winners', - value: winnerCount.toString(), - inline: true - }, - { - name: 'Duration', - value: durationString, - inline: true - }, - { - name: 'Channel', - value: targetChannel.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway creation event:', logError); - } - - logger.info(`Giveaway created successfully: ${giveawayMessage.id} in ${targetChannel.name}`); - - await InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `Giveaway Started! šŸŽ‰`, - `A new giveaway for **${prizeName}** has been started in ${targetChannel} and will end in **${durationString}**.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Giveaway/gdelete.js b/src/commands/Giveaway/gdelete.js deleted file mode 100644 index 838209c0c0..0000000000 --- a/src/commands/Giveaway/gdelete.js +++ /dev/null @@ -1,189 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -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.", - ) - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the giveaway to delete.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to delete a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway deletion started by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID.", - { messageId, guildId: interaction.guildId } - ); - } - - 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 (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) { - logger.warn(`Could not delete giveaway message: ${error.message}`); - } - - 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}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_DELETE, - data: { - description: `Giveaway deleted: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: giveaway.prize || 'Unknown', - inline: true - }, - { - name: 'Entries', - value: (giveaway.participants?.length || 0).toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway deletion:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Deleted", - `Successfully deleted the giveaway for **${giveaway.prize}** ${statusMsg}. ${winnerStatusMsg}`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Giveaway/gend.js b/src/commands/Giveaway/gend.js deleted file mode 100644 index 700ca5eaeb..0000000000 --- a/src/commands/Giveaway/gend.js +++ /dev/null @@ -1,191 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; -import { - endGiveaway as endGiveawayService, - createGiveawayEmbed, - createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("gend") - .setDescription( - "Ends an active giveaway immediately and picks the winner(s).", - ) - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the giveaway to end.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to end a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway end initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways(interaction.client, interaction.guildId); - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } - - const endResult = await endGiveawayService( - interaction.client, - giveaway, - interaction.guildId, - interaction.user.id - ); - - const updatedGiveaway = endResult.giveaway; - const winners = endResult.winners; - - const channel = await interaction.client.channels.fetch( - updatedGiveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${updatedGiveaway.channelId}:`, err.message); - return null; - }); - - if (!channel || !channel.isTextBased()) { - throw new TitanBotError( - `Channel not found: ${updatedGiveaway.channelId}`, - ErrorTypes.VALIDATION, - "Could not find the channel where the giveaway was hosted. The giveaway state has been updated.", - { channelId: updatedGiveaway.channelId, messageId } - ); - } - - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); - - if (!message) { - throw new TitanBotError( - `Message not found: ${messageId}`, - ErrorTypes.VALIDATION, - "Could not find the giveaway message. The giveaway state has been updated.", - { messageId, channelId: updatedGiveaway.channelId } - ); - } - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const newEmbed = createGiveawayEmbed(updatedGiveaway, "ended", winners); - const newRow = createGiveawayButtons(true); - - await message.edit({ - content: "šŸŽ‰ **GIVEAWAY ENDED** šŸŽ‰", - embeds: [newEmbed], - components: [newRow], - }); - - if (winners.length > 0) { - const winnerMentions = winners - .map((id) => `<@${id}>`) - .join(","); - const winnerPingMsg = await channel.send({ - content: `šŸŽ‰ CONGRATULATIONS ${winnerMentions}! You won the **${updatedGiveaway.prize}** giveaway! Please contact the host <@${updatedGiveaway.hostId}> to claim your prize.`, - }); - updatedGiveaway.winnerPingMessageId = winnerPingMsg.id; - await saveGiveaway(interaction.client, interaction.guildId, updatedGiveaway); - - logger.info(`Giveaway ended with ${winners.length} winner(s): ${messageId}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_WINNER, - data: { - description: `Giveaway ended with ${winners.length} winner(s)`, - channelId: channel.id, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: updatedGiveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: 'Winners', - value: winnerMentions, - inline: false - }, - { - name: 'Entries', - value: endResult.participantCount.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway winner event:', logError); - } - } else { - await channel.send({ - content: `The giveaway for **${updatedGiveaway.prize}** has ended with no valid entries.`, - }); - logger.info(`Giveaway ended with no winners: ${messageId}`); - } - - logger.info(`Giveaway successfully ended by ${interaction.user.tag}: ${messageId}`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Giveaway Ended āœ…", - `Successfully ended the giveaway for **${updatedGiveaway.prize}** in ${channel}. Selected ${winners.length} winner(s) from ${endResult.participantCount} entries.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Giveaway/greroll.js b/src/commands/Giveaway/greroll.js deleted file mode 100644 index 64be4b3e03..0000000000 --- a/src/commands/Giveaway/greroll.js +++ /dev/null @@ -1,289 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getGuildGiveaways, saveGiveaway } from '../../utils/giveaways.js'; -import { - selectWinners, - createGiveawayEmbed, - createGiveawayButtons -} from '../../services/giveawayService.js'; -import { logEvent, EVENT_TYPES } from '../../services/loggingService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("greroll") - .setDescription("Rerolls the winner(s) for an ended giveaway.") - .addStringOption((option) => - option - .setName("messageid") - .setDescription("The message ID of the ended giveaway.") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild), - - async execute(interaction) { - if (!interaction.inGuild()) { - throw new TitanBotError( - 'Giveaway command used outside guild', - ErrorTypes.VALIDATION, - 'This command can only be used in a server.', - { userId: interaction.user.id } - ); - } - - if (!interaction.member.permissions.has(PermissionFlagsBits.ManageGuild)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - "You need the 'Manage Server' permission to reroll a giveaway.", - { userId: interaction.user.id, guildId: interaction.guildId } - ); - } - - logger.info(`Giveaway reroll initiated by ${interaction.user.tag} in guild ${interaction.guildId}`); - - const messageId = interaction.options.getString("messageid"); - - if (!messageId || !/^\d+$/.test(messageId)) { - throw new TitanBotError( - 'Invalid message ID format', - ErrorTypes.VALIDATION, - 'Please provide a valid message ID.', - { providedId: messageId } - ); - } - - const giveaways = await getGuildGiveaways( - interaction.client, - interaction.guildId, - ); - - const giveaway = giveaways.find(g => g.messageId === messageId); - - if (!giveaway) { - throw new TitanBotError( - `Giveaway not found: ${messageId}`, - ErrorTypes.VALIDATION, - "No giveaway was found with that message ID in the database.", - { messageId, guildId: interaction.guildId } - ); - } - - if (!giveaway.isEnded && !giveaway.ended) { - throw new TitanBotError( - `Giveaway still active: ${messageId}`, - ErrorTypes.VALIDATION, - "This giveaway is still active. Please use `/gend` to end it first.", - { messageId, status: 'active' } - ); - } - - const participants = giveaway.participants || []; - - if (participants.length < giveaway.winnerCount) { - throw new TitanBotError( - `Insufficient participants for reroll: ${participants.length} < ${giveaway.winnerCount}`, - ErrorTypes.VALIDATION, - "Not enough entries to pick the required number of winners.", - { participantsCount: participants.length, winnersNeeded: giveaway.winnerCount } - ); - } - - const newWinners = selectWinners( - participants, - giveaway.winnerCount, - ); - - const updatedGiveaway = { - ...giveaway, - winnerIds: newWinners, - rerolledAt: new Date().toISOString(), - rerolledBy: interaction.user.id - }; - - const channel = await interaction.client.channels.fetch( - giveaway.channelId, - ).catch(err => { - logger.warn(`Could not fetch channel ${giveaway.channelId}:`, err.message); - return null; - }); - - if (!channel || !channel.isTextBased()) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - logger.warn(`Could not find channel for giveaway ${messageId}, but saved new winners to database`); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - "The new winners have been selected and saved to the database. Could not find channel to announce.", - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - const message = await channel.messages - .fetch(messageId) - .catch(err => { - logger.warn(`Could not fetch message ${messageId}:`, err.message); - return null; - }); - - if (!message) { - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const winnerMentions = newWinners - .map((id) => `<@${id}>`) - .join(","); - - const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) - : null; - if (existingPingMsg) { - await existingPingMsg.edit({ - content: `šŸ”„ **GIVEAWAY REROLL** šŸ”„ New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - } else { - const newPingMsg = await channel.send({ - content: `šŸ”„ **GIVEAWAY REROLL** šŸ”„ New winners for **${giveaway.prize}**: ${winnerMentions}!`, - }); - updatedGiveaway.winnerPingMessageId = newPingMsg.id; - } - - logger.info(`Giveaway rerolled (message not found, but announced): ${messageId}`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_REROLL, - data: { - description: `Giveaway rerolled: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: giveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: 'New Winners', - value: winnerMentions, - inline: false - }, - { - name: 'Total Entries', - value: participants.length.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway reroll:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Complete", - `The new winners have been announced in ${channel}. (Original message not found).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - } - - await saveGiveaway( - interaction.client, - interaction.guildId, - updatedGiveaway, - ); - - const newEmbed = createGiveawayEmbed(updatedGiveaway, "reroll", newWinners); - const newRow = createGiveawayButtons(true); - - await message.edit({ - content: "šŸ”„ **GIVEAWAY REROLLED** šŸ”„", - embeds: [newEmbed], - components: [newRow], - }); - - const winnerMentions = newWinners - .map((id) => `<@${id}>`) - .join(","); - - const existingPingMsg = giveaway.winnerPingMessageId - ? await channel.messages.fetch(giveaway.winnerPingMessageId).catch(() => null) - : null; - if (existingPingMsg) { - await existingPingMsg.edit({ - content: `šŸ”„ **REROLL WINNERS** šŸ”„ CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, - }); - } else { - const newPingMsg = await channel.send({ - content: `šŸ”„ **REROLL WINNERS** šŸ”„ CONGRATULATIONS ${winnerMentions}! You are the new winner(s) for the **${giveaway.prize}** giveaway! Please contact the host <@${giveaway.hostId}> to claim your prize.`, - }); - updatedGiveaway.winnerPingMessageId = newPingMsg.id; - } - - logger.info(`Giveaway successfully rerolled: ${messageId} with ${newWinners.length} new winners`); - - try { - await logEvent({ - client: interaction.client, - guildId: interaction.guildId, - eventType: EVENT_TYPES.GIVEAWAY_REROLL, - data: { - description: `Giveaway rerolled: ${giveaway.prize}`, - channelId: giveaway.channelId, - userId: interaction.user.id, - fields: [ - { - name: 'Prize', - value: giveaway.prize || 'Mystery Prize!', - inline: true - }, - { - name: 'New Winners', - value: winnerMentions, - inline: false - }, - { - name: 'Total Entries', - value: participants.length.toString(), - inline: true - } - ] - } - }); - } catch (logError) { - logger.debug('Error logging giveaway reroll event:', logError); - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "Reroll Successful āœ…", - `Successfully rerolled the giveaway for **${giveaway.prize}** in ${channel}. Selected ${newWinners.length} new winner(s).`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - }, -}; \ No newline at end of file diff --git a/src/commands/JoinToCreate/jointocreate.js b/src/commands/JoinToCreate/jointocreate.js deleted file mode 100644 index 593212f856..0000000000 --- a/src/commands/JoinToCreate/jointocreate.js +++ /dev/null @@ -1,684 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, EmbedBuilder, LabelBuilder } from 'discord.js'; -import { successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { - initializeJoinToCreate, - getChannelConfiguration, - updateChannelConfig, - removeTriggerChannel, - hasManageGuildPermission, - logConfigurationChange, - getConfiguration -} from '../../services/joinToCreateService.js'; - -export default { - data: new SlashCommandBuilder() - .setName("jointocreate") - .setDescription("Manage Join to Create voice channels system.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .setDMPermission(false) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription("Set up a new Join to Create voice channel.") - .addChannelOption((option) => - option - .setName("category") - .setDescription("Category to create the channel in.") - .addChannelTypes(ChannelType.GuildCategory) - ) - .addStringOption((option) => - option - .setName("channel_name") - .setDescription("Select a template for naming temporary voice channels.") - .addChoices( - { name: "{username}'s Room (Default)", value: "{username}'s Room" }, - { name: "{username}'s Channel", value: "{username}'s Channel" }, - { name: "{username}'s Lounge", value: "{username}'s Lounge" }, - { name: "{username}'s Space", value: "{username}'s Space" }, - { name: "{displayName}'s Room", value: "{displayName}'s Room" }, - { name: "{username}'s VC", value: "{username}'s VC" }, - { name: "{username}'s Music Room", value: "{username}'s Music Room" }, - { name: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { name: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { name: "{username}'s Private Room", value: "{username}'s Private Room" } - ) - ) - .addIntegerOption((option) => - option - .setName("user_limit") - .setDescription("Maximum number of users in temporary channels. (0 = unlimited)") - ) - .addIntegerOption((option) => - option - .setName("bitrate") - .setDescription("Bitrate for temporary channels in kbps (8-96).") - ) - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Configure an existing Join to Create system.") - .addChannelOption((option) => - option - .setName("trigger_channel") - .setDescription("The Join to Create trigger channel to configure.") - .setRequired(true) - .addChannelTypes(ChannelType.GuildVoice) - ) - ), - category: "utility", - - async execute(interaction, config, client) { - try { - - if (!hasManageGuildPermission(interaction.member)) { - throw new TitanBotError( - 'User lacks ManageGuild permission', - ErrorTypes.PERMISSION, - 'You need **Manage Server** permission to use this command.' - ); - } - - const subcommand = interaction.options.getSubcommand(); - await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - - let responseEmbed; - - if (subcommand === "setup") { - await handleSetupSubcommand(interaction, client); - return; - } else if (subcommand === "dashboard") { - await handleConfigSubcommand(interaction, client); - return; - } - - } catch (error) { - try { - let errorMessage = 'An error occurred while executing this command.'; - - if (error instanceof TitanBotError) { - errorMessage = error.userMessage || 'An error occurred. Please try again.'; - logger.debug(`TitanBotError [${error.type}]: ${error.message}`, error.context || {}); - } else { - logger.error('Unexpected error in jointocreate command:', error); - errorMessage = 'An unexpected error occurred. Please try again or contact support.'; - } - - return replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: errorMessage }); - } catch (replyError) { - logger.error('Failed to send error message:', replyError); - } - } - } -}; - -async function handleSetupSubcommand(interaction, client) { - try { - const category = interaction.options.getChannel('category'); - const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; - const userLimit = interaction.options.getInteger('user_limit') || 0; - const bitrate = interaction.options.getInteger('bitrate') || 64; - const guildId = interaction.guild.id; - - logger.debug(`Setting up Join to Create in guild ${guildId} with template: ${nameTemplate}`); - - const existingConfig = await getConfiguration(client, guildId); - - 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 dashboard\` 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, - { - guildId, - activeTriggerCount: activeTriggerChannels.length, - expected: true, - suppressErrorLog: true - } - ); - } - } - - 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], - }, - ], - }); - - 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 - }); - - await logConfigurationChange(client, guildId, interaction.user.id, 'Initialized Join to Create', { - channelId: triggerChannel.id, - nameTemplate, - userLimit, - bitrate - }); - - logger.info(`Successfully created Join to Create system in guild ${guildId}`); - - 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 InteractionHelper.safeEditReply(interaction, { 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. Please check bot permissions.' - ); - } -} - -async function handleConfigSubcommand(interaction, client) { - try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; - - const currentConfig = await getChannelConfiguration(client, guildId, triggerChannel.id); - const channelConfig = currentConfig.channelConfig || {}; - - 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"}\``, - inline: false - }, - { - name: 'User Limit', - value: `${(channelConfig.userLimit ?? currentConfig.userLimit ?? 0) === 0 ? 'Unlimited' : (channelConfig.userLimit ?? currentConfig.userLimit ?? 0) + ' users'}`, - inline: true - }, - { - name: 'Bitrate', - value: `${(channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000} kbps`, - inline: true - } - ) - .setFooter({ text: 'Use the buttons below to modify settings • Only one trigger channel is supported per guild' }) - .setTimestamp(); - - const nameButton = new ButtonBuilder() - .setCustomId(`jtc_config_name_${triggerChannel.id}`) - .setLabel('šŸ“ Name Template') - .setStyle(ButtonStyle.Primary); - - const limitButton = new ButtonBuilder() - .setCustomId(`jtc_config_limit_${triggerChannel.id}`) - .setLabel('šŸ‘„ User Limit') - .setStyle(ButtonStyle.Primary); - - const bitrateButton = new ButtonBuilder() - .setCustomId(`jtc_config_bitrate_${triggerChannel.id}`) - .setLabel('šŸŽµ Bitrate') - .setStyle(ButtonStyle.Primary); - - const deleteButton = new ButtonBuilder() - .setCustomId(`jtc_config_delete_${triggerChannel.id}`) - .setLabel('šŸ—‘ļø Remove Channel') - .setStyle(ButtonStyle.Danger); - - const row = new ActionRowBuilder().addComponents(nameButton, limitButton, bitrateButton, deleteButton); - - 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 dashboard` again.' - ); - } - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - time: 300000 - }); - - collector.on('collect', async (buttonInteraction) => { - try { - - if (!hasManageGuildPermission(buttonInteraction.member)) { - await buttonInteraction.reply({ - content: 'āŒ You need **Manage Server** permission to use these controls.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const customId = buttonInteraction.customId; - - if (customId.includes('jtc_config_name_')) { - await handleNameTemplateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_limit_')) { - await handleUserLimitModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_bitrate_')) { - await handleBitrateModal(buttonInteraction, triggerChannel, currentConfig, client); - } else if (customId.includes('jtc_config_delete_')) { - await handleChannelDeletion(buttonInteraction, triggerChannel, currentConfig, client); - } - } catch (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 - }).catch(() => {}); - } - }); - - collector.on('end', () => { - const disabledRow = new ActionRowBuilder().addComponents( - nameButton.setDisabled(true), - limitButton.setDisabled(true), - bitrateButton.setDisabled(true), - deleteButton.setDisabled(true) - ); - - message.edit({ - components: [disabledRow], - embeds: [configEmbed.setFooter({ text: 'Configuration session expired. Run the command again to make changes.' })] - }).catch(() => {}); - }); - - } catch (error) { - if (error instanceof TitanBotError) { - throw error; - } - throw new TitanBotError( - `Config failed: ${error.message}`, - ErrorTypes.DATABASE, - 'Failed to load configuration.' - ); - } -} - -async function handleNameTemplateModal(interaction, triggerChannel, currentConfig, client) { - try { - const TEMPLATE_OPTIONS = [ - { label: "{username}'s Room (Default)", value: "{username}'s Room" }, - { label: "{username}'s Channel", value: "{username}'s Channel" }, - { label: "{username}'s Lounge", value: "{username}'s Lounge" }, - { label: "{username}'s Space", value: "{username}'s Space" }, - { label: "{displayName}'s Room", value: "{displayName}'s Room" }, - { label: "{username}'s VC", value: "{username}'s VC" }, - { label: "{username}'s Music Room", value: "{username}'s Music Room" }, - { label: "{username}'s Gaming Room", value: "{username}'s Gaming Room" }, - { label: "{username}'s Chat Room", value: "{username}'s Chat Room" }, - { label: "{username}'s Private Room", value: "{username}'s Private Room" }, - ]; - - const currentTemplate = currentConfig.channelConfig?.nameTemplate - || currentConfig.channelNameTemplate - || "{username}'s Room"; - - const templateSelect = new StringSelectMenuBuilder() - .setCustomId('template') - .setPlaceholder('Pick a name template...') - .setOptions( - TEMPLATE_OPTIONS.map(o => ({ - label: o.label, - value: o.value, - default: o.value === currentTemplate, - })), - ); - - const templateLabel = new LabelBuilder() - .setLabel('Channel name template') - .setStringSelectMenuComponent(templateSelect); - - const modal = new ModalBuilder() - .setCustomId(`jtc_name_modal_${triggerChannel.id}`) - .setTitle('Channel Name Template') - .addLabelComponents(templateLabel); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_name_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - 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.getStringSelectValues('template'); - - await updateChannelConfig(client, interaction.guild.id, triggerChannel.id, { - nameTemplate: newTemplate - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated channel name template', { - channelId: triggerChannel.id, - newTemplate - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Channel name template changed to \`${newTemplate}\``)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - 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.' - ); - } -} - -async function handleUserLimitModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentLimit = currentConfig.channelConfig?.userLimit ?? currentConfig.userLimit ?? 0; - - const modal = new ModalBuilder() - .setCustomId(`jtc_limit_modal_${triggerChannel.id}`) - .setTitle('Configure User Limit') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('user_limit') - .setLabel('Enter user limit (0-99, 0 = unlimited)') - .setPlaceholder('Enter a number between 0 and 99') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(2) - .setValue(currentLimit.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_limit_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - 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, { - userLimit: parseInt(userInput) - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated user limit', { - channelId: triggerChannel.id, - userLimit: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `User limit changed to ${parseInt(userInput) === 0 ? 'Unlimited' : parseInt(userInput) + ' users'}`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - 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.' - ); - } -} - -async function handleBitrateModal(interaction, triggerChannel, currentConfig, client) { - try { - const currentBitrate = ((currentConfig.channelConfig.bitrate ?? currentConfig.bitrate ?? 64000) / 1000); - - const modal = new ModalBuilder() - .setCustomId(`jtc_bitrate_modal_${triggerChannel.id}`) - .setTitle('Configure Bitrate') - .addComponents( - new ActionRowBuilder().addComponents( - new TextInputBuilder() - .setCustomId('bitrate') - .setLabel('Enter bitrate in kbps (8-384)') - .setPlaceholder('Enter a number between 8 and 384') - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(1) - .setMaxLength(3) - .setValue(currentBitrate.toString()) - ) - ); - - await interaction.showModal(modal); - - const modalSubmission = await interaction.awaitModalSubmit({ - filter: (i) => i.customId === `jtc_bitrate_modal_${triggerChannel.id}` && i.user.id === interaction.user.id, - time: 60000 - }); - - 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, { - bitrate: parseInt(userInput) * 1000 - }); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Updated bitrate', { - channelId: triggerChannel.id, - bitrate: parseInt(userInput) - }); - - await modalSubmission.reply({ - embeds: [successEmbed('Updated', `Bitrate changed to ${parseInt(userInput)} kbps`)], - flags: MessageFlags.Ephemeral - }); - - } catch (error) { - if (error.code === 'INTERACTION_COLLECTOR_ERROR') { - return; - } - 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.' - ); - } -} - -async function handleChannelDeletion(interaction, triggerChannel, currentConfig, client) { - try { - const confirmRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`jtc_delete_confirm_${triggerChannel.id}`) - .setLabel('šŸ—‘ļø Yes, Delete') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`jtc_delete_cancel_${triggerChannel.id}`) - .setLabel('āŒ Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - await InteractionHelper.safeReply(interaction, { - embeds: [warningEmbed('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 - }); - - const message = await interaction.fetchReply(); - const deleteCollector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: (i) => i.user.id === interaction.user.id && - (i.customId === `jtc_delete_confirm_${triggerChannel.id}` || - i.customId === `jtc_delete_cancel_${triggerChannel.id}`), - time: 600_000, - max: 1 - }); - - deleteCollector.on('collect', async (buttonInteraction) => { - try { - - 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); - - await logConfigurationChange(client, interaction.guild.id, interaction.user.id, 'Removed Join to Create trigger', { - channelId: triggerChannel.id, - channelName: triggerChannel.name - }); - - try { - if (triggerChannel.members.size === 0) { - await triggerChannel.delete('Join to Create trigger removed by administrator'); - } - } catch (deleteError) { - logger.warn(`Could not delete channel ${triggerChannel.id}: ${deleteError.message}`); - - } - - await buttonInteraction.update({ - embeds: [successEmbed('Removed', `**${triggerChannel.name}** has been removed from the Join to Create system.`)], - components: [] - }); - - } else { - await buttonInteraction.update({ - embeds: [successEmbed('Cancelled', 'Channel removal has been cancelled.')], - components: [] - }); - } - } catch (collectError) { - logger.error('Error handling delete confirmation:', collectError); - await buttonInteraction.reply({ - content: 'āŒ An error occurred while processing your request.', - flags: MessageFlags.Ephemeral - }).catch(() => {}); - } - }); - - deleteCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - message.edit({ components: [] }).catch(() => {}); - } - }); - - } catch (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.' - ); - } -} \ No newline at end of file diff --git a/src/commands/JoinToCreate/modules/config_setup.js b/src/commands/JoinToCreate/modules/config_setup.js deleted file mode 100644 index 460d337f2f..0000000000 --- a/src/commands/JoinToCreate/modules/config_setup.js +++ /dev/null @@ -1,549 +0,0 @@ -import { getColor } from '../../../config/bot.js'; -import { - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ChannelType, - MessageFlags, - ComponentType, - EmbedBuilder, - ButtonBuilder, - ButtonStyle -} from 'discord.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { - getJoinToCreateConfig, - updateJoinToCreateConfig, - removeJoinToCreateTrigger, - addJoinToCreateTrigger -} from '../../../utils/database.js'; - -export default { - async execute(interaction, config, client) { - try { - const triggerChannel = interaction.options.getChannel('trigger_channel'); - const guildId = interaction.guild.id; - - const currentConfig = await getJoinToCreateConfig(client, guildId); - - if (!currentConfig.triggerChannels.includes(triggerChannel.id)) { - 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() - .setTitle('Join to Create Configuration') - .setDescription(`Configure settings for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: 'Current Channel Name Template', - value: `\`${currentConfig.channelOptions?.[triggerChannel.id]?.nameTemplate || currentConfig.channelNameTemplate}\``, - inline: false - }, - { - name: 'Current User Limit', - value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'No limit' : currentConfig.userLimit + ' users'}`, - inline: true - }, - { - name: 'Current Bitrate', - value: `${(currentConfig.channelOptions?.[triggerChannel.id]?.bitrate || currentConfig.bitrate) / 1000} kbps`, - inline: true - } - ) - .setFooter({ text: 'Select an option to configure below' }) - .setTimestamp(); - - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(`jointocreate_config_${triggerChannel.id}`) - .setPlaceholder('Select a configuration option') - .addOptions( - new StringSelectMenuOptionBuilder() - .setLabel('Change Channel Name Template') - .setDescription('Modify the template for temporary channel names') - .setValue('name_template'), - new StringSelectMenuOptionBuilder() - .setLabel('Change User Limit') - .setDescription('Set maximum users per temporary channel') - .setValue('user_limit'), - new StringSelectMenuOptionBuilder() - .setLabel('Change Bitrate') - .setDescription('Adjust audio quality for temporary channels') - .setValue('bitrate'), - new StringSelectMenuOptionBuilder() - .setLabel('Remove This Trigger Channel') - .setDescription('Remove this channel from the Join to Create system') - .setValue('remove_trigger'), - new StringSelectMenuOptionBuilder() - .setLabel('View Current Settings') - .setDescription('Show all current configuration details') - .setValue('view_settings') - ); - - const row = new ActionRowBuilder().addComponents(selectMenu); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }).catch(error => { - logger.error('Failed to edit reply in config_setup:', error); - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.StringSelect, - filter: (i) => i.user.id === interaction.user.id && i.customId === `jointocreate_config_${triggerChannel.id}`, -time: 60000 - }); - - collector.on('collect', async (selectInteraction) => { - await selectInteraction.deferUpdate(); - - const selectedOption = selectInteraction.values[0]; - - try { - switch (selectedOption) { - case 'name_template': - await handleNameTemplateChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'user_limit': - await handleUserLimitChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'bitrate': - await handleBitrateChange(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'remove_trigger': - await handleRemoveTrigger(selectInteraction, triggerChannel, currentConfig, client); - break; - case 'view_settings': - await handleViewSettings(selectInteraction, triggerChannel, currentConfig, client); - break; - } - } catch (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 replyUserError(selectInteraction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage - }).catch(() => {}); - } - }); - - collector.on('end', async (collected, reason) => { - if (reason === 'time') { - const disabledRow = new ActionRowBuilder().addComponents( - selectMenu.setDisabled(true) - ); - - await InteractionHelper.safeEditReply(interaction, { - components: [disabledRow], - }).catch(() => {}); - } - }); - } catch (error) { - 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.' - ); - } - } -}; - -async function handleNameTemplateChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('Channel Name Template Configuration') - .setDescription('Please enter the new channel name template.') - .addFields( - { - name: 'Available Variables', - value: '• `{username}` - User\'s username\n• `{display_name}` - User\'s display name\n• `{user_tag}` - User\'s tag (User#1234)\n• `{guild_name}` - Server name', - inline: false - }, - { - name: 'Current Template', - value: `\`${currentConfig.channelOptions?.[triggerChannel.id]?.nameTemplate || currentConfig.channelNameTemplate}\``, - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type your new template in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id, -time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newTemplate = message.content.trim(); - - if (!newTemplate || newTemplate.length > 100) { - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Template must be between 1 and 100 characters.' - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - nameTemplate: newTemplate - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('Template Updated', `Channel name template changed to \`${newTemplate}\``)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (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 replyUserError(interaction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - replyUserError(interaction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No response received. Template update cancelled.' - }).catch(() => {}); - } - }); -} - -async function handleUserLimitChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('User Limit Configuration') - .setDescription('Please enter the new user limit (0-99, where 0 = no limit).') - .addFields( - { - name: 'Current Limit', - value: `${currentConfig.channelOptions?.[triggerChannel.id]?.userLimit || currentConfig.userLimit === 0 ? 'No limit' : currentConfig.userLimit + ' users'}`, - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type the new limit in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newLimit = parseInt(message.content.trim()); - - if (newLimit < 0 || newLimit > 99) { - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'User limit must be between 0 and 99.' - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - userLimit: newLimit - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('Limit Updated', `User limit changed to ${newLimit === 0 ? 'No limit' : newLimit + ' users'}`)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (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 replyUserError(interaction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - replyUserError(interaction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No valid response received. Update cancelled.' - }).catch(() => {}); - } - }); -} - -async function handleBitrateChange(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('Bitrate Configuration') - .setDescription('Please enter the new bitrate in kbps (8-384).') - .addFields( - { - name: 'Current Bitrate', - value: `${(currentConfig.channelOptions?.[triggerChannel.id]?.bitrate || currentConfig.bitrate) / 1000} kbps`, - inline: false - }, - { - name: 'Common Values', - value: '• 64 kbps - Normal quality\n• 96 kbps - Good quality\n• 128 kbps - High quality\n• 256 kbps - Very high quality', - inline: false - } - ) - .setColor(getColor('info')) - .setFooter({ text: 'Type the new bitrate in the chat below' }); - - await interaction.followUp({ embeds: [embed], flags: MessageFlags.Ephemeral }); - - const collector = interaction.channel.createMessageCollector({ - filter: (m) => m.author.id === interaction.user.id && /^\d+$/.test(m.content.trim()), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (message) => { - try { - const newBitrate = parseInt(message.content.trim()); - - if (newBitrate < 8 || newBitrate > 384) { - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Bitrate must be between 8 and 384 kbps.' - }); - return; - } - - const channelOptions = currentConfig.channelOptions || {}; - channelOptions[triggerChannel.id] = { - ...channelOptions[triggerChannel.id], - bitrate: newBitrate * 1000 - }; - - await updateJoinToCreateConfig(client, interaction.guild.id, { - channelOptions: channelOptions - }); - - await interaction.followUp({ - embeds: [successEmbed('Bitrate Updated', `Bitrate changed to ${newBitrate} kbps`)], - flags: MessageFlags.Ephemeral, - }); - - await message.delete().catch(() => {}); - } catch (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 replyUserError(interaction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage - }).catch(() => {}); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - replyUserError(interaction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No valid response received. Update cancelled.' - }).catch(() => {}); - } - }); -} - -async function handleRemoveTrigger(interaction, triggerChannel, currentConfig, client) { - const embed = new EmbedBuilder() - .setTitle('Remove Trigger Channel') - .setDescription(`Are you sure you want to remove ${triggerChannel} from the Join to Create system?`) - .setColor('#ff6600') - .setFooter({ text: 'This action cannot be undone' }); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`confirm_remove_${triggerChannel.id}`) - .setLabel('Remove Channel') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`cancel_remove_${triggerChannel.id}`) - .setLabel('Cancel') - .setStyle(ButtonStyle.Secondary) - ); - - await interaction.followUp({ - embeds: [embed], - components: [row], - flags: MessageFlags.Ephemeral - }); - - const collector = interaction.channel.createMessageComponentCollector({ - componentType: ComponentType.Button, - filter: (i) => i.user.id === interaction.user.id && - (i.customId === `confirm_remove_${triggerChannel.id}` || i.customId === `cancel_remove_${triggerChannel.id}`), - time: 600_000, - max: 1 - }); - - collector.on('collect', async (buttonInteraction) => { - await buttonInteraction.deferUpdate(); - - if (buttonInteraction.customId === `confirm_remove_${triggerChannel.id}`) { - try { - const success = await removeJoinToCreateTrigger(client, interaction.guild.id, triggerChannel.id); - - if (success) { - await buttonInteraction.followUp({ - embeds: [successEmbed('Channel Removed', `${triggerChannel} has been removed from the Join to Create system.`)], - flags: MessageFlags.Ephemeral, - }); - } else { - await replyUserError(buttonInteraction, { - type: ErrorTypes.CONFIGURATION, - message: 'Could not remove the trigger channel.' - }); - } - } catch (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 replyUserError(buttonInteraction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage - }).catch(() => {}); - } - } else { - await buttonInteraction.followUp({ - embeds: [successEmbed('Cancelled', 'Channel removal has been cancelled.')], - flags: MessageFlags.Ephemeral, - }); - } - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time') { - replyUserError(interaction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No response received. Removal cancelled.' - }).catch(() => {}); - } - }); -} - -async function handleViewSettings(interaction, triggerChannel, currentConfig, client) { - const channelConfig = currentConfig.channelOptions?.[triggerChannel.id] || {}; - - const embed = new EmbedBuilder() - .setTitle('Current Settings') - .setDescription(`Configuration for ${triggerChannel}`) - .setColor(getColor('info')) - .addFields( - { - name: 'Trigger Channel', - value: `${triggerChannel} (${triggerChannel.id})`, - inline: false - }, - { - name: 'Channel Name Template', - value: `\`${channelConfig.nameTemplate || currentConfig.channelNameTemplate}\``, - inline: false - }, - { - name: 'User Limit', - value: `${channelConfig.userLimit || currentConfig.userLimit === 0 ? 'No limit' : (channelConfig.userLimit || currentConfig.userLimit) + ' users'}`, - inline: true - }, - { - name: 'Bitrate', - value: `${(channelConfig.bitrate || currentConfig.bitrate) / 1000} kbps`, - inline: true - }, - { - name: 'Category', - value: currentConfig.categoryId ? `<#${currentConfig.categoryId}>` : 'Not set', - inline: true - }, - { - name: 'System Status', - value: currentConfig.enabled ? 'āœ… Enabled' : 'āŒ Disabled', - inline: true - }, - { - name: 'Active Temporary Channels', - value: Object.keys(currentConfig.temporaryChannels || {}).length.toString(), - inline: true - } - ) - .setTimestamp(); - - await interaction.followUp({ - embeds: [embed], - flags: MessageFlags.Ephemeral - }); -} \ No newline at end of file diff --git a/src/commands/JoinToCreate/modules/setup.js b/src/commands/JoinToCreate/modules/setup.js deleted file mode 100644 index 00ebd31053..0000000000 --- a/src/commands/JoinToCreate/modules/setup.js +++ /dev/null @@ -1,78 +0,0 @@ -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'; - -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -export default { - async execute(interaction, config, client) { - const category = interaction.options.getChannel('category'); - const nameTemplate = interaction.options.getString('channel_name') || "{username}'s Room"; - const userLimit = interaction.options.getInteger('user_limit') || 0; - const bitrate = interaction.options.getInteger('bitrate') || 64; - const guildId = interaction.guild.id; - - try { - const triggerChannel = await interaction.guild.channels.create({ - name: 'Join to Create', - type: ChannelType.GuildVoice, - parent: category?.id, - userLimit: userLimit, - bitrate: bitrate * 1000, - permissionOverwrites: [ - { - id: interaction.guild.id, - allow: [PermissionFlagsBits.ViewChannel, PermissionFlagsBits.Connect], - }, - ], - }); - - await addJoinToCreateTrigger(client, guildId, triggerChannel.id, { - nameTemplate: nameTemplate, - userLimit: userLimit, - bitrate: bitrate * 1000, - categoryId: category?.id - }); - - const embed = successEmbed( - 'āœ… Join to Create Setup Complete', - `Created trigger channel: ${triggerChannel}\n\n` + - `**Settings:**\n` + - `• Temporary Channel Name Template: \`${nameTemplate}\`\n` + - `• User Limit: ${userLimit === 0 ? 'No limit' : userLimit + ' users'}\n` + - `• Bitrate: ${bitrate} kbps\n` + - `${category ?`• Category: ${category.name}`: '• Category: None (root level)'}\n\n` + - `When users join this channel, a temporary voice channel will be created for them.` - ); - - try { - if (interaction.deferred) { - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } else { - await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); - } - } catch (responseError) { - logger.error('Error responding to interaction:', responseError); - - try { - if (!interaction.replied) { - await InteractionHelper.safeReply(interaction, { embeds: [embed], flags: MessageFlags.Ephemeral }); - } - } catch (e) { - logger.error('All response attempts failed:', e); - } - } - } catch (error) { - 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.' - ); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation b/src/commands/Moderation new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/src/commands/Moderation @@ -0,0 +1 @@ + diff --git a/src/commands/Moderation/ban.js b/src/commands/Moderation/ban.js deleted file mode 100644 index 86dd778858..0000000000 --- a/src/commands/Moderation/ban.js +++ /dev/null @@ -1,67 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -export default { - data: new SlashCommandBuilder() - .setName("ban") - .setDescription("Ban a user from the server") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to ban") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the ban"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - - async execute(interaction, config, client) { - const user = interaction.options.getUser("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (!user) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to ban.', - { subtype: 'invalid_user' }, - ); - } - - if (user.id === interaction.user.id) { - throw new TitanBotError( - 'Cannot ban self', - ErrorTypes.VALIDATION, - 'You cannot ban yourself.', - ); - } - if (user.id === client.user.id) { - throw new TitanBotError( - 'Cannot ban bot', - ErrorTypes.VALIDATION, - 'You cannot ban the bot.', - ); - } - - const result = await ModerationService.banUser({ - guild: interaction.guild, - user, - moderator: interaction.member, - reason, - }); - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `🚫 **Banned** ${user.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -}; diff --git a/src/commands/Moderation/cases.js b/src/commands/Moderation/cases.js deleted file mode 100644 index 5999cd834d..0000000000 --- a/src/commands/Moderation/cases.js +++ /dev/null @@ -1,177 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { getModerationCases } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName('cases') - .setDescription('View moderation cases and audit logs') - .setDefaultMemberPermissions(PermissionFlagsBits.ViewAuditLog) - .setDMPermission(false) - .addStringOption(option => - option.setName('filter') - .setDescription('Filter cases by type or user') - .addChoices( - { name: 'All Cases', value: 'all' }, - { name: 'Bans', value: 'Member Banned' }, - { name: 'Kicks', value: 'Member Kicked' }, - { name: 'Timeouts', value: 'Member Timed Out' }, - { name: 'Warnings', value: 'User Warned' } - ) - ) - .addUserOption(option => - option.setName('user') - .setDescription('Filter cases by specific user') - ) - .addIntegerOption(option => - option.setName('limit') - .setDescription('Number of cases to show (default: 10)') - .setMinValue(1) - .setMaxValue(50) - ), - - category: 'moderation', - - 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'); - const limit = interaction.options.getInteger('limit') || 10; - - const filters = { - limit, - action: filterType === 'all' ? undefined : filterType, - userId: targetUser?.id - }; - - const cases = await getModerationCases(interaction.guild.id, filters); - - if (cases.length === 0) { - throw new Error(targetUser - ? `No moderation cases found for ${targetUser.tag}` - : `No ${filterType === 'all' ? '' : filterType} cases found in this server.` - ); - } - - const CASES_PER_PAGE = 5; - const totalPages = Math.ceil(cases.length / CASES_PER_PAGE); - let currentPage = 1; - - const createCasesEmbed = (page) => { - const startIndex = (page - 1) * CASES_PER_PAGE; - const endIndex = startIndex + CASES_PER_PAGE; - const pageCases = cases.slice(startIndex, endIndex); - - const embed = createEmbed({ - title: 'Moderation Cases', - description: `Showing moderation cases for **${interaction.guild.name}**\n\n**Page ${page} of ${totalPages}**` - }); - - pageCases.forEach(case_ => { - const date = new Date(case_.createdAt).toLocaleDateString(); - const time = new Date(case_.createdAt).toLocaleTimeString(); - - embed.addFields({ - name: `Case #${case_.caseId} - ${case_.action}`, - value: `**Target:** ${case_.target}\n**Moderator:** ${case_.executor}\n**Date:** ${date} at ${time}\n**Reason:** ${case_.reason || 'No reason provided'}`, - inline: false - }); - }); - - embed.setFooter({ - text: `Total cases: ${cases.length} | Filter: ${filterType}${targetUser ?` | User: ${targetUser.tag}`: ''}` - }); - - return embed; - }; - - const createNavigationRow = (page) => { - const row = new ActionRowBuilder(); - - const prevButton = new ButtonBuilder() - .setCustomId('prev_page') - .setLabel('ā¬…ļø Previous') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === 1); - - const pageInfoButton = new ButtonBuilder() - .setCustomId('page_info') - .setLabel(`Page ${page}/${totalPages}`) - .setStyle(ButtonStyle.Primary) - .setDisabled(true); - - const nextButton = new ButtonBuilder() - .setCustomId('next_page') - .setLabel('Next āž”ļø') - .setStyle(ButtonStyle.Secondary) - .setDisabled(page === totalPages); - - row.addComponents(prevButton, pageInfoButton, nextButton); - return row; - }; - - const message = await interaction.editReply({ - embeds: [createCasesEmbed(currentPage)], - components: [createNavigationRow(currentPage)] - }); - - const collector = message.createMessageComponentCollector({ - componentType: ComponentType.Button, -time: 120000 - }); - - collector.on('collect', async (buttonInteraction) => { - await buttonInteraction.deferUpdate(); - - if (buttonInteraction.user.id !== interaction.user.id) { - await buttonInteraction.followUp({ - content: 'You cannot use these buttons. Run `/cases` to get your own case view.', - flags: MessageFlags.Ephemeral - }); - return; - } - - const { customId } = buttonInteraction; - - if (customId === 'prev_page' && currentPage > 1) { - currentPage--; - } else if (customId === 'next_page' && currentPage < totalPages) { - currentPage++; - } - - await interaction.editReply({ - embeds: [createCasesEmbed(currentPage)], - components: [createNavigationRow(currentPage)] - }); - }); - - collector.on('end', async () => { - const disabledRow = createNavigationRow(currentPage); - disabledRow.components.forEach(button => button.setDisabled(true)); - - try { - await message.edit({ - components: [disabledRow] - }); - } catch (error) { - } - }); - - } catch (error) { - logger.error('Error in cases command:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while retrieving moderation cases. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/dm.js b/src/commands/Moderation/dm.js deleted file mode 100644 index da9714f18a..0000000000 --- a/src/commands/Moderation/dm.js +++ /dev/null @@ -1,110 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { sanitizeMarkdown } from '../../utils/validation.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName("dm") - .setDescription("Send a direct message to a user (Staff only)") - .addUserOption(option => - option - .setName("user") - .setDescription("The user to send a DM to") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("message") - .setDescription("The message to send") - .setRequired(true) - ) - .addBooleanOption(option => - option - .setName("anonymous") - .setDescription("Send the message anonymously (default: false)") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers) - .setDMPermission(false), - 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; - - try { - - if (message.length > 2000) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Messages must be under 2000 characters.' }); - } - - if (targetUser.bot) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot send DMs to bot accounts.' }); - } - - const sanitized = sanitizeMarkdown(message); - - const dmChannel = await targetUser.createDM(); - - await dmChannel.send({ - embeds: [ - successEmbed( - anonymous ? "Message from the Staff Team" : `Message from ${interaction.user.tag}`, - sanitized - ).setFooter({ - text: `You cannot reply to this message. | Logger ID: ${interaction.id}` - }) - ] - }); - - await logEvent({ - client: interaction.client, - guild: interaction.guild, - event: { - action: "DM Sent", - target: `${targetUser.tag} (${targetUser.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Anonymous: ${anonymous ? 'Yes' : 'No'}`, - metadata: { - userId: targetUser.id, - moderatorId: interaction.user.id, - anonymous, - messageLength: sanitized.length - } - } - }); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "DM Sent", - `Successfully sent a message to ${targetUser.tag}` - ), - ], - }); - } catch (error) { - logger.error('DM command error:', error); - -if (error.code === 50007) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Could not send a DM to ${targetUser.tag}. They may have DMs disabled.` }); - } - - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Failed to send DM: ${error.message}` }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/kick.js b/src/commands/Moderation/kick.js deleted file mode 100644 index 03fa9f677c..0000000000 --- a/src/commands/Moderation/kick.js +++ /dev/null @@ -1,78 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; - -export default { - data: new SlashCommandBuilder() - .setName("kick") - .setDescription("Kick a user from the server") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to kick") - .setRequired(true), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the kick"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - - async execute(interaction, config, client) { - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to kick.', - { subtype: 'invalid_user' }, - ); - } - - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot kick self", - ErrorTypes.VALIDATION, - "You cannot kick yourself.", - ); - } - - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot kick bot", - ErrorTypes.VALIDATION, - "You cannot kick the bot.", - ); - } - - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", - { subtype: 'user_not_found' }, - ); - } - - const result = await ModerationService.kickUser({ - guild: interaction.guild, - member, - moderator: interaction.member, - reason, - }); - - await InteractionHelper.universalReply(interaction, { - embeds: [ - successEmbed( - `šŸ‘¢ **Kicked** ${targetUser.tag}`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -}; diff --git a/src/commands/Moderation/lock.js b/src/commands/Moderation/lock.js deleted file mode 100644 index 75b251973f..0000000000 --- a/src/commands/Moderation/lock.js +++ /dev/null @@ -1,72 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName("lock") - .setDescription( - "Locks the current channel (prevents @everyone from sending messages).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - 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; - } - - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; - - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if (currentPermissions.has(PermissionFlagsBits.SendMessages) === false) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is already locked.` }); - } - - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: false }, -{ type: 0, reason: `Channel locked by ${interaction.user.tag}` }, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Locked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None', - moderatorId: interaction.user.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `šŸ”’ **Channel Locked**`, - `${channel} is now locked down. No one can speak here now.`, - ), - ], - }); - } catch (error) { - logger.error('Lock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to lock the channel. Check my permissions (I need \'Manage Channels\').' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/massban.js b/src/commands/Moderation/massban.js deleted file mode 100644 index 9f6f8637a4..0000000000 --- a/src/commands/Moderation/massban.js +++ /dev/null @@ -1,189 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("massban") - .setDescription("Ban multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to ban (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass ban") - .setRequired(false) - ) - .addIntegerOption(option => - option - .setName("delete_days") - .setDescription("Number of days of messages to delete (0-7)") - .setMinValue(0) - .setMaxValue(7) - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - 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; - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass ban - No reason provided"; - const deleteDays = interaction.options.getInteger("delete_days") || 0; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass ban.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass ban.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const user = await client.users.fetch(userId).catch(() => null); - - if (!user) { - results.failed.push({ userId, reason: "User not found" }); - continue; - } - - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (member) { - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'ban'); - if (!modCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'ban'); - if (!botCheck.valid) { - results.skipped.push({ - user: user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'ban', 'bot'), - }); - continue; - } - } - - await interaction.guild.members.ban(userId, { - reason: reason, - deleteMessageSeconds: deleteDays * 24 * 60 * 60 - }); - - results.successful.push({ - user: user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Banned", - target: `${user.tag} (${user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Ban)`, - metadata: { - userId: user.id, - moderatorId: interaction.user.id, - massBan: true, - permanent: true - } - } - }); - - } catch (error) { - logger.error(`Failed to ban user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Ban Results:**\n\n`; - - if (results.successful.length > 0) { - description += `āœ… **Successfully Banned (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `āš ļø **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `āŒ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `šŸ”Ø Mass Ban Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in massban command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass ban. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/masskick.js b/src/commands/Moderation/masskick.js deleted file mode 100644 index 26e7e21a48..0000000000 --- a/src/commands/Moderation/masskick.js +++ /dev/null @@ -1,181 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("masskick") - .setDescription("Kick multiple users from the server at once") - .addStringOption(option => - option - .setName("users") - .setDescription("User IDs or mentions to kick (separated by spaces or commas)") - .setRequired(true) - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the mass kick") - .setRequired(false) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.KickMembers), - category: "moderation", - abuseProtection: { maxAttempts: 3, windowMs: 60_000 }, - - 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; - } - - const usersInput = interaction.options.getString("users"); - const reason = interaction.options.getString("reason") || "Mass kick - No reason provided"; - - try { - const userIds = usersInput -.replace(/<@!?(\d+)>/g, '$1') -.split(/[\s,]+/) -.filter(id => id && /^\d+$/.test(id)) -.slice(0, 20); - - if (userIds.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please provide valid user IDs or mentions. Maximum 20 users at once.' }); - } - - if (userIds.includes(interaction.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include yourself in a mass kick.' }); - } - - if (userIds.includes(client.user.id)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You cannot include the bot in a mass kick.' }); - } - - const results = { - successful: [], - failed: [], - skipped: [] - }; - - for (const userId of userIds) { - try { - const member = await interaction.guild.members.fetch(userId).catch(() => null); - - if (!member) { - results.failed.push({ userId, reason: "User not in server" }); - continue; - } - - const modCheck = ModerationService.validateHierarchy(interaction.member, member, 'kick'); - if (!modCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick'), - }); - continue; - } - - const botCheck = ModerationService.validateBotHierarchy(member, 'kick'); - if (!botCheck.valid) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: ModerationService.buildHierarchySkipReason(interaction.member, member, 'kick', 'bot'), - }); - continue; - } - - if (!member.kickable) { - results.skipped.push({ - user: member.user.tag, - userId, - reason: 'Target has Admin or a managed role, or bot lacks Kick Members', - }); - continue; - } - - await member.kick(reason); - - results.successful.push({ - user: member.user.tag, - userId - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "Member Kicked", - target: `${member.user.tag} (${member.user.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `${reason} (Mass Kick)`, - metadata: { - userId: member.user.id, - moderatorId: interaction.user.id, - massKick: true - } - } - }); - - } catch (error) { - logger.error(`Failed to kick user ${userId}:`, error); - const reason = error instanceof TitanBotError - ? (error.userMessage || error.message) - : (error.message || "Unknown error"); - results.failed.push({ - userId, - reason, - }); - } - } - - let description = `**Mass Kick Results:**\n\n`; - - if (results.successful.length > 0) { - description += `āœ… **Successfully Kicked (${results.successful.length}):**\n`; - results.successful.forEach(result => { - description += `• ${result.user} (${result.userId})\n`; - }); - description += '\n'; - } - - if (results.skipped.length > 0) { - description += `āš ļø **Skipped (${results.skipped.length}):**\n`; - results.skipped.forEach(result => { - description += `• ${result.user} - ${result.reason}\n`; - }); - description += '\n'; - } - - if (results.failed.length > 0) { - description += `āŒ **Failed (${results.failed.length}):**\n`; - results.failed.forEach(result => { - description += `• ${result.userId} - ${result.reason}\n`; - }); - } - - const embed = results.successful.length > 0 ? successEmbed : warningEmbed; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - embed( - `šŸ‘¢ Mass Kick Completed`, - description - ) - ] - }); - - } catch (error) { - logger.error("Error in masskick command:", error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing the mass kick. Please try again later.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/purge.js b/src/commands/Moderation/purge.js deleted file mode 100644 index 7b279f2d4f..0000000000 --- a/src/commands/Moderation/purge.js +++ /dev/null @@ -1,84 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName("purge") - .setDescription("Delete a specific amount of messages") - .addIntegerOption((option) => - option - .setName("amount") - .setDescription("Number of messages (1-100)") - .setRequired(true), - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - category: "moderation", - abuseProtection: { maxAttempts: 5, windowMs: 60_000 }, - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferSuccess) { - logger.warn(`Purge interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'purge' - }); - return; - } - - const amount = interaction.options.getInteger("amount"); - const channel = interaction.channel; - - if (amount < 1 || amount > 100) - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please specify a number between 1 and 100.' }); - - try { - const fetched = await channel.messages.fetch({ limit: amount }); - const deleted = await channel.bulkDelete(fetched, true); - const deletedCount = deleted.size; - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Messages Purged", - target: `${channel} (${deletedCount} messages)`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Deleted ${deletedCount} messages`, - metadata: { - channelId: channel.id, - messageCount: deletedCount, - requestedAmount: amount, - moderatorId: interaction.user.id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Messages Purged", - `Deleted ${deletedCount} messages in ${channel}.`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - setTimeout(() => { - interaction.deleteReply().catch(err => - logger.debug('Failed to auto-delete purge response:', err) - ); - }, 3000); - } catch (error) { - logger.error('Purge command error:', error); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An unexpected error occurred during message deletion. Note: Messages older than 14 days cannot be bulk deleted.' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/say.js b/src/commands/Moderation/say.js deleted file mode 100644 index 51d8dffbfa..0000000000 --- a/src/commands/Moderation/say.js +++ /dev/null @@ -1,134 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ChannelType, - MessageFlags, -} from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { sanitizeInput } from '../../utils/validation.js'; - -const TEXT_CHANNEL_TYPES = [ - ChannelType.GuildText, - ChannelType.GuildAnnouncement, -]; - -function resolveTargetChannel(interaction) { - const selected = interaction.options.getChannel('channel'); - if (selected) { - return selected; - } - - if (!interaction.channel || !TEXT_CHANNEL_TYPES.includes(interaction.channel.type)) { - return null; - } - - return interaction.channel; -} - -export default { - data: new SlashCommandBuilder() - .setName('say') - .setDescription('Send a plain message as the bot') - .addStringOption((option) => - option - .setName('message') - .setDescription('The message the bot should send') - .setRequired(true) - .setMaxLength(2000), - ) - .addChannelOption((option) => - option - .setName('channel') - .setDescription('Channel to send in (defaults to the current channel)') - .addChannelTypes(...TEXT_CHANNEL_TYPES) - .setRequired(false), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages) - .setDMPermission(false), - category: 'moderation', - abuseProtection: { maxAttempts: 8, windowMs: 60_000 }, - - async execute(interaction, _config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral, - }); - if (!deferSuccess) { - logger.warn('Say interaction defer failed', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'say', - }); - return; - } - - const rawMessage = interaction.options.getString('message'); - const message = sanitizeInput(rawMessage, 2000); - - if (!message) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Message cannot be empty.', - }); - } - - const channel = resolveTargetChannel(interaction); - if (!channel) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Choose a text channel or run this command in one.', - }); - } - - const memberPermissions = channel.permissionsFor(interaction.member); - const botPermissions = channel.permissionsFor(interaction.guild.members.me); - - if (!memberPermissions?.has(PermissionFlagsBits.SendMessages)) { - return replyUserError(interaction, { - type: ErrorTypes.PERMISSION, - message: `You do not have permission to send messages in ${channel}.`, - }); - } - - if (!botPermissions?.has(PermissionFlagsBits.SendMessages)) { - return replyUserError(interaction, { - type: ErrorTypes.PERMISSION, - message: `I do not have permission to send messages in ${channel}.`, - }); - } - - const sentMessage = await channel.send({ content: message }); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: 'Bot Message Sent', - target: `${channel} (${channel.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: message.length > 200 - ? `${message.slice(0, 197)}...` - : message, - metadata: { - channelId: channel.id, - messageId: sentMessage.id, - moderatorId: interaction.user.id, - messageLength: message.length, - }, - }, - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - 'Message Sent', - `Posted in ${channel}. [Jump to message](${sentMessage.url})`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - }, -}; diff --git a/src/commands/Moderation/timeout.js b/src/commands/Moderation/timeout.js deleted file mode 100644 index 936464ced6..0000000000 --- a/src/commands/Moderation/timeout.js +++ /dev/null @@ -1,111 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { 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 { ModerationService } from '../../services/moderation/moderationService.js'; - -const durationChoices = [ - { name: "5 minutes", value: 5 }, - { name: "10 minutes", value: 10 }, - { name: "30 minutes", value: 30 }, - { name: "1 hour", value: 60 }, - { name: "6 hours", value: 360 }, - { name: "1 day", value: 1440 }, - { name: "1 week", value: 10080 }, -]; - -export default { - data: new SlashCommandBuilder() - .setName("timeout") - .setDescription("Timeout a user for a specific duration.") - .addUserOption((option) => - option - .setName("target") - .setDescription("User to timeout") - .setRequired(true), - ) - .addIntegerOption( - (option) => - option - .setName("duration") - .setDescription("Duration of the timeout") - .setRequired(true) - .addChoices(...durationChoices), - ) - .addStringOption((option) => - option.setName("reason").setDescription("Reason for the timeout"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - 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; - } - - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const durationMinutes = interaction.options.getInteger("duration"); - const reason = interaction.options.getString("reason") || "No reason provided"; - - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to timeout.', - { subtype: 'invalid_user' }, - ); - } - - if (targetUser.id === interaction.user.id) { - throw new TitanBotError( - "Cannot timeout self", - ErrorTypes.VALIDATION, - "You cannot timeout yourself.", - ); - } - if (targetUser.id === client.user.id) { - throw new TitanBotError( - "Cannot timeout bot", - ErrorTypes.VALIDATION, - "You cannot timeout the bot.", - ); - } - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", - ); - } - - const durationMs = durationMinutes * 60 * 1000; - const result = await ModerationService.timeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member, - durationMs, - reason, - }); - - const durationDisplay = - durationChoices.find((c) => c.value === durationMinutes) - ?.name || `${durationMinutes} minutes`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `ā³ **Timed out** ${targetUser.tag} for ${durationDisplay}.`, - `**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -}; diff --git a/src/commands/Moderation/unban.js b/src/commands/Moderation/unban.js deleted file mode 100644 index 484cdec02a..0000000000 --- a/src/commands/Moderation/unban.js +++ /dev/null @@ -1,73 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("unban") - .setDescription("Unban a user from the server") - .addStringOption(option => - option - .setName("target") - .setDescription("The ID (or mention) of the user to unban") - .setRequired(true), - ) - .addStringOption(option => - option.setName("reason") - .setDescription("Reason for the unban") - .setRequired(false), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.BanMembers), - 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; - } - - const rawTarget = interaction.options.getString("target"); - const targetId = rawTarget.replace(/[<@!>]/g, '').trim(); - - if (!/^\d{17,20}$/.test(targetId)) { - return replyUserError(interaction, { - type: ErrorTypes.USER_INPUT, - message: 'Please provide a valid user ID or mention.', - }); - } - - const targetUser = await client.users.fetch(targetId).catch(() => null); - if (!targetUser) { - return replyUserError(interaction, { - type: ErrorTypes.USER_INPUT, - message: `Could not find a user with the ID \`${targetId}\`.`, - }); - } - - const reason = interaction.options.getString("reason") || "No reason provided"; - - const result = await ModerationService.unbanUser({ - guild: interaction.guild, - user: targetUser, - moderator: interaction.member, - reason, - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "āœ… User Unbanned", - `Successfully unbanned **${targetUser.tag}** from the server.\n\n**Reason:** ${reason}\n**Case ID:** #${result.caseId}`, - ), - ], - }); - }, -}; diff --git a/src/commands/Moderation/unlock.js b/src/commands/Moderation/unlock.js deleted file mode 100644 index 408c2b3b07..0000000000 --- a/src/commands/Moderation/unlock.js +++ /dev/null @@ -1,79 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName("unlock") - .setDescription( - "Unlocks the current channel (allows @everyone to send messages again).", - ) -.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels), - 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; - } - - const channel = interaction.channel; - const everyoneRole = interaction.guild.roles.everyone; - - try { - const currentPermissions = channel.permissionsFor(everyoneRole); - if ( - currentPermissions.has(PermissionFlagsBits.SendMessages) === - true || - currentPermissions.has(PermissionFlagsBits.SendMessages) === - null - ) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `${channel} is not explicitly locked (everyone can already send messages).` }); - } - - await channel.permissionOverwrites.edit( - everyoneRole, - { SendMessages: true }, - { - type: 0, - reason: `Channel unlocked by ${interaction.user.tag}`, -}, - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Channel Unlocked", - target: channel.toString(), - executor: `${interaction.user.tag} (${interaction.user.id})`, - metadata: { - channelId: channel.id, - category: channel.parent?.name || 'None' - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `šŸ”“ **Channel Unlocked**`, - `${channel} is now unlocked. You may speak now.`, - ), - ], - }); - } catch (error) { - logger.error('Unlock command error:', error); - await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'An unexpected error occurred while trying to unlock the channel. Check my permissions (I need \'Manage Channels\').' }); - } - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/untimeout.js b/src/commands/Moderation/untimeout.js deleted file mode 100644 index 104a108f2e..0000000000 --- a/src/commands/Moderation/untimeout.js +++ /dev/null @@ -1,66 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("untimeout") - .setDescription("Remove timeout from a user") - .addUserOption((option) => - option - .setName("target") - .setDescription("User to untimeout") - .setRequired(true), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - 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; - } - - const targetUser = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - - if (!targetUser) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to untimeout.', - { subtype: 'invalid_user' }, - ); - } - - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server.", - ); - } - - await ModerationService.removeTimeoutUser({ - guild: interaction.guild, - member, - moderator: interaction.member, - }); - - 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 deleted file mode 100644 index 4fc581fec7..0000000000 --- a/src/commands/Moderation/usernotes.js +++ /dev/null @@ -1,271 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getFromDb, setInDb, deleteFromDb, getUserNotesKey, getUserNotesListKey } from '../../utils/database.js'; -import { sanitizeInput } from '../../utils/validation.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -export default { - data: new SlashCommandBuilder() - .setName("usernotes") - .setDescription("Manage user notes for moderation purposes") - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a note to a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to add a note for") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("note") - .setDescription("The note to add") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("type") - .setDescription("Type of note") - .addChoices( - { name: "Warning", value: "warning" }, - { name: "Positive", value: "positive" }, - { name: "Neutral", value: "neutral" }, - { name: "Alert", value: "alert" } - ) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("view") - .setDescription("View notes for a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to view notes for") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove a specific note from a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to remove a note from") - .setRequired(true) - ) - .addIntegerOption(option => - option - .setName("index") - .setDescription("The index of the note to remove") - .setRequired(true) - .setMinValue(1) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("clear") - .setDescription("Clear all notes for a user") - .addUserOption(option => - option - .setName("target") - .setDescription("The user to clear notes for") - .setRequired(true) - ) - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages), - category: "moderation", - - async execute(interaction, config, client) { - const subcommand = interaction.options.getSubcommand(); - const targetUser = interaction.options.getUser("target"); - const guildId = interaction.guild.id; - - if (subcommand !== "view" && subcommand !== "remove" && subcommand !== "clear" && subcommand !== "add") { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); - } - - let notes = []; - if (targetUser) { - const notesKey = getUserNotesKey(guildId, targetUser.id); - notes = await getFromDb(notesKey, []); - } - - try { - switch (subcommand) { - case "add": - return await handleAddNote(interaction, targetUser, notes, guildId); - case "view": - return await handleViewNotes(interaction, targetUser, notes); - case "remove": - return await handleRemoveNote(interaction, targetUser, notes, guildId); - case "clear": - return await handleClearNotes(interaction, targetUser, notes, guildId); - default: - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'Please select a valid subcommand.' }); - } - } catch (error) { - logger.error(`Error in usernotes command (${subcommand}):`, error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'An error occurred while processing your request. Please try again later.' }); - } - } -}; - -async function handleAddNote(interaction, targetUser, notes, guildId) { - let note = interaction.options.getString("note").trim(); - const type = interaction.options.getString("type") || "neutral"; - - if (note.length > 1000) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Notes must be 1000 characters or less.' }); - } - - if (note.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Note cannot be empty.' }); - } - - note = sanitizeInput(note); - - const noteData = { - id: Date.now(), - content: note, - type: type, - author: interaction.user.tag, - authorId: interaction.user.id, - timestamp: new Date().toISOString() - }; - - notes.push(noteData); - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - const typeInfo = getNoteTypeInfo(type); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `${typeInfo.emoji} Note Added`, - `Added a **${type}** note for **${targetUser.tag}**:\n\n` + - `> ${note}\n\n` + - `**Moderator:** ${interaction.user.tag}\n` + - `**Total Notes:** ${notes.length}` - ) - ] - }); -} - -async function handleViewNotes(interaction, targetUser, notes) { - if (notes.length === 0) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - "šŸ“ No Notes", - `There are no notes for **${targetUser.tag}**.` - ), - ], - }); - } - - const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - - let description = `**Notes for ${targetUser.tag} (${targetUser.id}):**\n\n`; - - sortedNotes.forEach((note, index) => { - const typeInfo = getNoteTypeInfo(note.type); - const date = new Date(note.timestamp).toLocaleDateString(); - description += `${typeInfo.emoji} **Note #${index + 1}** (${note.type}) - ${date}\n`; - description += `> ${note.content}\n`; - description += `*Added by ${note.author}*\n\n`; - }); - - if (description.length > 4000) { - description = description.substring(0, 3900) + "\n... *(truncated)*"; - } - - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - `šŸ“ User Notes (${notes.length})`, - description - ) - ] - }); -} - -async function handleRemoveNote(interaction, targetUser, notes, guildId) { - const index = interaction.options.getInteger("index") - 1; - - if (index < 0 || index >= notes.length) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Please provide a valid note index (1-${notes.length}).` }); - } - - // The view command displays notes sorted newest-first, so resolve the index - // against the same ordering to delete the note the user actually sees. - const sortedNotes = [...notes].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); - const removedNote = sortedNotes[index]; - const originalIndex = notes.indexOf(removedNote); - notes.splice(originalIndex, 1); - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - const typeInfo = getNoteTypeInfo(removedNote.type); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - `${typeInfo.emoji} Note Removed`, - `Removed note #${index + 1} from **${targetUser.tag}**:\n\n` + - `> ${removedNote.content}\n\n` + - `**Remaining Notes:** ${notes.length}` - ) - ] - }); -} - -async function handleClearNotes(interaction, targetUser, notes, guildId) { - const noteCount = notes.length; - - if (noteCount === 0) { - return InteractionHelper.safeReply(interaction, { - embeds: [ - infoEmbed( - "No Notes to Clear", - `There are no notes for **${targetUser.tag}** to clear.` - ), - ], - }); - } - - notes.length = 0; - - const notesKey = getUserNotesKey(guildId, targetUser.id); - await setInDb(notesKey, notes); - - return InteractionHelper.safeReply(interaction, { - embeds: [ - successEmbed( - "šŸ—‘ļø Notes Cleared", - `Cleared **${noteCount}** notes from **${targetUser.tag}**.` - ) - ] - }); -} - -function getNoteTypeInfo(type) { - const types = { - warning: { emoji: "āš ļø", color: "#FF6B6B" }, - positive: { emoji: "āœ…", color: "#51CF66" }, - neutral: { emoji: "šŸ“", color: "#74C0FC" }, - alert: { emoji: "🚨", color: "#FFD43B" } - }; - - return types[type] || types.neutral; -} \ No newline at end of file diff --git a/src/commands/Moderation/warn.js b/src/commands/Moderation/warn.js deleted file mode 100644 index 1d1b2ef1eb..0000000000 --- a/src/commands/Moderation/warn.js +++ /dev/null @@ -1,108 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logModerationAction } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/moderation/warningService.js'; -import { ModerationService } from '../../services/moderation/moderationService.js'; -import { TitanBotError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName("warn") - .setDescription("Warn a user") - .addUserOption((o) => - o - .setName("target") - .setRequired(true) - .setDescription("User to warn"), - ) - .addStringOption((o) => - o - .setName("reason") - .setRequired(true) - .setDescription("Reason for the warning"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - 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; - } - - const target = interaction.options.getUser("target"); - const member = interaction.options.getMember("target"); - const reason = interaction.options.getString("reason"); - const moderator = interaction.user; - const guildId = interaction.guildId; - - if (!target) { - throw new TitanBotError( - 'Missing target user', - ErrorTypes.USER_INPUT, - 'You must specify a user to warn.', - { subtype: 'invalid_user' }, - ); - } - - if (!reason) { - throw new TitanBotError( - 'Missing warning reason', - ErrorTypes.VALIDATION, - 'You must provide a reason for the warning.', - { subtype: 'missing_required' }, - ); - } - - if (!member) { - throw new TitanBotError( - "Target not found", - ErrorTypes.USER_INPUT, - "The target user is not currently in this server." - ); - } - - ModerationService.assertModerationHierarchy(interaction.member, member, 'warn'); - - const { id, totalCount } = await WarningService.addWarning({ - guildId, - userId: target.id, - moderatorId: moderator.id, - reason, - timestamp: Date.now() - }); - - await logModerationAction({ - client, - guild: interaction.guild, - event: { - action: "User Warned", - target: `${target.tag} (${target.id})`, - executor: `${moderator.tag} (${moderator.id})`, - reason, - metadata: { - userId: target.id, - moderatorId: moderator.id, - totalWarns: totalCount, - warningNumber: totalCount, - warningId: id - } - } - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - `āš ļø **Warned** ${target.tag}`, - `**Reason:** ${reason}\n**Total Warns:** ${totalCount}`, - ), - ], - }); - } -}; \ No newline at end of file diff --git a/src/commands/Moderation/warnings.js b/src/commands/Moderation/warnings.js deleted file mode 100644 index 59fbce081c..0000000000 --- a/src/commands/Moderation/warnings.js +++ /dev/null @@ -1,98 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed } from '../../utils/embeds.js'; -import { logEvent } from '../../utils/moderation.js'; -import { logger } from '../../utils/logger.js'; -import { WarningService } from '../../services/moderation/warningService.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("warnings") - .setDescription("View all warnings for a user") - .addUserOption((o) => - o - .setName("target") - .setRequired(true) - .setDescription("User to check warnings for"), - ) - .setDefaultMemberPermissions(PermissionFlagsBits.ModerateMembers), - 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; - } - - const target = interaction.options.getUser("target"); - const guildId = interaction.guildId; - - const validWarnings = await WarningService.getWarnings(guildId, target.id); - const totalWarns = validWarnings.length; - - if (totalWarns === 0) { - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - createEmbed({ - title: `Warnings: ${target.tag}`, - description: "This user has no recorded warnings.", - }).setColor(getColor('success')), - ], - }); - return; - } - - const embed = createEmbed({ - title: `Warnings: ${target.tag}`, - description: `Total Warnings: **${totalWarns}**`, - }).setColor(getColor('warning')); - - const warningFields = validWarnings - .map((w, i) => { - const discordTimestamp = Math.floor(w.timestamp / 1000); - return { - name: `[#${i + 1}] Reason: ${w.reason.substring(0, 100)}`, - value: `**Moderator:** <@${w.moderatorId}>\n**Date:** ()`, - inline: false, - }; - }) - .slice(0, 25); - - embed.addFields(warningFields); - - const actionRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`warning_delete_specific:${target.id}:${interaction.user.id}`) - .setLabel('Delete Specific Warning') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId(`warning_clear_all:${target.id}:${interaction.user.id}`) - .setLabel('Clear All Warnings') - .setStyle(ButtonStyle.Danger), - ); - - await logEvent({ - client, - guild: interaction.guild, - event: { - action: "Warnings Viewed", - target: `${target.tag} (${target.id})`, - executor: `${interaction.user.tag} (${interaction.user.id})`, - reason: `Viewed ${totalWarns} warnings`, - metadata: { - userId: target.id, - moderatorId: interaction.user.id, - totalWarnings: totalWarns, - }, - }, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed], components: [actionRow] }); - }, -}; diff --git a/src/commands/Reaction_roles/reactroles.js b/src/commands/Reaction_roles/reactroles.js deleted file mode 100644 index 692443c5e9..0000000000 --- a/src/commands/Reaction_roles/reactroles.js +++ /dev/null @@ -1,1096 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, RoleSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ButtonBuilder, ButtonStyle, MessageFlags, ComponentType, EmbedBuilder, LabelBuilder, CheckboxBuilder, TextDisplayBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { createError, TitanBotError, ErrorTypes, replyUserError } 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'; -import { - getReactionRolePanelStatus, - formatPanelStatusField, -} from '../../utils/panelStatus.js'; -import { startDashboardSession } from '../../utils/dashboardSession.js'; -import { getReactionRoleKey } from '../../utils/database/keys.js'; - -const DASHBOARD_EPHEMERAL = MessageFlags.Ephemeral; -const SELECT_OPTION_LABEL_LIMIT = 100; -const SELECT_OPTION_DESCRIPTION_LIMIT = 100; - -function truncateText(value, maxLength) { - const text = String(value ?? ''); - return text.length > maxLength ? text.substring(0, maxLength) : text; -} - -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') - .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) - .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(); - - if (subcommand === 'setup') { - await handleSetup(interaction); - } else if (subcommand === 'dashboard') { - const selectedPanelId = interaction.options.getString('panel'); - await handleDashboard(interaction, selectedPanelId); - } - }, - - async autocomplete(interaction) { - if (interaction.commandName !== 'reactroles') return; - if (interaction.options.getSubcommand() !== 'dashboard') return; - - // Autocomplete must respond within 3s. Build choices from stored panel data and - // cached channels/messages only — no network fetches — to avoid DiscordAPIError 10062. - try { - const guildId = interaction.guild.id; - const client = interaction.client; - const guild = interaction.guild; - - let panels; - try { - panels = await getAllReactionRoleMessages(client, guildId); - } catch { - await interaction.respond([]).catch(() => {}); - return; - } - - if (!panels?.length) { - await interaction.respond([]).catch(() => {}); - return; - } - - const choices = []; - for (const panel of panels) { - if (!panel.messageId || !panel.channelId) continue; - - const channel = guild.channels.cache.get(panel.channelId); - if (!channel) continue; - - const cachedTitle = channel.messages?.cache?.get(panel.messageId)?.embeds?.[0]?.title; - const roleCount = Array.isArray(panel.roles) ? panel.roles.length : 0; - const label = cachedTitle - ? `${cachedTitle} (#${channel.name})` - : `#${channel.name} Ā· ${roleCount} role${roleCount === 1 ? '' : 's'}`; - - choices.push({ name: label.substring(0, 100), value: panel.messageId }); - if (choices.length >= 25) break; - } - - await interaction.respond(choices).catch(() => {}); - } catch { - await interaction.respond([]).catch(() => {}); - } - } -}; - -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'); - - 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 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 } - ); - } - - const roles = []; - const roleValidationErrors = []; - const seenRoleIds = new Set(); - - for (let i = 1; i <= 5; i++) { - const role = interaction.options.getRole(`role${i}`); - if (role) { - if (seenRoleIds.has(role.id)) { - roleValidationErrors.push(`**${role.name}** - This role was selected more than once`); - continue; - } - - 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; - } - - seenRoleIds.add(role.id); - 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)], - flags: MessageFlags.Ephemeral - }); - } - - 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: truncateText(role.name, SELECT_OPTION_LABEL_LIMIT), - description: truncateText(`Add/remove the ${role.name} role`, SELECT_OPTION_DESCRIPTION_LIMIT), - 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); - try { - await createReactionRoleMessage( - interaction.client, - interaction.guildId, - channel.id, - message.id, - roleIds - ); - } catch (saveError) { - // The panel is already posted but its data failed to persist, so the dropdown - // would not work. Remove the orphaned message before surfacing the error. - await message.delete().catch(() => {}); - throw saveError; - } - - 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}`)] - }); -} - -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); - } -} - -async function showPanelDashboard(interaction, panelData, discordMsg, guildId, guild, client, panelStatus = null) { - if (!panelStatus && client) { - panelStatus = await getReactionRolePanelStatus(client, guild, panelData); - if (panelStatus.recoveredId) { - await migrateReactionRoleMessageId(client, guildId, panelData, panelStatus.recoveredId); - discordMsg = panelStatus.message || discordMsg; - } - } - - const payload = buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild, panelStatus); - await InteractionHelper.safeEditReply(interaction, { ...payload, flags: DASHBOARD_EPHEMERAL }); -} - -function buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild, panelStatus = null) { - 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 showRepost = panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; - - 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: 'Panel Status', value: formatPanelStatusField(panelStatus), inline: false }, - { 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 buttons = []; - - if (showRepost) { - buttons.push( - new ButtonBuilder() - .setCustomId(`rr_repost_${guildId}`) - .setLabel('Repost Panel') - .setStyle(ButtonStyle.Primary) - .setEmoji('šŸ“Œ'), - ); - } - - buttons.push( - new ButtonBuilder() - .setCustomId(`rr_edit_text_${guildId}`) - .setLabel('Edit Panel Text') - .setStyle(ButtonStyle.Primary) - .setEmoji('āœļø'), - 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('āž–'), - ] - : []), - ); - - return { - embeds: [embed], - components: [ - new ActionRowBuilder().addComponents(buttons), - new ActionRowBuilder().addComponents(optionsSelect), - ], - }; -} - -async function migrateReactionRoleMessageId(client, guildId, panelData, newMessageId) { - if (!newMessageId || panelData.messageId === newMessageId) return; - const oldKey = getReactionRoleKey(guildId, panelData.messageId); - panelData.messageId = newMessageId; - await client.db.set(getReactionRoleKey(guildId, newMessageId), panelData); - await client.db.delete(oldKey).catch(() => {}); -} - -async function repostReactionRolePanel(guild, panelData, client, guildId, fallbackEmbed = null) { - const channel = await guild.channels.fetch(panelData.channelId).catch(() => null); - if (!channel) { - throw createError( - 'Panel channel missing', - ErrorTypes.CONFIGURATION, - 'The configured panel channel no longer exists.', - ); - } - - const roleObjects = panelData.roles.map(id => guild.roles.cache.get(id)).filter(Boolean); - if (roleObjects.length === 0) { - throw createError( - 'No valid roles', - ErrorTypes.VALIDATION, - 'This panel has no valid roles left to repost.', - ); - } - - const title = fallbackEmbed?.title || 'Reaction Roles'; - const description = fallbackEmbed?.description || 'Select your roles using the menu below.'; - - const panelEmbed = new EmbedBuilder() - .setTitle(title) - .setDescription(description) - .setColor(getColor('info')) - .addFields({ - name: 'Available Roles', - value: roleObjects.map(role => `• ${role}`).join('\n'), - }); - - const row = new ActionRowBuilder().addComponents( - new StringSelectMenuBuilder() - .setCustomId('reaction_roles') - .setPlaceholder('Select your roles') - .setMinValues(0) - .setMaxValues(roleObjects.length) - .addOptions( - roleObjects.map(role => ({ - label: role.name.substring(0, 100), - description: `Add/remove the ${role.name} role`.substring(0, 100), - value: role.id, - emoji: 'šŸŽ­', - })), - ), - ); - - const sent = await channel.send({ embeds: [panelEmbed], components: [row] }); - await migrateReactionRoleMessageId(client, guildId, panelData, sent.id); - return sent; -} - -async function handleDashboard(interaction, selectedPanelId) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { flags: DASHBOARD_EPHEMERAL }); - if (!deferSuccess) return; - - const client = interaction.client; - const guildId = interaction.guild.id; - const guild = interaction.guild; - - const panels = await getAllReactionRoleMessages(client, guildId); - if (!panels?.length) { - throw createError( - 'No panels', - ErrorTypes.CONFIGURATION, - 'No reaction role panels found. Use `/reactroles setup` first.', - ); - } - - let panelData = selectedPanelId ? panels.find(p => p.messageId === selectedPanelId) : null; - if (!panelData) { - if (panels.length === 1) { - panelData = panels[0]; - } else { - throw createError( - 'Panel required', - ErrorTypes.VALIDATION, - 'Multiple panels exist. Choose one using the **panel** option.', - ); - } - } - - let panelStatus = await getReactionRolePanelStatus(client, guild, panelData); - if (panelStatus.recoveredId) { - await migrateReactionRoleMessageId(client, guildId, panelData, panelStatus.recoveredId); - panelStatus = await getReactionRolePanelStatus(client, guild, panelData); - } - - const discordMsg = panelStatus.message || (await fetchPanelDiscordMessage(guild, panelData)); - const payload = buildReactionRoleDashboardPayload(panelData, discordMsg, guildId, guild, panelStatus); - - await startDashboardSession({ - interaction, - ...payload, - flags: DASHBOARD_EPHEMERAL, - selectMenuId: `rr_opts_${guildId}`, - buttonMatcher: (customId) => - customId === `rr_edit_text_${guildId}` || - customId === `rr_delete_${guildId}` || - customId === `rr_repost_${guildId}`, - onSelect: async (selectInteraction) => { - const selectedOption = selectInteraction.values[0]; - if (selectedOption === 'add_role') { - await handleAddRole(selectInteraction, interaction, panelData, guildId, guild, client); - } else if (selectedOption === 'remove_role') { - await handleRemoveRole(selectInteraction, interaction, panelData, panels, guildId, guild, client); - } - }, - onButton: async (btnInteraction) => { - if (btnInteraction.customId === `rr_repost_${guildId}`) { - await btnInteraction.deferUpdate(); - const fallbackEmbed = discordMsg?.embeds?.[0]; - const newMsg = await repostReactionRolePanel( - guild, - panelData, - client, - guildId, - fallbackEmbed, - ); - await btnInteraction.followUp({ - embeds: [successEmbed('Panel Reposted', `Reaction role panel restored in ${newMsg.channel}.`)], - flags: MessageFlags.Ephemeral, - }); - await showPanelDashboard( - interaction, - panelData, - newMsg, - guildId, - guild, - client, - { exists: true, message: newMsg }, - ); - return; - } - - if (btnInteraction.customId === `rr_edit_text_${guildId}`) { - await handleEditText(btnInteraction, interaction, panelData, guildId, guild, client); - return; - } - - if (btnInteraction.customId === `rr_delete_${guildId}`) { - await handleDeletePanel(btnInteraction, interaction, panelData, panels, guildId, guild, client); - } - }, - }); -} - -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 replyUserError(buttonInteraction, { - type: ErrorTypes.UNKNOWN, - message: 'Failed to show the edit panel text modal. Please try again.', - }).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 newDescription = submitted.fields.getTextInputValue('panel_description').trim(); - - if (discordMsg) { - const roleObjects = panelData.roles - .map(id => guild.roles.cache.get(id)) - .filter(Boolean); - const updatedEmbed = EmbedBuilder.from(discordMsg.embeds[0]) - .setTitle(newTitle) - .setDescription(newDescription); - if (roleObjects.length > 0) { - const fields = discordMsg.embeds[0].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); - } - await discordMsg.edit({ embeds: [updatedEmbed] }).catch(() => {}); - } - - 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, client); -} - -async function handleAddRole(selectInteraction, rootInteraction, panelData, guildId, guild, client) { - await selectInteraction.deferUpdate(); - - if (panelData.roles.length >= 25) { - await replyUserError(selectInteraction, { - type: ErrorTypes.VALIDATION, - message: 'This panel already has the maximum of 25 roles.', - }); - 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 replyUserError(roleInteraction, { - type: ErrorTypes.VALIDATION, - message: `${role} is already in this panel.`, - }); - return; - } - if (role.id === guild.id) { - await replyUserError(roleInteraction, { - type: ErrorTypes.VALIDATION, - message: 'You cannot use @everyone.', - }); - return; - } - if (role.managed) { - await replyUserError(roleInteraction, { - type: ErrorTypes.VALIDATION, - message: 'Managed/bot roles cannot be used.', - }); - return; - } - if (hasDangerousPermissions(role)) { - await replyUserError(roleInteraction, { - type: ErrorTypes.PERMISSION, - message: 'That role has sensitive permissions (Administrator, Manage Server, etc.) and cannot be used.', - }); - return; - } - if (role.position >= guild.members.me.roles.highest.position) { - await replyUserError(roleInteraction, { - type: ErrorTypes.PERMISSION, - message: "That role is above my highest role in the hierarchy. Move my role above it first.", - }); - return; - } - - panelData.roles.push(role.id); - const key = getReactionRoleKey(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, client); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role selected. Nothing was changed.', - }).catch(() => {}); - } - }); -} - -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 replyUserError(selectInteraction, { - type: ErrorTypes.USER_INPUT, - message: 'The roles on this panel no longer exist in the server.', - }); - 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, - }); - - 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: [], - flags: DASHBOARD_EPHEMERAL, - }); - } else { - - 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: [], - flags: DASHBOARD_EPHEMERAL, - }); - } - } else { - const key = getReactionRoleKey(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, client); - } - }); - - removeCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role selected. Nothing was changed.', - }).catch(() => {}); - } - }); -} - -async function handleDeletePanel(btnInteraction, rootInteraction, panelData, panels, guildId, guild, client) { - 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'); - - const deleteWarningText = new TextDisplayBuilder() - .setContent(`āš ļø You are about to permanently delete the panel **${title}**. This will remove the Discord message and all associated reaction role assignments.`); - - const deleteCheckbox = new CheckboxBuilder() - .setCustomId('delete_confirmation') - .setDefault(false); - - const deleteCheckboxLabel = new LabelBuilder() - .setLabel('I confirm — this cannot be undone') - .setCheckboxComponent(deleteCheckbox); - - deleteModal - .addTextDisplayComponents(deleteWarningText) - .addLabelComponents(deleteCheckboxLabel); - - 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, client); - return; - } - - const confirmed = submitted.fields.getCheckbox('delete_confirmation'); - - if (!confirmed) { - await replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: 'You must tick the confirmation checkbox to delete the panel.' }); - await showPanelDashboard(rootInteraction, panelData, discordMsg, guildId, guild, client); - 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, - }); - - 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: [], - flags: DASHBOARD_EPHEMERAL, - }); - } else { - 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: [], - flags: DASHBOARD_EPHEMERAL, - }); - } -} \ No newline at end of file diff --git a/src/commands/Search/modules/search_define.js b/src/commands/Search/modules/search_define.js deleted file mode 100644 index 149ec0f3aa..0000000000 --- a/src/commands/Search/modules/search_define.js +++ /dev/null @@ -1,94 +0,0 @@ -import axios from 'axios'; -import { createEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; - -export default { - async execute(interaction) { - try { - const deferred = await InteractionHelper.safeDefer(interaction); - if (!deferred) { - return; - } - - const word = interaction.options.getString('word'); - - if (word.length < 2) { - logger.warn('Define command - word too short', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a word with at least 2 characters.' }); - } - - const response = await axios.get( - `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, - { timeout: 5000 } - ); - - if (!response.data || response.data.length === 0) { - return await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${word}".` }); - } - - const data = response.data[0]; - const embed = createEmbed({ - title: data.word, - description: data.phonetic ? `*${data.phonetic}*` : '', - color: 'success' - }); - - data.meanings.slice(0, 5).forEach(meaning => { - const definitions = meaning.definitions - .slice(0, 3) - .map((def, idx) => { - let text = `${idx + 1}. ${def.definition}`; - if (def.example) { - text += `\n *Example: ${def.example}*`; - } - return text; - }) - .join('\n\n'); - - if (definitions) { - embed.addFields({ - name: `**${meaning.partOfSpeech || 'Definition'}**`, - value: definitions, - inline: false - }); - } - }); - - embed.setFooter({ text: 'Powered by Free Dictionary API' }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - logger.info('Dictionary definition retrieved', { - userId: interaction.user.id, - word: word, - guildId: interaction.guildId, - commandName: 'define' - }); - - } catch (error) { - logger.error('Dictionary lookup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - word: interaction.options.getString('word'), - guildId: interaction.guildId, - commandName: 'define' - }); - - if (error.response?.status === 404) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${interaction.options.getString('word')}".` }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'define', - source: 'dictionary_api' - }); - } - } - }, -}; diff --git a/src/commands/Search/modules/search_google.js b/src/commands/Search/modules/search_google.js deleted file mode 100644 index c277046d49..0000000000 --- a/src/commands/Search/modules/search_google.js +++ /dev/null @@ -1,26 +0,0 @@ -import { createEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; - -export default { - async execute(interaction) { - const query = interaction.options.getString('query'); - const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; - - const embed = createEmbed({ - title: 'Google Search', - description: `[Search for "${query}"](${searchUrl})`, - color: 'info' - }) - .setFooter({ text: 'Google Search Results' }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Google search link generated', { - userId: interaction.user.id, - query: query, - guildId: interaction.guildId, - commandName: 'google' - }); - }, -}; diff --git a/src/commands/Search/modules/search_urban.js b/src/commands/Search/modules/search_urban.js deleted file mode 100644 index b382fe30c0..0000000000 --- a/src/commands/Search/modules/search_urban.js +++ /dev/null @@ -1,121 +0,0 @@ -import axios from 'axios'; -import { createEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; - -export default { - async execute(interaction) { - try { - const term = interaction.options.getString('term'); - - if (term.length < 2) { - logger.warn('Urban command - term too short', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId - }); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Please enter a term with at least 2 characters.' }); - } - - 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 replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${term}" on Urban Dictionary.` }); - } - - const definition = response.data.list[0]; - const cleanDefinition = definition.definition.replace(/\[|\]/g, ''); - const cleanExample = definition.example.replace(/\[|\]/g, ''); - - const formattedDefinition = cleanDefinition - .replace(/\n\s*\n/g, '\n\n') - .slice(0, 2000); - - const formattedExample = cleanExample - ? `*"${cleanExample.replace(/\n/g, ' ').slice(0, 500)}..."*` - : '*No example provided*'; - - const embed = createEmbed({ - title: definition.word, - description: formattedDefinition, - color: 'info' - }) - .setURL(definition.permalink) - .addFields( - { - name: 'Example', - value: formattedExample, - inline: false - }, - { - name: 'Stats', - value: `${definition.thumbs_up.toLocaleString()} • ${definition.thumbs_down.toLocaleString()}`, - inline: true - }, - { - name: 'Author', - value: definition.author || 'Anonymous', - inline: true - } - ) - .setFooter({ - text: 'Urban Dictionary', - iconURL: 'https://i.imgur.com/8aQrX3a.png' - }); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - - logger.info('Urban Dictionary definition retrieved', { - userId: interaction.user.id, - term: term, - guildId: interaction.guildId, - commandName: 'urban' - }); - - } catch (error) { - logger.error('Urban Dictionary error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - term: interaction.options.getString('term'), - guildId: interaction.guildId, - apiStatus: error.response?.status, - commandName: 'urban' - }); - - if (error.response?.status === 404 || !error.response) { - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `No definitions found for "${interaction.options.getString('term')}" on Urban Dictionary.` }); - } else if (error.response?.status === 429) { - await replyUserError(interaction, { type: ErrorTypes.RATE_LIMIT, message: 'Too many requests to Urban Dictionary. Please try again in a few minutes.' }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'urban', - source: 'urban_dictionary_api' - }); - } - } - }, -}; diff --git a/src/commands/Search/search.js b/src/commands/Search/search.js deleted file mode 100644 index 52c1871902..0000000000 --- a/src/commands/Search/search.js +++ /dev/null @@ -1,54 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import searchDefine from './modules/search_define.js'; -import searchGoogle from './modules/search_google.js'; -import searchUrban from './modules/search_urban.js'; - -export default { - data: new SlashCommandBuilder() - .setName('search') - .setDescription('Search the web and dictionaries') - .addSubcommand(subcommand => - subcommand - .setName('define') - .setDescription('Look up a word definition') - .addStringOption(option => - option.setName('word') - .setDescription('The word to look up') - .setRequired(true)) - ) - .addSubcommand(subcommand => - subcommand - .setName('google') - .setDescription('Search Google') - .addStringOption(option => - option.setName('query') - .setDescription('What would you like to search for?') - .setRequired(true)) - ) - .addSubcommand(subcommand => - subcommand - .setName('urban') - .setDescription('Search Urban Dictionary for definitions') - .addStringOption(option => - option.setName('term') - .setDescription('The term to look up on Urban Dictionary') - .setRequired(true)) - ), - - async execute(interaction, config, client) { - const subcommand = interaction.options.getSubcommand(); - - switch (subcommand) { - case 'define': - return await searchDefine.execute(interaction, config, client); - case 'google': - return await searchGoogle.execute(interaction, config, client); - case 'urban': - return await searchUrban.execute(interaction, config, client); - default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand' }); - } - } -}; diff --git a/src/commands/Ticket/claim.js b/src/commands/Ticket/claim.js deleted file mode 100644 index d1d1c1f938..0000000000 --- a/src/commands/Ticket/claim.js +++ /dev/null @@ -1,50 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { claimTicket } from '../../services/ticket.js'; -export default { - data: new SlashCommandBuilder() - .setName("claim") - .setDescription("Claims an open ticket, assigning it to you.") - .setDMPermission(false), - - async execute(interaction, guildConfig, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); - } - - if (!permissionContext.canManageTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission or the configured `Ticket Staff Role` to claim tickets.' }); - } - - await claimTicket(interaction.channel, interaction.user); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Claimed!", - "You have successfully claimed this ticket.", - ), - ], - }); - - logger.info('Ticket claimed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, - guildId: interaction.guildId, - commandName: 'claim' - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Ticket/close.js b/src/commands/Ticket/close.js deleted file mode 100644 index 056a699b9b..0000000000 --- a/src/commands/Ticket/close.js +++ /dev/null @@ -1,61 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { closeTicket } from '../../services/ticket.js'; -export default { - data: new SlashCommandBuilder() - .setName("close") - .setDescription("Closes the current ticket.") - .setDMPermission(false) - .addStringOption((option) => - option - .setName("reason") - .setDescription("The reason for closing the ticket.") - .setRequired(false), - ), - - async execute(interaction, guildConfig, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); - } - - if (!permissionContext.canCloseTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission, the configured `Ticket Staff Role`, or be the ticket creator to close this ticket.' }); - } - - const reason = - interaction.options?.getString("reason") || - "Closed via command without a specific reason."; - - await closeTicket(interaction.channel, interaction.user, reason); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Closed!", - "This ticket has been closed successfully.", - ), - ], - }); - - logger.info('Ticket closed successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, - guildId: interaction.guildId, - reason: reason, - commandName: 'close' - }); - }, -}; diff --git a/src/commands/Ticket/modules/ticket_dashboard.js b/src/commands/Ticket/modules/ticket_dashboard.js deleted file mode 100644 index 17f3f43f88..0000000000 --- a/src/commands/Ticket/modules/ticket_dashboard.js +++ /dev/null @@ -1,1020 +0,0 @@ -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, infoEmbed } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { getGuildTicketStats } from '../../../utils/database/tickets.js'; -import { getUserTicketCount } from '../../../services/ticket.js'; -import { - getTicketPanelStatus, - messageHasButtonCustomId, - formatPanelStatusField, -} from '../../../utils/panelStatus.js'; -import { startDashboardSession } from '../../../utils/dashboardSession.js'; - -function buildButtonRow(guildConfig, guildId, disabled = false, panelStatus = null) { - const dmEnabled = guildConfig.dmOnClose !== false; - const showRepost = panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; - - const buttons = []; - - if (showRepost) { - buttons.push( - new ButtonBuilder() - .setCustomId(`ticket_cfg_repost_${guildId}`) - .setLabel('Repost Panel') - .setStyle(ButtonStyle.Primary) - .setEmoji('šŸ“Œ') - .setDisabled(disabled), - ); - } - - buttons.push( - 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), - ); - - return new ActionRowBuilder().addComponents(buttons); -} - -async function persistPanelMessageId(client, guildId, guildConfig, messageId) { - if (!messageId || guildConfig.ticketPanelMessageId === messageId) return; - guildConfig.ticketPanelMessageId = messageId; - if (client.db) { - await setGuildConfig(client, guildId, guildConfig); - } -} - -function buildPanelEmbed(config) { - return new EmbedBuilder() - .setTitle('Support Tickets') - .setDescription(config.ticketPanelMessage || 'Click the button below to create a support ticket.') - .setColor(getColor('info')); -} - -function buildPanelButtonRow(config) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('create_ticket') - .setLabel(config.ticketButtonLabel || 'Create Ticket') - .setStyle(ButtonStyle.Primary) - .setEmoji('šŸ“©'), - ); -} - -async function repostTicketPanel(client, guild, guildConfig, guildId) { - const channel = await guild.channels.fetch(guildConfig.ticketPanelChannelId).catch(() => null); - if (!channel) { - throw new TitanBotError( - 'Panel channel missing', - ErrorTypes.CONFIGURATION, - 'The configured ticket panel channel no longer exists. Set a new panel channel from the dashboard.', - ); - } - - const sentPanel = await channel.send({ - embeds: [buildPanelEmbed(guildConfig)], - components: [buildPanelButtonRow(guildConfig)], - }); - - await persistPanelMessageId(client, guildId, guildConfig, sentPanel.id); - return sentPanel; -} - -function formatCloseDuration(ms) { - if (ms == null) return '`N/A`'; - const hours = Math.floor(ms / 3_600_000); - const minutes = Math.floor((ms % 3_600_000) / 60_000); - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m`; -} - -function buildDashboardEmbed(config, guild, panelStatus = null, ticketStats = null) { - 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`'; - - 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'}\``; - - let panelStatusValue = formatPanelStatusField(panelStatus); - - const openTickets = ticketStats ? String(ticketStats.openCount) : '`—`'; - const avgCloseTime = ticketStats ? formatCloseDuration(ticketStats.avgCloseTimeMs) : '`—`'; - const feedbackSummary = ticketStats?.feedbackCount - ? `${ticketStats.avgRating}/5 (${ticketStats.feedbackCount} rating${ticketStats.feedbackCount !== 1 ? 's' : ''})` - : '`No ratings yet`'; - - 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 Status', value: panelStatusValue, inline: false }, - { 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 }, - { name: '\u200B', value: '\u200B', inline: true }, - { name: 'Open Tickets', value: openTickets, inline: true }, - { name: 'Avg Close Time', value: avgCloseTime, inline: true }, - { name: 'Feedback Rating', value: feedbackSummary, 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('šŸ“œ'), - ); -} - -async function refreshDashboard(rootInteraction, guildConfig, guildId, client) { - const panelStatus = client - ? await getTicketPanelStatus(client, rootInteraction.guild, guildConfig) - : null; - const ticketStats = client ? await getGuildTicketStats(guildId) : null; - - if (panelStatus?.recoveredId) { - await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); - } - - const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); - await InteractionHelper.safeEditReply(rootInteraction, { - embeds: [buildDashboardEmbed(guildConfig, rootInteraction.guild, panelStatus, ticketStats)], - components: [buttonRow, selectRow], - }).catch(() => {}); -} - -async function updateLivePanel(client, guild, config, guildId) { - if (!config.ticketPanelChannelId) return false; - try { - const panelStatus = await getTicketPanelStatus(client, guild, config); - if (panelStatus.recoveredId) { - await persistPanelMessageId(client, guildId, config, panelStatus.recoveredId); - } - if (!panelStatus.exists || !panelStatus.message) return false; - - await panelStatus.message.edit({ - embeds: [buildPanelEmbed(config)], - components: [buildPanelButtonRow(config)], - }); - return true; - } catch (error) { - logger.warn('Failed to update live ticket panel:', error.message); - return false; - } -} - -export default { - prefixOnly: false, - 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 panelStatus = await getTicketPanelStatus(client, interaction.guild, guildConfig); - if (panelStatus.recoveredId) { - await persistPanelMessageId(client, guildId, guildConfig, panelStatus.recoveredId); - } - - const ticketStats = await getGuildTicketStats(guildId); - - const selectRow = new ActionRowBuilder().addComponents(buildSelectMenu(guildId)); - const buttonRow = buildButtonRow(guildConfig, guildId, false, panelStatus); - - await startDashboardSession({ - interaction, - embeds: [buildDashboardEmbed(guildConfig, interaction.guild, panelStatus, ticketStats)], - components: [buttonRow, selectRow], - selectMenuId: `ticket_config_${guildId}`, - buttonMatcher: (customId) => - customId === `ticket_cfg_repost_${guildId}` || - customId === `ticket_cfg_dm_toggle_${guildId}` || - customId === `ticket_cfg_staff_role_btn_${guildId}` || - customId === `ticket_cfg_delete_${guildId}`, - onSelect: async (selectInteraction) => { - const selectedOption = selectInteraction.values[0]; - 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; - } - }, - onButton: async (btnInteraction) => { - if (btnInteraction.customId === `ticket_cfg_repost_${guildId}`) { - await handleRepostPanel(btnInteraction, interaction, guildConfig, guildId, client); - } else 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 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.', - ); - } - }, -}; - -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 setGuildConfig(client, guildId, guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); - - 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. Use **Repost Panel** on the dashboard to restore it.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -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 setGuildConfig(client, guildId, guildConfig); - - const panelUpdated = await updateLivePanel(client, rootInteraction.guild, guildConfig, guildId); - - 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. Use **Repost Panel** on the dashboard to restore it.' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -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 setGuildConfig(client, guildId, guildConfig); - - await roleInteraction.followUp({ - embeds: [successEmbed('Staff Role Updated', `Staff role set to ${role}.`)], - flags: MessageFlags.Ephemeral, - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - roleCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The staff role was not changed.', - }).catch(() => {}); - } - }); -} - -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); - - 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: [new ActionRowBuilder().addComponents(channelSelect)], - 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 setGuildConfig(client, 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, client); - }); - - catCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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); - - 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: [new ActionRowBuilder().addComponents(channelSelect)], - 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 setGuildConfig(client, 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, client); - }); - - catCollector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No category was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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 (Number.isNaN(newMax) || newMax < 1 || newMax > 10) { - await replyUserError(submitted, { - type: ErrorTypes.VALIDATION, - message: 'Max tickets must be a whole number between **1** and **10**.', - }); - return; - } - - guildConfig.maxTicketsPerUser = newMax; - await setGuildConfig(client, 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, client); -} - -async function handleDmOnClose(btnInteraction, rootInteraction, guildConfig, guildId, client) { - await btnInteraction.deferUpdate(); - - const newState = guildConfig.dmOnClose === false; - guildConfig.dmOnClose = newState; - await setGuildConfig(client, 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, client); -} - -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 setGuildConfig(client, 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, client); - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', - }).catch(() => {}); - } - }); -} - -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 setGuildConfig(client, guildId, guildConfig); - - await channelInteraction.followUp({ - embeds: [successEmbed('Transcript Channel Updated', `Transcripts will be sent to ${channel}`)], - flags: MessageFlags.Ephemeral - }); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - }); - - collector.on('end', (collected, reason) => { - if (reason === 'time' && collected.size === 0) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No channel selected. No changes were made.', - }).catch(() => {}); - } - }); -} - -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) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No user was selected.', - }).catch(() => {}); - } - }); -} - -async function handleRepostPanel(btnInteraction, rootInteraction, guildConfig, guildId, client) { - await btnInteraction.deferUpdate(); - - const panelStatus = await getTicketPanelStatus(client, rootInteraction.guild, guildConfig); - if (panelStatus.exists) { - await btnInteraction.followUp({ - embeds: [infoEmbed('Panel Already Active', 'The ticket panel is already posted in the configured channel.')], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - return; - } - - const sentPanel = await repostTicketPanel(client, rootInteraction.guild, guildConfig, guildId); - - await btnInteraction.followUp({ - embeds: [ - successEmbed( - 'Panel Reposted', - `A new ticket panel was posted in <#${guildConfig.ticketPanelChannelId}>.${ - sentPanel.url ? `\n[Open panel message](${sentPanel.url})` : '' - }`, - ), - ], - flags: MessageFlags.Ephemeral, - }).catch(() => {}); - - await refreshDashboard(rootInteraction, guildConfig, guildId, client); -} - -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, client); - return; - } - - const confirmation = submitted.fields.getTextInputValue('delete_confirmation').trim(); - - if (confirmation !== 'DELETE') { - await replyUserError(submitted, { type: ErrorTypes.UNKNOWN, message: 'You must type "DELETE" exactly to confirm deletion.' }); - await refreshDashboard(rootInteraction, guildConfig, guildId, client); - return; - } - - await submitted.deferUpdate(); - - const keysToDelete = [ - 'ticketPanelChannelId', - 'ticketPanelMessageId', - 'ticketStaffRoleId', - 'ticketCategoryId', - 'ticketClosedCategoryId', - 'ticketPanelMessage', - 'ticketButtonLabel', - 'maxTicketsPerUser', - 'dmOnClose', - ]; - - 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 { - - const messages = await panelChannel.messages.fetch({ limit: 50 }).catch(() => null); - if (messages) { - const found = messages.find( - m => m.author.id === client.user.id && messageHasButtonCustomId(m, 'create_ticket'), - ); - if (found) await found.delete().catch(() => {}); - } - } - } - } catch (panelDeleteError) { - logger.warn('Could not delete ticket panel message:', panelDeleteError.message); - } - } - - try { - const { pgConfig } = await import('../../../config/database/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 setGuildConfig(client, 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/priority.js b/src/commands/Ticket/priority.js deleted file mode 100644 index fde4195156..0000000000 --- a/src/commands/Ticket/priority.js +++ /dev/null @@ -1,67 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getTicketPermissionContext } from '../../utils/ticket/ticketPermissions.js'; -import { updateTicketPriority } from '../../services/ticket.js'; - -export default { - data: new SlashCommandBuilder() - .setName("priority") - .setDescription("Sets the priority level for the current support ticket.") - .addStringOption((option) => - option - .setName("level") - .setDescription("The priority level for the ticket.") - .setRequired(true) - .addChoices( - { name: "Urgent", value: "urgent" }, - { name: "High", value: "high" }, - { name: "Medium", value: "medium" }, - { name: "Low", value: "low" }, - { name: "None", value: "none" }, - ), - ) - .setDMPermission(false), - category: "Ticket", - - async execute(interaction, guildConfig, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - const permissionContext = await getTicketPermissionContext({ client, interaction }); - if (!permissionContext.ticketData) { - return await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: 'This command can only be used in a valid ticket channel.' }); - } - - if (!permissionContext.canManageTicket) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission or the configured `Ticket Staff Role` to change ticket priority.' }); - } - - const priorityLevel = interaction.options.getString("level"); - await updateTicketPriority(interaction.channel, priorityLevel, interaction.user); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Priority Updated", - `Ticket priority set to **${priorityLevel.toUpperCase()}**.`, - ), - ], - }); - - logger.info('Ticket priority updated successfully', { - userId: interaction.user.id, - userTag: interaction.user.tag, - channelId: interaction.channel.id, - channelName: interaction.channel.name, - guildId: interaction.guildId, - priority: priorityLevel, - commandName: 'priority' - }); - }, -}; diff --git a/src/commands/Ticket/ticket.js b/src/commands/Ticket/ticket.js deleted file mode 100644 index 982747b118..0000000000 --- a/src/commands/Ticket/ticket.js +++ /dev/null @@ -1,299 +0,0 @@ -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -import { handleInteractionError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; - -import ticketConfig from './modules/ticket_dashboard.js'; - -export default { - data: new SlashCommandBuilder() - .setName("ticket") - .setDescription("Manages the server's ticket system.") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels) - .addSubcommand((subcommand) => - subcommand - .setName("setup") - .setDescription( - "Sets up the ticket creation panel in a specified channel.", - ) - .addChannelOption((option) => - option -.setName("panel_channel") - .setDescription( - "The channel where the ticket panel will be sent.", - ) - .addChannelTypes(ChannelType.GuildText) - .setRequired(true), - ) - - .addStringOption((option) => - option - .setName("panel_message") - .setDescription( - "The main message/description for the ticket panel.", - ) - .setRequired(true), - ) - .addStringOption((option) => - option - .setName("button_label") - .setDescription( - "The label for the ticket creation button (default: Create Ticket)", - ) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("category") - .setDescription( - "The category where new tickets will be created (optional).", - ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addChannelOption((option) => - option - .setName("closed_category") - .setDescription( - "The category where closed tickets will be moved (optional).", - ) - .addChannelTypes(ChannelType.GuildCategory) - .setRequired(false), - ) - .addRoleOption((option) => - option - .setName("staff_role") - .setDescription( - "The role that can access tickets (optional).", - ) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("max_tickets_per_user") - .setDescription("Maximum number of tickets a user can create (default: 3)") - .setMinValue(1) - .setMaxValue(10) - .setRequired(false), - ) - .addBooleanOption((option) => - option - .setName("dm_on_close") - .setDescription("Send DM to user when their ticket is closed (default: true)") - .setRequired(false), - ), - ) - .addSubcommand((subcommand) => - subcommand - .setName("dashboard") - .setDescription("Open the interactive ticket system dashboard"), - ), - category: "ticket", - - async execute(interaction, config, client) { - const deferred = await InteractionHelper.safeDefer(interaction, { flags: MessageFlags.Ephemeral }); - if (!deferred) { - return; - } - - if ( - !interaction.member.permissions.has( - PermissionFlagsBits.ManageChannels, - ) - ) { - logger.warn('Ticket command permission denied', { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket' - }); - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need the `Manage Channels` permission for this action.' }); - } - - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === "dashboard") { - return ticketConfig.execute(interaction, config, client); - } - - if (subcommand === "setup") { - const existingConfig = await getGuildConfig(client, interaction.guildId); - if (existingConfig?.ticketPanelChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `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"); - const closedCategoryChannel = interaction.options.getChannel("closed_category"); - const staffRole = interaction.options.getRole("staff_role"); -const panelMessage = interaction.options.getString("panel_message") || "Click the button below to create a support ticket."; - const buttonLabel = - interaction.options.getString("button_label") || -"Create Ticket"; - const maxTicketsPerUser = interaction.options.getInteger("max_tickets_per_user") || 3; -const dmOnClose = interaction.options.getBoolean("dm_on_close") !== false; - - const setupEmbed = createEmbed({ - title: "Support Tickets", -description: panelMessage, - color: getColor('info') - }); - - const ticketButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("create_ticket") -.setLabel(buttonLabel) - .setStyle(ButtonStyle.Primary) - .setEmoji("šŸ“©"), - ); - - try { - const sentPanel = await panelChannel.send({ - embeds: [setupEmbed], - components: [ticketButton], - }); - - if (client.db && 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.ticketPanelMessageId = sentPanel?.id || null; - currentConfig.ticketPanelMessage = panelMessage; - currentConfig.ticketButtonLabel = buttonLabel; - currentConfig.maxTicketsPerUser = maxTicketsPerUser; - currentConfig.dmOnClose = dmOnClose; - - await setGuildConfig(client, interaction.guildId, currentConfig); - logger.info('Ticket configuration saved', { - guildId: interaction.guildId, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - }); - } else { - logger.error('Ticket setup: database unavailable, panel sent but configuration was NOT saved', { - guildId: interaction.guildId, - }); - } - - let successMessage = `The ticket creation panel has been sent to ${panelChannel}.`; - - if (categoryChannel) { - successMessage += `New tickets will be created in the **${categoryChannel.name}** category.`; - } else { - successMessage += 'New tickets will be created in a new "Tickets" category.'; - } - - if (closedCategoryChannel) { - successMessage += `Closed tickets will be moved to **${closedCategoryChannel.name}**.`; - } - - if (staffRole) { - successMessage += `**${staffRole.name}** role will have access to tickets.`; - } - - successMessage += `\n\n**Max Tickets Per User:** ${maxTicketsPerUser}\n**DM on Close:** ${dmOnClose ? 'Enabled' : 'Disabled'}`; - - await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Ticket Panel Set Up", - successMessage, - ), - ], - }); - - logger.info('Ticket panel setup completed', { - userId: interaction.user.id, - userTag: interaction.user.tag, - guildId: interaction.guildId, - panelChannelId: panelChannel.id, - categoryId: categoryChannel?.id, - closedCategoryId: closedCategoryChannel?.id, - staffRoleId: staffRole?.id, - maxTickets: maxTicketsPerUser, - dmOnClose: dmOnClose, - commandName: 'ticket_setup' - }); - - const logEmbed = createEmbed({ - title: "Ticket System Setup (Configuration Log)", - description: `The ticket panel was set up in ${panelChannel} by ${interaction.user}.`, - color: getColor('warning') - }) - .addFields( - { - name: "Panel Channel", - value: panelChannel.toString(), - inline: true, - }, - { - name: "Ticket Category", - value: categoryChannel - ? categoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Closed Category", - value: closedCategoryChannel - ? closedCategoryChannel.toString() - : "None specified.", - inline: true, - }, - { - name: "Staff Role", - value: staffRole - ? staffRole.toString() - : "None specified.", - inline: true, - }, - { - name: "Max Tickets Per User", - value: maxTicketsPerUser.toString(), - inline: true, - }, - { - name: "DM on Close", - value: dmOnClose ? 'Enabled' : 'Disabled', - inline: true, - }, - { - name: "Moderator", - value: `${interaction.user.tag} (${interaction.user.id})`, - inline: false, - }, - ); - - } catch (error) { - logger.error('Ticket setup error', { - error: error.message, - stack: error.stack, - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'ticket_setup' - }); - if (interaction.deferred || interaction.replied) { - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not send the ticket panel or save configuration. Check the bot\'s permissions (especially the ability to send messages in the target channel) and database connection.' }).catch(err => { - logger.error('Failed to send error reply', { - error: err.message, - guildId: interaction.guildId - }); - }); - } else { - await handleInteractionError(interaction, error, { - commandName: 'ticket_setup', - source: 'ticket_setup_command' - }); - } - } - } - } -}; \ No newline at end of file diff --git a/src/commands/Tools/baseconvert.js b/src/commands/Tools/baseconvert.js deleted file mode 100644 index 1ef560dc7e..0000000000 --- a/src/commands/Tools/baseconvert.js +++ /dev/null @@ -1,230 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { getColor } from '../../config/bot.js'; - -const BASE_ALPHABETS = { - 'BIN': { base: 2, prefix: '0b', name: 'Binary', alphabet: '01' }, - 'OCT': { base: 8, prefix: '0o', name: 'Octal', alphabet: '0-7' }, - 'DEC': { base: 10, prefix: '', name: 'Decimal', alphabet: '0-9' }, - 'HEX': { base: 16, prefix: '0x', name: 'Hexadecimal', alphabet: '0-9A-F' }, - 'B64': { base: 64, prefix: 'b64:', name: 'Base64', alphabet: 'A-Za-z0-9+/=' }, - 'B36': { base: 36, prefix: '', name: 'Base36', alphabet: '0-9A-Z' }, - 'B58': { base: 58, prefix: '', name: 'Base58', alphabet: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' }, - 'B62': { base: 62, prefix: '', name: 'Base62', alphabet: '0-9A-Za-z' }, -}; - -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() - .setName('baseconvert') - .setDescription('Convert numbers between different bases') - .addStringOption(option => - option.setName('number') - .setDescription('The number to convert') - .setRequired(true)) - .addStringOption(option => - option.setName('from') - .setDescription('Source base/format') - .setRequired(true) - .addChoices(...BASE_NAMES)) - .addStringOption(option => - option.setName('to') - .setDescription('Target base/format (default: all)') - .setRequired(false) - .addChoices(...BASE_NAMES)), - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`BaseConvert interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'baseconvert' - }); - return; - } - - const numberStr = interaction.options.getString('number').trim(); - const fromBase = interaction.options.getString('from'); - const toBase = interaction.options.getString('to'); - - const { prefix: fromPrefix, name: fromName } = BASE_ALPHABETS[fromBase]; - - const cleanNumber = fromPrefix && numberStr.startsWith(fromPrefix) - ? numberStr.slice(fromPrefix.length) - : numberStr; - - if (!cleanNumber) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'You must provide a number to convert.\n\n**Example:** `/baseconvert number:1010 from:BIN to:DEC`', - }); - } - - const alphabet = BASE_ALPHABETS[fromBase].alphabet; - 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)'; - } - logger.warn(`Invalid base conversion input: ${cleanNumber} for base ${fromBase}`); - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: `You provided: \`${cleanNumber}\`\n\nValid characters: \`${alphabet}\`${examples}`, - }); - } - - let decimalValue; - try { - if (fromBase === 'B64') { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } else { - decimalValue = parseBigIntFromBase(cleanNumber, fromBase); - } - } catch (error) { - logger.error('Base conversion parse error:', error); - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'The number is too large to process.\n\nTry with a smaller number.', - }); - } - - if (toBase) { - const { prefix: toPrefix, name: toName } = BASE_ALPHABETS[toBase]; - let result; - - try { - result = formatBigIntToBase(decimalValue, toBase); - - const embed = successEmbed( - 'šŸ”„ Base Conversion Result', - `**From ${fromName} (${fromBase}):** \`${fromPrefix}${cleanNumber}\`\n` + - `**To ${toName} (${toBase}):** \`${toPrefix}${result}\`\n` + - `**Decimal:** \`${decimalValue.toLocaleString()}\`` - ); - embed.setColor(getColor('success')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - - } catch (error) { - logger.error(`Base conversion error to ${toName}:`, error); - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'The result would be too large or incompatible.\n\nTry with a smaller number or different target base.', - }); - } - - } else { - let description = `**Input (${fromName}):** \`${fromPrefix}${cleanNumber}\`\n`; - description += `**Decimal:** \`${decimalValue.toLocaleString()}\`\n\n`; - - for (const [baseKey, { prefix, name }] of Object.entries(BASE_ALPHABETS)) { - if (baseKey === fromBase) continue; - - try { - let value = formatBigIntToBase(decimalValue, baseKey); - - description += `**${name} (${baseKey}):** \`${prefix}${value}\`\n`; - } catch (error) { - description += `**${name} (${baseKey}):** *Too large to convert*\n`; - } - } - - const embed = successEmbed( - 'šŸ”„ Base Conversion Results', - description - ); - embed.setColor(getColor('primary')); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/calculate.js b/src/commands/Tools/calculate.js deleted file mode 100644 index 233b88ab8f..0000000000 --- a/src/commands/Tools/calculate.js +++ /dev/null @@ -1,335 +0,0 @@ -import { SlashCommandBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { evaluateMathExpression } from '../../utils/safeMathParser.js'; - -const calculationContexts = new Map(); - -function evaluate(expression) { - return evaluateMathExpression(expression); -} - -const calculationHistory = new Map(); -const MAX_HISTORY = 5; - -export { calculationContexts }; - -export default { - data: new SlashCommandBuilder() - .setName("calculate") - .setDescription("Evaluate a mathematical expression") - .addStringOption((option) => - option - .setName("expression") - .setDescription( - "The mathematical expression to evaluate (e.g., 2+2*3, sin(45 deg), 16^0.5)", - ) - .setRequired(true), - ), - - 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; - } - - const expression = interaction.options.getString("expression"); - - if ( - !/^[0-9+\-*/.()^%! ,<>=&|~?:\[\]{}a-zāˆšĻ€āˆžĀ°]+$/i.test(expression) - ) { - return await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: '**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' - }); - } - - const dangerousPatterns = [ - /\b(?:import|require|process|fs|child_process|exec|eval|Function|setTimeout|setInterval|new\s+Function)\s*\(/i, - /`/g, - /\$\{.*\}/, - /\b(?:localStorage|document|window|fetch|XMLHttpRequest)\b/, - /\b(?:while|for)\s*\([^)]*\)\s*\{/, - /\b(?:function\*|yield|await|async)\b/, - ]; - - for (const pattern of dangerousPatterns) { - if (pattern.test(expression)) { - return await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: '**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.' - }); - } - } - - let result; - try { - result = evaluate(expression); - - let formattedResult; - if (typeof result === "number") { - formattedResult = result.toLocaleString("en-US", { - maximumFractionDigits: 10, - }); - - if ( - Math.abs(result) > 0 && - (Math.abs(result) >= 1e10 || Math.abs(result) < 1e-3) - ) { - formattedResult = result.toExponential(6); - } - } else if (typeof result === "boolean") { - formattedResult = result ? "true" : "false"; - } else if (result === null || result === undefined) { - formattedResult = "No result"; - } else if ( - Array.isArray(result) || - typeof result === "object" - ) { - formattedResult = - "```json\n" + JSON.stringify(result, null, 2) + "\n```"; - } else { - formattedResult = String(result); - } - - const userId = interaction.user.id; - if (!calculationHistory.has(userId)) { - calculationHistory.set(userId, []); - } - - const history = calculationHistory.get(userId); - history.unshift({ - expression, - result: formattedResult, - timestamp: Date.now(), - }); - - if (history.length > MAX_HISTORY) { - history.pop(); - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_add`) - .setLabel("+") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_subtract`) - .setLabel("-") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_multiply`) - .setLabel("Ɨ") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_divide`) - .setLabel("Ć·") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId(`calc_${interaction.id}_history`) - .setLabel("History") - .setStyle(ButtonStyle.Secondary), - ); - - const embed = successEmbed( - "🧮 Calculation Result", - `**Expression:** \`${expression.replace(/`/g, "\`")}\`\n` + - `**Result:** \`${formattedResult}\`\n\n` + - `*Use the buttons below to perform operations with the result.*`, - ); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - components: [row], - }); - - const filter = (i) => - i.customId.startsWith(`calc_${interaction.id}`) && - i.user.id === interaction.user.id; - const BUTTON_TIMEOUT = 300000; - const collector = - interaction.channel.createMessageComponentCollector({ - filter, - time: BUTTON_TIMEOUT, - }); - - collector.on("collect", async (i) => { - try { - const operation = i.customId.split("_")[2]; - - if (operation === "history") { - if (!i.deferred && !i.replied) { - await i.deferUpdate().catch(console.error); - } - - const userHistory = - calculationHistory.get(userId) || []; - - if (userHistory.length === 0) { - await i.followUp({ - content: "No calculation history found.", - flags: ["Ephemeral"], - }); - return; - } - - const historyText = userHistory - .map( - (item, index) => - `${index + 1}. **${item.expression}** = \`${item.result}\`\n` + - ``, - ) - .join("\n\n"); - - await i.followUp({ - content: `šŸ“œ **Your Calculation History**\n\n${historyText}`, - flags: ["Ephemeral"], - }); - return; - } - - let operator = ""; - - switch (operation) { - case "add": - operator = "+"; - break; - case "subtract": - operator = "-"; - break; - case "multiply": - operator = "*"; - break; - case "divide": - operator = "/"; - break; - } - - 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:${operation}`, - title: `Enter a number to ${operation}`, - components: [ - { - type: 1, - components: [ - { - type: 4, - customId: `operand:${contextKey}`, - label: `Number to ${operator} with ${formattedResult}`, - placeholder: "Enter a number...", - style: 1, - required: true, - maxLength: 50, - }, - ], - }, - ], - }); - } catch (modalError) { - 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; - } - - } catch (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); - } - } - }); - - collector.on("end", (collected, reason) => { - if (reason === "timeout") { - const disabledRow = - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId( - `calc_${interaction.id}_expired`, - ) - .setLabel("Calculator Expired") - .setStyle(ButtonStyle.Secondary) - .setDisabled(true), - ); - - interaction - .editReply({ - components: [disabledRow], - content: - "ā±ļø This calculator has expired. Use the command again to perform more calculations.", - }) - .catch(console.error); - } else { - const disabledRow = ActionRowBuilder.from( - row, - ).setComponents( - row.components.map((component) => - ButtonBuilder.from(component).setDisabled(true), - ), - ); - - interaction - .editReply({ components: [disabledRow] }) - .catch(console.error); - } - }); - } catch (error) { - logger.error('Calculation error:', error); - - let errorMessage = 'Failed to evaluate the expression.'; - - if (error.message.includes('Unexpected type')) { - errorMessage += - 'The expression contains an unsupported operation or function.'; - } else if (error.message.includes('Undefined symbol')) { - errorMessage += - 'The expression contains an undefined variable or function.'; - } else if (error.message.includes('Brackets not balanced')) { - errorMessage += 'The expression has unbalanced brackets.'; - } else if ( - error.message.includes('Unexpected operator') || - error.message.includes('Unexpected character') - ) { - errorMessage += - 'The expression contains an invalid operator or character.'; - } else { - errorMessage += 'Please check the syntax and try again.'; - } - - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: errorMessage, - }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/countdown.js b/src/commands/Tools/countdown.js deleted file mode 100644 index d7561ddbf3..0000000000 --- a/src/commands/Tools/countdown.js +++ /dev/null @@ -1,96 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { createControlButtons, formatTime, startCountdown } from '../../handlers/countdownButtons.js'; - -const activeCountdowns = new Map(); - -export { activeCountdowns }; - -export default { - data: new SlashCommandBuilder() - .setName("countdown") - .setDescription("Start a countdown timer") - .addIntegerOption((option) => - option - .setName("minutes") - .setDescription("Number of minutes to count down (0-1440)") - .setMinValue(0) - .setMaxValue(1440) - .setRequired(false), - ) - .addIntegerOption((option) => - option - .setName("seconds") - .setDescription("Number of seconds to count down (0-59)") - .setMinValue(0) - .setMaxValue(59) - .setRequired(false), - ) - .addStringOption((option) => - option - .setName("title") - .setDescription("Optional title for the countdown") - .setRequired(false), - ), - - 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; - } - - const minutes = interaction.options.getInteger("minutes") || 0; - const seconds = interaction.options.getInteger("seconds") || 0; - const title = interaction.options.getString("title") || "Countdown Timer"; - - const totalSeconds = minutes * 60 + seconds; - - if (totalSeconds <= 0) { - throw new Error("Please specify a duration of at least 1 second."); - } - - if (totalSeconds > 86400) { - throw new Error("Countdown cannot be longer than 24 hours."); - } - - const endTime = Date.now() + totalSeconds * 1000; - const countdownId = `${interaction.channelId}-${Date.now()}`; - - const row = createControlButtons(countdownId); - - const initialEmbed = successEmbed( - `ā±ļø ${title}`, - `Time remaining: **${formatTime(totalSeconds)}**`, - ); - - const message = await interaction.channel.send({ - embeds: [initialEmbed], - components: [row], - }); - - const countdownData = { - message, - endTime, - remainingTime: totalSeconds * 1000, - isPaused: false, - title, - lastUpdate: Date.now(), - interval: null, - }; - - activeCountdowns.set(countdownId, countdownData); - startCountdown(countdownId, countdownData, activeCountdowns); - - await InteractionHelper.safeEditReply(interaction, { - content: "āœ… Countdown started!", - flags: MessageFlags.Ephemeral, - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/embedbuilder.js b/src/commands/Tools/embedbuilder.js deleted file mode 100644 index 3da94f3dc1..0000000000 --- a/src/commands/Tools/embedbuilder.js +++ /dev/null @@ -1,1158 +0,0 @@ -import { - SlashCommandBuilder, - PermissionFlagsBits, - ActionRowBuilder, - StringSelectMenuBuilder, - StringSelectMenuOptionBuilder, - ChannelSelectMenuBuilder, - ModalBuilder, - TextInputBuilder, - TextInputStyle, - ButtonBuilder, - ButtonStyle, - MessageFlags, - ComponentType, - ChannelType, - EmbedBuilder, - LabelBuilder, - RadioGroupBuilder, -} from 'discord.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { successEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { TitanBotError, replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; - -const MAX_FIELDS = 25; -const IDLE_TIMEOUT = 900_000; - -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: '' }, -]; - -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); -} - -function resolveEmbedColor(value) { - try { - const resolved = getColor(value || 'primary'); - if (typeof resolved === 'number' && Number.isFinite(resolved) && resolved >= 0 && resolved <= 0xffffff) { - return resolved; - } - } catch { - // ignore invalid value and fall through to primary - } - return getColor('primary'); -} - -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)); - - embed.setColor(resolveEmbedColor(state.color)); - - 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)); - - if ( - !state.title && - !state.description && - state.fields.length === 0 && - !state.author?.name - ) { - embed.setDescription('*(Empty — use the menu below to add content)*'); - } - - return embed; -} - -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' }); -} - -function buildMainMenu(state) { - const primaryRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('eb_main_edit_content') - .setLabel('Edit Content') - .setStyle(ButtonStyle.Primary) - .setEmoji('āœļø'), - new ButtonBuilder() - .setCustomId('eb_main_set_color') - .setLabel('Set Color') - .setStyle(ButtonStyle.Secondary) - .setEmoji('šŸŽØ'), - new ButtonBuilder() - .setCustomId('eb_main_set_images') - .setLabel('Set Images') - .setStyle(ButtonStyle.Secondary) - .setEmoji('šŸ–¼ļø'), - new ButtonBuilder() - .setCustomId('eb_main_post_embed') - .setLabel('Post Embed') - .setStyle(ButtonStyle.Success) - .setEmoji('šŸ“¤'), - ); - - const secondaryRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('eb_main_add_field') - .setLabel(`Add Field (${state.fields.length}/${MAX_FIELDS})`) - .setStyle(ButtonStyle.Primary) - .setEmoji('āž•'), - new ButtonBuilder() - .setCustomId('eb_main_edit_field') - .setLabel('Edit Field') - .setStyle(ButtonStyle.Secondary) - .setEmoji('šŸ“') - .setDisabled(state.fields.length === 0), - new ButtonBuilder() - .setCustomId('eb_main_remove_field') - .setLabel('Remove Field') - .setStyle(ButtonStyle.Danger) - .setEmoji('āž–') - .setDisabled(state.fields.length === 0), - new ButtonBuilder() - .setCustomId('eb_main_toggle_timestamp') - .setLabel(state.timestamp ? 'Disable Timestamp' : 'Enable Timestamp') - .setStyle(ButtonStyle.Secondary) - .setEmoji('šŸ•'), - ); - - const tertiaryRow = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('eb_main_reorder_fields') - .setLabel('Reorder Fields') - .setStyle(ButtonStyle.Secondary) - .setEmoji('ā†•ļø') - .setDisabled(state.fields.length < 2), - new ButtonBuilder() - .setCustomId('eb_main_json_export') - .setLabel('JSON / Raw Data') - .setStyle(ButtonStyle.Secondary) - .setEmoji('šŸ“‹'), - new ButtonBuilder() - .setCustomId('eb_main_reset_all') - .setLabel('Reset Everything') - .setStyle(ButtonStyle.Danger) - .setEmoji('šŸ—‘ļø'), - ); - - return [primaryRow, secondaryRow, tertiaryRow]; -} - -async function refreshDashboard(interaction, state) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [buildPreviewEmbed(state), buildDashboardEmbed(state)], - components: buildMainMenu(state), - }); -} - -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...'), - ), - ); - - const shown = await InteractionHelper.safeShowModal(selectInteraction, modal); - if (!shown) return; - - 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; - - 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 => { - try { - 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), - ), - ); - - const shown = await InteractionHelper.safeShowModal(colorInter, hexModal); - if (!shown) return; - - 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 replyUserError(hexSubmit, { - type: ErrorTypes.USER_INPUT, - message: `\`${hex}\` is not a valid hex color. Use the format \`#RRGGBB\` (e.g. \`#5865F2\`).`, - }); - return; - } - - state.color = hex; - await hexSubmit.deferUpdate().catch(() => {}); - } else { - state.color = picked; - await colorInter.deferUpdate().catch(() => {}); - } - - await refreshDashboard(rootInteraction, state); - } catch (error) { - logger.warn('Embed builder color picker interaction failed:', error.message); - } - }); -} - -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'), - ), - ); - - const shown = await InteractionHelper.safeShowModal(selectInteraction, modal); - if (!shown) return; - - 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 replyUserError(submitted, { - type: ErrorTypes.USER_INPUT, - message: 'Author icon URL must be a valid `https://` URL.', - }); - return; - } - if (url && !isValidUrl(url)) { - await replyUserError(submitted, { - type: ErrorTypes.USER_INPUT, - message: 'Author link URL must be a valid `https://` URL.', - }); - 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'), - ), - ); - - const shown = await InteractionHelper.safeShowModal(selectInteraction, modal); - if (!shown) return; - - 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 replyUserError(submitted, { - type: ErrorTypes.USER_INPUT, - message: 'Footer icon URL must be a valid `https://` URL.', - }); - 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 => { - try { - 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'), - ), - ); - - const shown = await InteractionHelper.safeShowModal(imgInter, urlModal); - if (!shown) return; - - 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 replyUserError(submitted, { - type: ErrorTypes.USER_INPUT, - message: 'Image URL must be a valid `https://` link to a publicly accessible image.', - }); - return; - } - - if (isThumb) state.thumbnail = url; - else state.image = url; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); - } catch (error) { - logger.warn('Embed builder image picker interaction failed:', error.message); - } - }); -} - -async function handleAddField(selectInteraction, rootInteraction, state) { - if (state.fields.length >= MAX_FIELDS) { - await selectInteraction.deferUpdate(); - await replyUserError(selectInteraction, { - type: ErrorTypes.VALIDATION, - message: `Embeds can have a maximum of ${MAX_FIELDS} fields.`, - }); - return; - } - - const modal = new ModalBuilder() - .setCustomId('eb_add_field') - .setTitle('Add Field'); - - const fieldNameLabel = new LabelBuilder() - .setLabel('Field Name (max 256 characters)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_name') - .setStyle(TextInputStyle.Short) - .setMaxLength(256) - .setRequired(true) - .setPlaceholder('Field Title'), - ); - - const fieldValueLabel = new LabelBuilder() - .setLabel('Field Value (max 1024 characters)') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_value') - .setStyle(TextInputStyle.Paragraph) - .setMaxLength(1024) - .setRequired(true) - .setPlaceholder('Field content goes here...'), - ); - - const inlineRadio = new RadioGroupBuilder() - .setCustomId('field_inline') - .setRequired(false) - .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, - ]); - - const inlineLabel = new LabelBuilder() - .setLabel('Display inline?') - .setRadioGroupComponent(inlineRadio); - - modal.addLabelComponents(fieldNameLabel, fieldValueLabel, inlineLabel); - - const shown = await InteractionHelper.safeShowModal(selectInteraction, modal); - if (!shown) return; - - 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 inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; - - 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 => { - try { - 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}`); - - const editNameLabel = new LabelBuilder() - .setLabel('Field Name') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_name') - .setStyle(TextInputStyle.Short) - .setValue(field.name) - .setMaxLength(256) - .setRequired(true), - ); - - const editValueLabel = new LabelBuilder() - .setLabel('Field Value') - .setTextInputComponent( - new TextInputBuilder() - .setCustomId('field_value') - .setStyle(TextInputStyle.Paragraph) - .setValue(field.value.substring(0, 4000)) - .setMaxLength(1024) - .setRequired(true), - ); - - const editInlineRadio = new RadioGroupBuilder() - .setCustomId('field_inline') - .setRequired(false) - .addOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes' }, - ]); - - if (field.inline) { - editInlineRadio.setOptions([ - { label: 'No — full width', value: 'no' }, - { label: 'Yes — side-by-side', value: 'yes', default: true }, - ]); - } - - const editInlineLabel = new LabelBuilder() - .setLabel('Display inline?') - .setRadioGroupComponent(editInlineRadio); - - modal.addLabelComponents(editNameLabel, editValueLabel, editInlineLabel); - - const shown = await InteractionHelper.safeShowModal(pickInter, modal); - if (!shown) return; - - 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 inline = submitted.fields.getRadioGroup('field_inline') === 'yes'; - - state.fields[idx] = { name, value, inline }; - - await submitted.deferUpdate().catch(() => {}); - await refreshDashboard(rootInteraction, state); - } catch (error) { - logger.warn('Embed builder field edit interaction failed:', error.message); - } - }); -} - -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 replyUserError(selectInteraction, { - type: ErrorTypes.VALIDATION, - message: 'Add at least a title, description, or field before posting.', - }); - 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 replyUserError(chanInter, { - type: ErrorTypes.USER_INPUT, - message: 'Could not resolve the selected channel.', - }); - return; - } - - const perms = channel.permissionsFor(guild.members.me); - if (!perms?.has([PermissionFlagsBits.SendMessages, PermissionFlagsBits.EmbedLinks])) { - await replyUserError(chanInter, { - type: ErrorTypes.PERMISSION, - message: `I need **Send Messages** and **Embed Links** permissions in ${channel} to post there.`, - }); - return; - } - - const finalEmbed = buildPreviewEmbed(state); - - 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, - }); - } -} - -export default { - slashOnly: true, - 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; - - 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.Button, - filter: i => - i.user.id === interaction.user.id && i.customId.startsWith('eb_main_'), - time: IDLE_TIMEOUT, - }); - - collector.on('collect', async ci => { - try { - switch (ci.customId) { - case 'eb_main_edit_content': - await handleEditContent(ci, interaction, state); - break; - case 'eb_main_set_color': - await handleSetColor(ci, interaction, state); - break; - case 'eb_main_set_images': - await handleSetImages(ci, interaction, state); - break; - case 'eb_main_post_embed': - await handlePostEmbed(ci, interaction, state, guild); - break; - case 'eb_main_add_field': - await handleAddField(ci, interaction, state); - break; - case 'eb_main_edit_field': - await handleEditField(ci, interaction, state); - break; - case 'eb_main_remove_field': - await handleRemoveField(ci, interaction, state); - break; - case 'eb_main_reorder_fields': - await handleReorderFields(ci, interaction, state); - break; - case 'eb_main_toggle_timestamp': - state.timestamp = !state.timestamp; - await ci.deferUpdate(); - await refreshDashboard(interaction, state); - break; - case 'eb_main_json_export': - await handleJsonExport(ci, interaction, state); - break; - case 'eb_main_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 replyUserError(ci, { - type: ErrorTypes.UNKNOWN, - message: msg, - }).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.', - ); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/generatepassword.js b/src/commands/Tools/generatepassword.js deleted file mode 100644 index 51628bfed0..0000000000 --- a/src/commands/Tools/generatepassword.js +++ /dev/null @@ -1,146 +0,0 @@ -import { webcrypto as crypto } from 'node:crypto'; -import { getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('generatepassword') - .setDescription('Generate a strong, random password') - .addIntegerOption(option => - option.setName('length') - .setDescription('Password length (default: 16, max: 50)') - .setMinValue(8) - .setMaxValue(50) - .setRequired(false)) - .addBooleanOption(option => - option.setName('uppercase') - .setDescription('Include uppercase letters (A-Z)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('numbers') - .setDescription('Include numbers (0-9)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('symbols') - .setDescription('Include symbols (!@#$%^&*)') - .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; - } - - const length = interaction.options.getInteger('length') || 16; - const includeUppercase = interaction.options.getBoolean('uppercase') ?? true; - const includeNumbers = interaction.options.getBoolean('numbers') ?? true; - const includeSymbols = interaction.options.getBoolean('symbols') ?? true; - - if (length < 8 || length > 50) { - await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: `Password must be 8-50 characters. You provided: ${length}` }); - return; - } - - const lowercase = 'abcdefghijklmnopqrstuvwxyz'; - const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const numbers = '0123456789'; - const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'; - - let chars = lowercase; - if (includeUppercase) chars += uppercase; - if (includeNumbers) chars += numbers; - if (includeSymbols) chars += symbols; - - let password = ''; - const randomValues = new Uint32Array(length); - crypto.getRandomValues(randomValues); - - for (let i = 0; i < length; i++) { - const randomIndex = randomValues[i] % chars.length; - password += chars[randomIndex]; - } - - if (includeUppercase && !/[A-Z]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomUpper = uppercase[Math.floor(Math.random() * uppercase.length)]; - password = password.substring(0, randomIndex) + randomUpper + password.substring(randomIndex + 1); - } - - if (includeNumbers && !/[0-9]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomNumber = numbers[Math.floor(Math.random() * numbers.length)]; - password = password.substring(0, randomIndex) + randomNumber + password.substring(randomIndex + 1); - } - - if (includeSymbols && !/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(password)) { - const randomIndex = Math.floor(Math.random() * length); - const randomSymbol = symbols[Math.floor(Math.random() * symbols.length)]; - password = password.substring(0, randomIndex) + randomSymbol + password.substring(randomIndex + 1); - } - - let strength = 'Weak'; - let strengthEmoji = 'šŸ”“'; - let strengthColor = getColor('error'); - - const hasLower = /[a-z]/.test(password); - const hasUpper = /[A-Z]/.test(password); - const hasNumber = /[0-9]/.test(password); - const hasSymbol = /[^a-zA-Z0-9]/.test(password); - - const uniqueChars = new Set(password).size; - const uniqueRatio = uniqueChars / password.length; - - let score = 0; - score += password.length * 4; - score += (password.length - (password.match(/[a-z]/g) || []).length) * 2; - score += (password.length - (password.match(/[A-Z]/g) || []).length) * 2; - score += (password.match(/[0-9]/g) || []).length * 4; - score += (password.match(/[^a-zA-Z0-9]/g) || []).length * 6; - - if (uniqueRatio < 0.5) score *= 0.7; - if (hasLower && hasUpper) score *= 1.2; - if (hasNumber) score *= 1.2; - if (hasSymbol) score *= 1.3; - - if (score > 80) { - strength = 'Very Strong'; - strengthEmoji = '🟢'; - strengthColor = getColor('success'); - } else if (score > 60) { - strength = 'Strong'; - strengthEmoji = '🟢'; - strengthColor = getColor('success'); - } else if (score > 40) { - strength = 'Good'; - strengthEmoji = '🟔'; - strengthColor = getColor('warning'); - } else if (score > 20) { - strength = 'Weak'; - strengthEmoji = '🟠'; - strengthColor = getColor('warning'); - } - - const embed = successEmbed( - 'šŸ”‘ Generated Password', - `**Password:** ||\`${password}\`||\n` + - `**Length:** ${password.length} characters\n` + - `**Strength:** ${strengthEmoji} ${strength}\n` + - `**Contains:** ${hasLower ? 'Lowercase' : ''}${hasUpper ? ', Uppercase' : ''}${hasNumber ? ', Numbers' : ''}${hasSymbol ? ', Symbols' : ''}` - ).setColor(strengthColor); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/hexcolor.js b/src/commands/Tools/hexcolor.js deleted file mode 100644 index 36736e3a6e..0000000000 --- a/src/commands/Tools/hexcolor.js +++ /dev/null @@ -1,140 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { getColor } from '../../config/bot.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -export default { - data: new SlashCommandBuilder() - .setName('hexcolor') - .setDescription('Generate a random hex color with preview') - .addStringOption(option => - option.setName('color') - .setDescription('Specific hex color (e.g., #FF5733 or FF5733)') - .setRequired(false)), - - async execute(interaction) { - 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 await replyUserError(interaction, { type: ErrorTypes.VALIDATION, message: '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(); - } - - 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 } - } - ); - }, -}; - -function rgbToHsl(r, g, b) { - r /= 255, g /= 255, b /= 255; - const max = Math.max(r, g, b), min = Math.min(r, g, b); - let h, s, l = (max + min) / 2; - - if (max === min) { -h = s = 0; - } else { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - switch (max) { - case r: h = (g - b) / d + (g < b ? 6 : 0); break; - case g: h = (b - r) / d + 2; break; - case b: h = (r - g) / d + 4; break; - } - h /= 6; - } - - return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`; -} - -function getColorName(hex) { - const colors = { - '#FF0000': 'Red', - '#00FF00': 'Green', - '#0000FF': 'Blue', - '#FFFF00': 'Yellow', - '#FF00FF': 'Magenta', - '#00FFFF': 'Cyan', - '#000000': 'Black', - '#FFFFFF': 'White', - '#808080': 'Gray', - '#FFA500': 'Orange', - '#800080': 'Purple', - '#A52A2A': 'Brown', - '#FFC0CB': 'Pink', - '#008000': 'Dark Green', - '#000080': 'Navy', - '#FFD700': 'Gold', - '#C0C0C0': 'Silver', - '#FF6347': 'Tomato', - '#40E0D0': 'Turquoise', - '#E6E6FA': 'Lavender' - }; - - if (colors[hex.toUpperCase()]) { - return colors[hex.toUpperCase()]; - } - - const hexValue = parseInt(hex.replace('#', ''), 16); - let closestColor = ''; - let minDistance = Infinity; - - for (const [colorHex, name] of Object.entries(colors)) { - const colorValue = parseInt(colorHex.replace('#', ''), 16); - const distance = Math.abs(hexValue - colorValue); - - if (distance < minDistance) { - minDistance = distance; - closestColor = name; - } - } - - return minDistance < 1000000 ? `Close to ${closestColor}` : null; -} \ No newline at end of file diff --git a/src/commands/Tools/poll.js b/src/commands/Tools/poll.js deleted file mode 100644 index 772f1500c5..0000000000 --- a/src/commands/Tools/poll.js +++ /dev/null @@ -1,112 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.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 { - data: new SlashCommandBuilder() - .setName('poll') - .setDescription('Create a simple poll with up to 10 options') - .addStringOption(option => - option.setName('question') - .setDescription('The poll question') - .setRequired(true)) - .addStringOption(option => - option.setName('option1') - .setDescription('First option') - .setRequired(true)) - .addStringOption(option => - option.setName('option2') - .setDescription('Second option') - .setRequired(true)) - .addStringOption(option => - option.setName('option3') - .setDescription('Third option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option4') - .setDescription('Fourth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option5') - .setDescription('Fifth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option6') - .setDescription('Sixth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option7') - .setDescription('Seventh option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option8') - .setDescription('Eighth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option9') - .setDescription('Ninth option (optional)') - .setRequired(false)) - .addStringOption(option => - option.setName('option10') - .setDescription('Tenth option (optional)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('anonymous') - .setDescription('Make the poll anonymous (default: false)') - .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; - } - - const question = interaction.options.getString('question'); - const isAnonymous = interaction.options.getBoolean('anonymous') || false; - - const options = []; - for (let i = 1; i <= MAX_OPTIONS; i++) { - const option = interaction.options.getString(`option${i}`); - if (option) options.push(option); - } - - if (options.length < 2) { - throw new Error("You must provide at least 2 options for the poll."); - } - - let description = `**${question}**\n\n`; - options.forEach((option, index) => { - description += `${EMOJIS[index]} ${option}\n`; - }); - - if (isAnonymous) { - description += '\n*This is an anonymous poll. Votes are not tracked to users.*'; - } else { - description += '\n*React with the emoji to vote!*'; - } - - const embed = successEmbed( - `šŸ“Š ${isAnonymous ? 'Anonymous ' : ''}Poll`, - description - ); - - const message = await interaction.channel.send({ embeds: [embed] }); - - for (let i = 0; i < options.length; i++) { - await message.react(EMOJIS[i]); - await new Promise(resolve => setTimeout(resolve, 500)); - } - - await InteractionHelper.safeEditReply(interaction, { - content: 'āœ… Poll created successfully!', - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/randomuser.js b/src/commands/Tools/randomuser.js deleted file mode 100644 index 4dc15a6fd1..0000000000 --- a/src/commands/Tools/randomuser.js +++ /dev/null @@ -1,193 +0,0 @@ -import { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName('randomuser') - .setDescription('Select a random user from the server') - .addRoleOption(option => - option.setName('role') - .setDescription('Limit selection to users with this role') - .setRequired(false)) - .addBooleanOption(option => - option.setName('bots') - .setDescription('Include bots in the selection (default: false)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('online') - .setDescription('Only select from online users (default: false)') - .setRequired(false)) - .addBooleanOption(option => - option.setName('mention') - .setDescription('Mention the selected user (default: false)') - .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; - } - - if (!interaction.guild) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'This command can only be used in a server/guild.', - }); - } - - const role = interaction.options.getRole('role'); - const includeBots = interaction.options.getBoolean('bots') || false; - const onlineOnly = interaction.options.getBoolean('online') || false; - const shouldMention = interaction.options.getBoolean('mention') || false; - - let members = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; - }); - - let memberArray = Array.from(members.values()); - - if (!includeBots) { - memberArray = memberArray.filter(member => !member.user.bot); - } - - if (memberArray.length === 0) { - 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 replyUserError(interaction, { - type: ErrorTypes.USER_INPUT, - message: errorMessage + '\n\nTry adjusting your filters.', - }); - } - - const randomIndex = Math.floor(Math.random() * memberArray.length); - const selectedMember = memberArray[randomIndex]; - - const user = selectedMember.user; - const joinDate = selectedMember.joinedAt; - const roles = selectedMember.roles.cache - .filter(role => role.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(role => role.toString()) - .slice(0, 10); - - const embed = successEmbed( - 'šŸŽ² Random User Selected', - shouldMention ? `${selectedMember}` : `**${user.username}**` - ) - .setThumbnail(user.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: user.username, inline: true }, - { 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('primary'); - - const row = new ActionRowBuilder() - .addComponents( - new ButtonBuilder() - .setCustomId(`randomuser_${interaction.user.id}_again`) - .setLabel('šŸŽ² Pick Another User') - .setStyle(ButtonStyle.Primary) - ); - - const response = await interaction.editReply({ - content: shouldMention ? `${selectedMember}, you've been chosen!` : null, - embeds: [embed], - components: [row], - allowedMentions: { users: shouldMention ? [user.id] : [] } - }); - - const filter = (i) => i.customId === `randomuser_${interaction.user.id}_again` && i.user.id === interaction.user.id; - const collector = response.createMessageComponentCollector({ filter, time: 300000 }); - - collector.on('collect', async (i) => { - try { - let newMembers = interaction.guild.members.cache.filter(member => { - if (member.user.bot && !includeBots) return false; - - if (onlineOnly && member.presence?.status === 'offline') return false; - - if (role && !member.roles.cache.has(role.id)) return false; - - return true; - }); - - let newMemberArray = Array.from(newMembers.values()); - - if (!includeBots) { - newMemberArray = newMemberArray.filter(member => !member.user.bot); - } - - if (newMemberArray.length === 0) { - await replyUserError(i, { - type: ErrorTypes.USER_INPUT, - message: 'No users found matching the criteria.', - }); - return; - } - - const newRandomIndex = Math.floor(Math.random() * newMemberArray.length); - const newSelectedMember = newMemberArray[newRandomIndex]; - const newUser = newSelectedMember.user; - - const newRoles = newSelectedMember.roles.cache - .filter(r => r.id !== interaction.guild.id) - .sort((a, b) => b.position - a.position) - .map(r => r.toString()) - .slice(0, 10); - - const newEmbed = successEmbed( - 'šŸŽ² Random User Selected', - shouldMention ? `${newSelectedMember}` : `**${newUser.username}**` - ) - .setThumbnail(newUser.displayAvatarURL({ dynamic: true, size: 256 })) - .addFields( - { name: 'Username', value: newUser.username, inline: true }, - { name: 'Bot', value: newUser.bot ? 'Yes' : 'No', inline: true }, - { name: `Roles (${newRoles.length})`, value: newRoles.length > 0 ? newRoles.slice(0, 5).join('') + (newRoles.length > 5 ? `+${newRoles.length - 5} more` : '') : 'No roles', inline: false } - ) - .setColor(newSelectedMember.displayHexColor || '#3498db'); - - await i.update({ - content: shouldMention ? `${newSelectedMember}, you've been chosen!` : null, - embeds: [newEmbed], - components: [row], - allowedMentions: { users: shouldMention ? [newUser.id] : [] } - }); - - } catch (error) { - logger.error('Button interaction error:', error); - await i.reply({ - content: 'An error occurred while selecting another user.', - flags: ['Ephemeral'] - }); - } - }); - - collector.on('end', () => { - const disabledRow = ActionRowBuilder.from(row).setComponents( - ButtonBuilder.from(row.components[0]).setDisabled(true) - ); - - interaction.editReply({ components: [disabledRow] }).catch(console.error); - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/shorten.js b/src/commands/Tools/shorten.js deleted file mode 100644 index 4b5b9af5f5..0000000000 --- a/src/commands/Tools/shorten.js +++ /dev/null @@ -1,122 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { getColor } from '../../config/bot.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; - -export default { - data: new SlashCommandBuilder() - .setName("shorten") - .setDescription("Shorten a URL using is.gd") - .addStringOption(option => - option - .setName("url") - .setDescription("The URL to shorten") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("custom") - .setDescription("Custom URL ending (optional)") - .setRequired(false) - ) - .setDMPermission(false), - category: "Tools", - - async execute(interaction) { - const deferSuccess = await InteractionHelper.safeDefer(interaction, { - flags: MessageFlags.Ephemeral - }); - if (!deferSuccess) { - logger.warn(`Shorten interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'shorten' - }); - return; - } - - const url = interaction.options.getString("url"); - const custom = interaction.options.getString("custom"); - - try { - new URL(url); - } catch (e) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Invalid URL format. Include http:// or https://', - }); - } - - if (custom && !/^[a-zA-Z0-9_-]+$/.test(custom)) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Custom URL can only contain letters, numbers, underscores, and hyphens.', - }); - } - - let apiUrl = `https://is.gd/create.php?format=simple&url=${encodeURIComponent(url)}`; - if (custom) { - apiUrl += `&shorturl=${encodeURIComponent(custom)}`; - } - - 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.'; - return replyUserError(interaction, { - type: ErrorTypes.NETWORK, - message, - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - return replyUserError(interaction, { - type: ErrorTypes.UNKNOWN, - message: `Shortener service returned HTTP ${response.status}. Please try again later.`, - }); - } - - const shortUrl = await response.text(); - - try { - new URL(shortUrl); - } catch (e) { - if (shortUrl.includes("already exists")) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'That custom URL is already taken. Try a different one.', - }); - } else if (shortUrl.includes("invalid")) { - return replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Invalid URL. Include http:// or https://', - }); - } - return replyUserError(interaction, { - type: ErrorTypes.UNKNOWN, - message: `URL shortening failed: ${shortUrl}`, - }); - } - - const embed = successEmbed('URL Shortened', `Here's your shortened URL: ${shortUrl}`); - embed.setColor(getColor('success')); - await InteractionHelper.safeEditReply(interaction, { - embeds: [embed], - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/time.js b/src/commands/Tools/time.js deleted file mode 100644 index d26db76f23..0000000000 --- a/src/commands/Tools/time.js +++ /dev/null @@ -1,62 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - data: new SlashCommandBuilder() - .setName('time') - .setDescription('Get the current time in different timezones') - .addStringOption(option => - option.setName('timezone') - .setDescription('The timezone to display (e.g., UTC, America/New_York)') - .setRequired(false)), - - async execute(interaction) { - 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}`); - await replyUserError(interaction, { - type: ErrorTypes.VALIDATION, - message: 'Invalid timezone. Please use a valid timezone identifier (e.g., UTC, America/New_York, Europe/London)', - }); - 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 } - } - ); - }, -}; \ No newline at end of file diff --git a/src/commands/Tools/unixtime.js b/src/commands/Tools/unixtime.js deleted file mode 100644 index 7e8e7e7b91..0000000000 --- a/src/commands/Tools/unixtime.js +++ /dev/null @@ -1,38 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.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('unixtime') - .setDescription('Get the current Unix timestamp'), - - async execute(interaction) { - 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 } - } - ); - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/avatar.js b/src/commands/Utility/avatar.js deleted file mode 100644 index b9caf87daf..0000000000 --- a/src/commands/Utility/avatar.js +++ /dev/null @@ -1,35 +0,0 @@ -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() - .setName("avatar") - .setDescription("Display a user's avatar image") - .addUserOption((option) => - option - .setName("target") - .setDescription( - "The user whose avatar you want to see (defaults to you)", - ), - ), - - async execute(interaction) { - const user = interaction.options.getUser("target") || interaction.user; - const avatarUrl = user.displayAvatarURL({ size: 2048, dynamic: true }); - - const embed = createEmbed({ - title: `${user.username}'s Avatar`, - description: `[Download Link](${avatarUrl})` - }) - .setImage(avatarUrl); - - await InteractionHelper.safeReply(interaction, { embeds: [embed] }); - logger.info(`Avatar command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - } -}; \ No newline at end of file diff --git a/src/commands/Utility/firstmsg.js b/src/commands/Utility/firstmsg.js deleted file mode 100644 index 1624921387..0000000000 --- a/src/commands/Utility/firstmsg.js +++ /dev/null @@ -1,61 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed } from '../../utils/embeds.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import { logger } from '../../utils/logger.js'; -export default { - data: new SlashCommandBuilder() - .setName("firstmsg") - .setDescription("Get a link to the first message in this channel") - .setDMPermission(false) - .setDefaultMemberPermissions(PermissionFlagsBits.SendMessages), - category: "Utility", - - async execute(interaction, config, client) { - const deferSuccess = await InteractionHelper.safeDefer(interaction); - if (!deferSuccess) { - logger.warn(`FirstMsg interaction defer failed`, { - userId: interaction.user.id, - guildId: interaction.guildId, - commandName: 'firstmsg' - }); - return; - } - - const messages = await interaction.channel.messages.fetch({ - limit: 1, - after: '1', - cache: false - }); - - const firstMessage = messages.first(); - - if (!firstMessage) { - logger.info(`FirstMsg - no messages found in channel`, { - userId: interaction.user.id, - channelId: interaction.channelId, - guildId: interaction.guildId - }); - 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 InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "First Message in #" + interaction.channel.name, - `Message Link: ${messageLink}` - ), - ], - }); - - logger.info(`FirstMsg command executed`, { - userId: interaction.user.id, - channelId: interaction.channelId, - messageId: firstMessage.id, - guildId: interaction.guildId - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/modules/report.js b/src/commands/Utility/modules/report.js deleted file mode 100644 index 86b34b7773..0000000000 --- a/src/commands/Utility/modules/report.js +++ /dev/null @@ -1,64 +0,0 @@ -import { createEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig } from '../../../services/config/guildConfig.js'; -import { logEvent, EVENT_TYPES, resolveLogChannel } from '../../../services/loggingService.js'; -import { formatLogLine, resolveUserAuthor } from '../../../utils/logging/logEmbeds.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { replyUserError, ErrorTypes } 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 = resolveLogChannel(guildConfig, 'reports'); - - if (!reportChannelId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'The report channel has not been set up. Ask a moderator to use `/logging dashboard` or `/logging channel`.' }); - } - - const ownerMention = interaction.guild.ownerId - ? `<@${interaction.guild.ownerId}> New report!` - : 'New report!'; - - await logEvent({ - client, - guildId, - eventType: EVENT_TYPES.REPORT_FILE, - content: ownerMention, - data: { - title: 'User Report', - lines: [ - formatLogLine('Reported User', `${targetUser.tag} (\`${targetUser.id}\`)`), - formatLogLine('Reported By', `${interaction.user.tag} (\`${interaction.user.id}\`)`), - formatLogLine('Channel', interaction.channel.toString()), - ], - blockFields: [{ name: 'Reason', value: reason }], - author: await resolveUserAuthor(client, targetUser.id), - thumbnail: targetUser.displayAvatarURL(), - }, - }); - - 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, - }); - }, -}; diff --git a/src/commands/Utility/modules/report_setchannel.js b/src/commands/Utility/modules/report_setchannel.js deleted file mode 100644 index 0da04de754..0000000000 --- a/src/commands/Utility/modules/report_setchannel.js +++ /dev/null @@ -1,32 +0,0 @@ -import { PermissionsBitField } from 'discord.js'; -import { successEmbed } from '../../../utils/embeds.js'; -import { setLogChannel } from '../../../services/loggingService.js'; -import { InteractionHelper } from '../../../utils/interactionHelper.js'; -import { logger } from '../../../utils/logger.js'; - -import { replyUserError, ErrorTypes } from '../../../utils/errorHandler.js'; -export default { - async execute(interaction, config, client) { - if (!interaction.member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await replyUserError(interaction, { type: ErrorTypes.PERMISSION, message: 'You need **Manage Server** permissions to set the report channel.' }); - } - - const channel = interaction.options.getChannel('channel'); - const guildId = interaction.guildId; - - try { - await setLogChannel(client, guildId, 'reports', channel.id); - - return InteractionHelper.safeReply(interaction, { - embeds: [successEmbed( - 'Report Channel Set', - `All new reports will now be sent to ${channel}.\nYou can also manage this from \`/logging dashboard\`.`, - )], - ephemeral: true, - }); - } catch (error) { - logger.error('report_setchannel error:', error); - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Could not save the channel configuration.' }); - } - }, -}; diff --git a/src/commands/Utility/report.js b/src/commands/Utility/report.js deleted file mode 100644 index 561aaf983c..0000000000 --- a/src/commands/Utility/report.js +++ /dev/null @@ -1,58 +0,0 @@ -import { SlashCommandBuilder, ChannelType } from 'discord.js'; -import { replyUserError, ErrorTypes } 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 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), - ), - ) - .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) { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === 'file') { - return await report.execute(interaction, config, client); - } - - if (subcommand === 'setchannel') { - return await reportSetchannel.execute(interaction, config, client); - } - - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Unknown subcommand.' }); - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/serverinfo.js b/src/commands/Utility/serverinfo.js deleted file mode 100644 index fd417d6845..0000000000 --- a/src/commands/Utility/serverinfo.js +++ /dev/null @@ -1,58 +0,0 @@ -import { SlashCommandBuilder } 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() - .setName("serverinfo") - .setDescription("Get detailed information about the server"), - - async execute(interaction) { - 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(); - - const createdTimestamp = Math.floor(guild.createdAt.getTime() / 1000); - - const embed = createEmbed({ title: `Server Info: ${guild.name}`, description: `Server ID: ${guild.id}` }) - .setThumbnail(guild.iconURL({ size: 256 })) - .addFields( - { name: "Owner", value: owner.user.tag, inline: true }, - { name: "Members", value: `${guild.memberCount}`, inline: true }, - { - name: "Channels", - value: `${guild.channels.cache.size}`, - inline: true, - }, - { name: "Roles", value: `${guild.roles.cache.size}`, inline: true }, - { - name: "Boosts", - value: `Level ${guild.premiumTier} (${guild.premiumSubscriptionCount})`, - inline: true, - }, - { - name: "Creation Date", - value: ``, - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`ServerInfo command executed`, { - userId: interaction.user.id, - guildId: guild.id, - guildName: guild.name, - memberCount: guild.memberCount - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/todo.js b/src/commands/Utility/todo.js deleted file mode 100644 index af82306340..0000000000 --- a/src/commands/Utility/todo.js +++ /dev/null @@ -1,501 +0,0 @@ -import { SlashCommandBuilder, PermissionFlagsBits, PermissionsBitField, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getFromDb, setInDb } from '../../utils/database.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { InteractionHelper } from '../../utils/interactionHelper.js'; -import crypto from 'crypto'; - -function generateShareId() { - return crypto.randomBytes(16).toString('hex'); -} - -export default { - data: new SlashCommandBuilder() - .setName("todo") - .setDescription("Manage your personal to-do list") - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a task to your to-do list") - .addStringOption(option => - option - .setName("task") - .setDescription("The task to add") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("list") - .setDescription("View your to-do list") - ) - .addSubcommand(subcommand => - subcommand - .setName("complete") - .setDescription("Mark a task as complete") - .addIntegerOption(option => - option - .setName("number") - .setDescription("The number of the task to complete") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove a task from your to-do list") - .addIntegerOption(option => - option - .setName("number") - .setDescription("The number of the task to remove") - .setRequired(true) - ) - ) - .addSubcommandGroup(group => - group - .setName("share") - .setDescription("Manage shared to-do lists") - .addSubcommand(subcommand => - subcommand - .setName("create") - .setDescription("Create a new shared to-do list") - .addStringOption(option => - option - .setName("name") - .setDescription("Name for the shared list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("add") - .setDescription("Add a member to a shared list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - .addUserOption(option => - option - .setName("user") - .setDescription("User to add to the list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("view") - .setDescription("View a shared to-do list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("addtask") - .setDescription("Add a task to a shared to-do list") - .addStringOption(option => - option - .setName("list_id") - .setDescription("ID of the shared list") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("task") - .setDescription("The task to add") - .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), - category: "Utility", - - async execute(interaction, config, client) { - const userId = interaction.user.id; - const subcommand = interaction.options.getSubcommand(); - const shareSubcommand = interaction.options.getSubcommandGroup() === 'share' ? interaction.options.getSubcommand() : null; - - async function getOrCreateSharedList(listId, creatorId = null, listName = null) { - const listKey = `shared_todo_${listId}`; - let listData = await getFromDb(listKey, null); - - if (!listData || (listData.ok === false && listData.error)) { - if (creatorId) { - listData = { - id: listId, - name: listName, - creatorId, - members: [creatorId], - tasks: [], - nextId: 1, - createdAt: new Date().toISOString() - }; - await setInDb(listKey, listData); - } else { - return null; - } - } - - if (listData) { - if (!Array.isArray(listData.tasks)) listData.tasks = []; - if (!listData.nextId) listData.nextId = 1; - if (!Array.isArray(listData.members)) listData.members = []; - } - - return listData; - } - - 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': { - const listName = interaction.options.getString('name'); - const listId = generateShareId(); - - await getOrCreateSharedList(listId, userId, listName); - - const userSharedLists = await getFromDb(`user_shared_lists_${userId}`, []); - const sharedListsArray = Array.isArray(userSharedLists) ? userSharedLists : []; - if (!sharedListsArray.includes(listId)) { - sharedListsArray.push(listId); - await setInDb(`user_shared_lists_${userId}`, sharedListsArray); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Shared List Created", - `Created shared list "${listName}" with ID: \`${listId}\`\n` + - `Use \`/todo share add list_id:${listId} user:@username\` to add members.` - ) - ] - }); - } - - case 'add': { - const listId = interaction.options.getString('list_id'); - const memberToAdd = interaction.options.getUser('user'); - - const listData = await getOrCreateSharedList(listId); - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (listData.creatorId !== userId) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Only the list creator can add members.' }); - } - - if (!listData.members.includes(memberToAdd.id)) { - listData.members.push(memberToAdd.id); - await setInDb(`shared_todo_${listId}`, listData); - - const memberLists = await getFromDb(`user_shared_lists_${memberToAdd.id}`, []); - const memberListsArray = Array.isArray(memberLists) ? memberLists : []; - if (!memberListsArray.includes(listId)) { - memberListsArray.push(listId); - await setInDb(`user_shared_lists_${memberToAdd.id}`, memberListsArray); - } - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Member Added', - `Added ${memberToAdd.username} to the shared list "${listData.name}"` - ) - ] - }); - } else { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'User is already a member of this list.' }); - } - } - - case 'view': { - const listId = interaction.options.getString('list_id'); - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } - - if (listData.tasks.length === 0) { - const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(memberId); - return member ? member.user.username : `<@${memberId}>`; - }).join(','); - - const owner = interaction.guild.members.cache.get(listData.creatorId); - const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - - return await InteractionHelper.safeEditReply(interaction, { - 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 = listData.tasks - .map(task => - `${task.completed ? 'āœ…' : 'šŸ“'} #${task.id} ${task.text}` + - `\`[${new Date(task.createdAt).toLocaleDateString()}]` + - (task.completed ? `• Completed by ${task.completedBy}` : '') + '`' - ) - .join('\n'); - - const memberList = listData.members.map(memberId => { - const member = interaction.guild.members.cache.get(memberId); - return member ? member.user.username : `<@${memberId}>`; - }).join(','); - - const owner = interaction.guild.members.cache.get(listData.creatorId); - const ownerName = owner ? owner.user.username : `<@${listData.creatorId}>`; - - const fullListDisplay = `šŸ“‹ **${listData.name}**\n\n` + - `šŸ‘‘ **Owner:** ${ownerName}\n` + - `šŸ‘„ **Members:** ${memberList}\n\n` + - `**Tasks:**\n${taskList}`; - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed(`Shared List (ID: \`${listId}\`)`, fullListDisplay) - ], - 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) - ) - ] - }); - } - - case 'addtask': { - const listId = interaction.options.getString('list_id'); - const taskText = interaction.options.getString('task'); - - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } - - const newTask = { - id: listData.nextId++, - text: taskText, - completed: false, - createdAt: new Date().toISOString(), - createdBy: userId - }; - - listData.tasks.push(newTask); - await setInDb(`shared_todo_${listId}`, listData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Added', `Added "${taskText}" to the shared list "${listData.name}"`) - ] - }); - } - - case 'remove': { - const listId = interaction.options.getString('list_id'); - const taskNumber = interaction.options.getInteger('number'); - - const listData = await getOrCreateSharedList(listId); - - if (!listData) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Shared list not found.' }); - } - - if (!listData.members.includes(userId)) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'You don\'t have access to this list.' }); - } - - const taskIndex = listData.tasks.findIndex(task => task.id === taskNumber); - if (taskIndex === -1) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); - } - - const [removedTask] = listData.tasks.splice(taskIndex, 1); - await setInDb(`shared_todo_${listId}`, listData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from the shared list "${listData.name}".`) - ] - }); - } - } - return; - } - - const dbKey = `todo_${userId}`; - - const userData = await getFromDb(dbKey, { - tasks: [], - nextId: 1 - }); - - if (!userData.tasks) userData.tasks = []; - if (!userData.nextId) userData.nextId = 1; - - switch (subcommand) { - case 'add': { - const taskText = interaction.options.getString('task'); - - const newTask = { - id: userData.nextId++, - text: taskText, - completed: false, - createdAt: new Date().toISOString() - }; - - userData.tasks.push(newTask); - await setInDb(dbKey, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed( - "Task Added", - `Added "${taskText}" to your to-do list.` - ), - ], - }); - } - - case 'list': { - if (userData.tasks.length === 0) { - return await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed('Your to-do list is empty!', "Your To-Do List")], - }); - } - - const taskList = userData.tasks - .map(task => - `${task.completed ? 'āœ…' : 'šŸ“'} #${task.id} ${task.text}` + - `\`[${new Date(task.createdAt).toLocaleDateString()}\`` - ) - .join('\n'); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Your To-Do List', taskList) - ], - }); - } - - case 'complete': { - const taskNumber = interaction.options.getInteger('number'); - const task = userData.tasks.find(t => t.id === taskNumber); - - if (!task) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); - } - - if (task.completed) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: `Task #${task.id} is already completed.` }); - } - - task.completed = true; - await setInDb(`todo_${userId}`, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Completed', `Marked "${task.text}" as complete!`) - ], - }); - } - - case 'remove': { - const taskNumber = interaction.options.getInteger('number'); - const taskIndex = userData.tasks.findIndex(t => t.id === taskNumber); - - if (taskIndex === -1) { - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Task not found.' }); - } - - const [removedTask] = userData.tasks.splice(taskIndex, 1); - await setInDb(`todo_${userId}`, userData); - - return await InteractionHelper.safeEditReply(interaction, { - embeds: [ - successEmbed('Task Removed', `Removed "${removedTask.text}" from your to-do list.`) - ], - }); - } - - default: - return await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'Invalid subcommand.' }); - } - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/userinfo.js b/src/commands/Utility/userinfo.js deleted file mode 100644 index 2455b0f4a9..0000000000 --- a/src/commands/Utility/userinfo.js +++ /dev/null @@ -1,72 +0,0 @@ -import { SlashCommandBuilder } 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() - .setName("userinfo") - .setDescription("Get detailed information about a user") - .addUserOption((option) => - option - .setName("target") - .setDescription("The user to inspect (defaults to you)"), - ), - - async execute(interaction) { - 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); - - const createdTimestamp = Math.floor(user.createdAt.getTime() / 1000); - const joinedTimestamp = member?.joinedAt ? Math.floor(member.joinedAt.getTime() / 1000) : null; - - const embed = createEmbed({ title: `User Info: ${user.username}` }) - .setThumbnail(user.displayAvatarURL({ size: 256 })) - .addFields( - { name: "ID", value: user.id, inline: true }, - { name: "Bot", value: user.bot ? "Yes" : "No", inline: true }, - { - name: "Roles", - value: - member && member.roles.cache.size > 1 - ? member.roles.cache - .map((r) => r.name) - .slice(0, 5) - .join(",") - : "None", - inline: true, - }, - { - name: "Account Created", - value: ``, - inline: false, - }, - { - name: "Joined Server", - value: joinedTimestamp ? `` : "Not in server", - inline: false, - }, - { - name: "Highest Role", - value: member?.roles?.highest?.name || "None", - inline: true, - }, - ); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`UserInfo command executed`, { - userId: interaction.user.id, - targetUserId: user.id, - guildId: interaction.guildId - }); - }, -}; \ No newline at end of file diff --git a/src/commands/Utility/weather.js b/src/commands/Utility/weather.js deleted file mode 100644 index a76c3eeb09..0000000000 --- a/src/commands/Utility/weather.js +++ /dev/null @@ -1,123 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js'; -import { createEmbed, successEmbed, infoEmbed, warningEmbed } from '../../utils/embeds.js'; -import { logger } from '../../utils/logger.js'; -import { replyUserError, ErrorTypes } 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"; - -export default { - data: new SlashCommandBuilder() - .setName("weather") - .setDescription("Get real-time weather information for a location") - .addStringOption((option) => - option - .setName("city") - .setDescription("The city name, e.g., 'London' or 'Tokyo'") - .setRequired(true), - ), - - async execute(interaction) { - 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( - `${GEOCODING_URL}?name=${encodeURIComponent(city)}`, - ); - const geoData = await geoResponse.json(); - - if (!geoData.results || geoData.results.length === 0) { - logger.info(`Weather command - city not found`, { - userId: interaction.user.id, - city: city, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.USER_INPUT, message: `Could not find a location for **${city}**. Please check the spelling.` }); - return; - } - - const { latitude, longitude, name, country } = geoData.results[0]; - const cityDisplay = name; - - const weatherResponse = await fetch( - `${WEATHER_URL}?latitude=${latitude}&longitude=${longitude}¤t_weather=true`, - ); - const weatherData = await weatherResponse.json(); - - if (weatherData.error) { - logger.error(`Weather API error`, { - error: weatherData.reason, - city: city, - userId: interaction.user.id, - guildId: interaction.guildId - }); - await replyUserError(interaction, { type: ErrorTypes.UNKNOWN, message: 'A weather service error occurred.' }); - return; - } - - const current = weatherData.current || weatherData.current_weather || {}; - const temperature = current.temperature != null ? Math.round(current.temperature) : "N/A"; - const humidity = current.relativehumidity ?? current.relative_humidity_2m ?? "N/A"; - const windSpeed = current.windspeed != null ? Math.round(current.windspeed) : "N/A"; - const weatherCode = current.weathercode ?? current.weather_code ?? null; - - const condition = getWeatherDescription(weatherCode); - - const embed = createEmbed({ title: `Weather in ${cityDisplay}, ${country}`, description: condition.description }) - .addFields( - { - name: "Temperature", - value: `${temperature}°C`, - inline: true, - }, - { - name: "Humidity", - value: `${humidity}%`, - inline: true, - }, - { - name: "Wind Speed", - value: `${windSpeed} km/h`, - inline: true, - }, - ) - .setFooter({ - text: `Latitude: ${latitude.toFixed(2)} | Longitude: ${longitude.toFixed(2)}`, - }); - - await InteractionHelper.safeEditReply(interaction, { embeds: [embed] }); - logger.info(`Weather command executed`, { - userId: interaction.user.id, - city: cityDisplay, - country: country, - temperature: temperature, - guildId: interaction.guildId - }); - }, -}; - -function getWeatherDescription(code) { - if (code >= 0 && code <= 3) { - return { description: "Clear sky / Partly cloudy", emoji: "" }; - } else if (code >= 45 && code <= 48) { - return { description: "Fog and Rime fog", emoji: "" }; - } else if (code >= 51 && code <= 67) { - return { description: "Drizzle or Rain", emoji: "" }; - } else if (code >= 71 && code <= 75) { - return { description: "Snow fall", emoji: "" }; - } else if (code >= 80 && code <= 86) { - return { description: "Showers (Rain/Snow)", emoji: "" }; - } else if (code >= 95 && code <= 99) { - return { description: "Thunderstorm", emoji: "" }; - } - return { description: "Unknown conditions.", emoji: "" }; -} \ No newline at end of file diff --git a/src/commands/Utility/wipedata.js b/src/commands/Utility/wipedata.js deleted file mode 100644 index 941265dca8..0000000000 --- a/src/commands/Utility/wipedata.js +++ /dev/null @@ -1,41 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, warningEmbed } from '../../utils/embeds.js'; -import { getConfirmationButtons } from '../../utils/components.js'; -import { logger } from '../../utils/logger.js'; - -import { InteractionHelper } from '../../utils/interactionHelper.js'; -export default { - slashOnly: true, - data: new SlashCommandBuilder() - .setName('wipedata') - .setDescription('Delete all your personal data from the bot (irreversible)'), - - async execute(interaction, guildConfig, client) { - const warningMessage = - `āš ļø **THIS ACTION IS IRREVERSIBLE!** āš ļø\n\n` + - `This will permanently delete **ALL** your data from this server including:\n` + - `• šŸ’° Economy balance (wallet & bank)\n` + - `• šŸ“Š Levels and XP\n` + - `• šŸŽ’ Inventory items\n` + - `• šŸ›ļø Shop purchases\n` + - `• šŸŽ‚ Birthday information\n` + - `• šŸ”¢ Counter data\n` + - `• šŸ“‹ All other personal data\n\n` + - `**This cannot be undone. Are you absolutely sure?**`; - - const embed = warningEmbed('Wipe All Data', warningMessage); - - const confirmButtons = getConfirmationButtons('wipedata'); - - await InteractionHelper.safeReply(interaction, { - embeds: [embed], - components: [confirmButtons], - flags: MessageFlags.Ephemeral - }); - - logger.info(`Wipedata command executed - confirmation prompt shown`, { - userId: interaction.user.id, - guildId: interaction.guildId - }); - } -}; \ No newline at end of file diff --git a/src/commands/Verification/autoverify.js b/src/commands/Verification/autoverify.js deleted file mode 100644 index 562f81758f..0000000000 --- a/src/commands/Verification/autoverify.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from './modules/autoVerify.js'; \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerify.js b/src/commands/Verification/modules/autoVerify.js deleted file mode 100644 index 31ca0e881d..0000000000 --- a/src/commands/Verification/modules/autoVerify.js +++ /dev/null @@ -1,190 +0,0 @@ -import { botConfig, getColor } from '../../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from 'discord.js'; -import { createEmbed, errorEmbed, successEmbed, infoEmbed } from '../../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/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'; -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; - -export default { - data: new SlashCommandBuilder() - .setName("autoverify") - .setDescription("Configure automatic verification settings") - .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) - .addSubcommand(subcommand => - subcommand - .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") - .setDescription("Criteria for automatic verification") - .addChoices( - { 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 (required for account age criteria)") - .setMinValue(minAccountAgeDays) - .setMaxValue(maxAccountAgeDays) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("dashboard") - .setDescription("Open the auto-verification dashboard for customization") - ), - - async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { - const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild; - - switch (subcommand) { - case "setup": - return await handleSetup(interaction, guild, client); - case "dashboard": - return await autoVerifyDashboard.execute(interaction, config, client); - default: - throw createError( - `Unknown subcommand: ${subcommand}`, - ErrorTypes.VALIDATION, - "Invalid subcommand selected.", - { subcommand } - ); - } - }, { command: 'autoverify', subcommand: interaction.options.getSubcommand() }); - - return await wrappedExecute(interaction, config, 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); - - if (!guildConfig.verification) { - guildConfig.verification = {}; - } - - guildConfig.verification.autoVerify = { - enabled: true, - criteria: criteria, - accountAgeDays: criteria === "account_age" ? accountAgeDays : null, - roleId: targetRole.id, - configuredVia: 'setup' - }; - - await setGuildConfig(client, guild.id, guildConfig); - - let criteriaDescription = ""; - switch (criteria) { - case "account_age": - criteriaDescription = `\`${accountAgeDays} days\` old`; - break; - case "none": - criteriaDescription = "All users immediately"; - break; - } - - logger.info('Auto-verify enabled', { - guildId: guild.id, - criteria, - accountAgeDays: criteria === 'account_age' ? accountAgeDays : null, - roleId: targetRole.id - }); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - "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.` - )] - }); - - } catch (error) { - - throw error; - } -} \ No newline at end of file diff --git a/src/commands/Verification/modules/autoVerifyDashboard.js b/src/commands/Verification/modules/autoVerifyDashboard.js deleted file mode 100644 index 7209da79fd..0000000000 --- a/src/commands/Verification/modules/autoVerifyDashboard.js +++ /dev/null @@ -1,504 +0,0 @@ -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 } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/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; - -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), - ); -} - -async function refreshDashboard(rootInteraction, cfg, guildId, client) { - try { - const selectMenu = buildSelectMenu(guildId); - - 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); - } -} - -export default { - prefixOnly: false, - async execute(interaction, config, client) { - try { - const guildId = interaction.guild.id; - const guildConfig = await getGuildConfig(client, guildId); - - if (!guildConfig.verification?.autoVerify?.enabled) { - - 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); - - 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, - }); - - 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 replyUserError(selectInteraction, { - type: ErrorTypes.CONFIGURATION, - message: errorMessage, - }).catch(() => {}); - } - }); - - 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.', - ); - } - }, -}; - -async function handleCriteria(selectInteraction, rootInteraction, guildConfig, guildId, client) { - - 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; - - 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) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No criteria selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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 replyUserError(roleInteraction, { - type: ErrorTypes.VALIDATION, - message: 'Please choose a normal assignable role (not @everyone or a bot-managed role).', - }); - return; - } - - const botMember = rootInteraction.guild.members.me; - if (role.position >= botMember.roles.highest.position) { - await replyUserError(roleInteraction, { - type: ErrorTypes.PERMISSION, - message: 'The selected role must be below my highest role in the server role hierarchy.', - }); - 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) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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 replyUserError(submitted, { type: ErrorTypes.VALIDATION, message: `Please enter a number between ${minAccountAgeDays} and ${maxAccountAgeDays}.` }); - 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); -} \ No newline at end of file diff --git a/src/commands/Verification/modules/verification_dashboard.js b/src/commands/Verification/modules/verification_dashboard.js deleted file mode 100644 index ab32689527..0000000000 --- a/src/commands/Verification/modules/verification_dashboard.js +++ /dev/null @@ -1,669 +0,0 @@ -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 } from '../../../utils/embeds.js'; -import { logger } from '../../../utils/logger.js'; -import { TitanBotError, ErrorTypes, replyUserError } from '../../../utils/errorHandler.js'; -import { getGuildConfig, setGuildConfig } from '../../../services/config/guildConfig.js'; -import { getWelcomeConfig } from '../../../utils/database.js'; -import { botHasPermission } from '../../../utils/permissionGuard.js'; -import { - getVerificationPanelStatus, - formatPanelStatusField, -} from '../../../utils/panelStatus.js'; -import { startDashboardSession } from '../../../utils/dashboardSession.js'; - -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); - } -} - -function buildDashboardEmbed(cfg, guild, verifiedUserCount = 0, conflictSummary = '', panelStatus = null) { - 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 panelStatusValue = cfg.channelId ? formatPanelStatusField(panelStatus) : '`Not configured`'; - - 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: 'Panel Status', value: panelStatusValue, inline: false }, - { 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, panelStatus = null) { - const systemOn = cfg.enabled !== false; - const showRepost = - systemOn && panelStatus?.exists === false && panelStatus?.reason === 'panel_deleted'; - - const buttons = []; - - if (showRepost) { - buttons.push( - new ButtonBuilder() - .setCustomId(`verif_cfg_repost_${guildId}`) - .setLabel('Repost Panel') - .setStyle(ButtonStyle.Primary) - .setEmoji('šŸ“Œ') - .setDisabled(disabled), - ); - } - - buttons.push( - new ButtonBuilder() - .setCustomId(`verif_cfg_toggle_${guildId}`) - .setLabel('Verification') - .setStyle(systemOn ? ButtonStyle.Success : ButtonStyle.Danger) - .setEmoji('šŸ”’') - .setDisabled(disabled), - ); - - return new ActionRowBuilder().addComponents(buttons); -} - -async function repostVerificationPanel(guild, cfg) { - const channel = await guild.channels.fetch(cfg.channelId).catch(() => null); - if (!channel) { - throw new TitanBotError( - 'Panel channel missing', - ErrorTypes.CONFIGURATION, - 'The configured verification channel no longer exists. Set a new channel from the dashboard.', - ); - } - - 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('āœ…'), - ); - - return channel.send({ embeds: [verifyEmbed], components: [verifyButton] }); -} - -async function refreshDashboard(rootInteraction, cfg, guildId, client) { - try { - const selectMenu = buildSelectMenu(guildId); - - let verifiedUserCount = 0; - let conflictSummary = ''; - let panelStatus = null; - - if (cfg.channelId && cfg.enabled !== false) { - panelStatus = await getVerificationPanelStatus(client, rootInteraction.guild, cfg); - if (panelStatus.recoveredId) { - cfg.messageId = panelStatus.recoveredId; - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - } - } - - 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, panelStatus)], - components: [ - buildButtonRow(cfg, guildId, false, panelStatus), - new ActionRowBuilder().addComponents(selectMenu), - ], - flags: MessageFlags.Ephemeral, - }); - } catch (error) { - logger.debug('Could not refresh verification dashboard (interaction may have expired):', error.message); - } -} - -export default { - prefixOnly: false, - 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 }); - - let verifiedUserCount = 0; - let conflictSummary = ''; - let panelStatus = null; - - if (cfg.channelId && cfg.enabled !== false) { - panelStatus = await getVerificationPanelStatus(client, interaction.guild, cfg); - if (panelStatus.recoveredId) { - cfg.messageId = panelStatus.recoveredId; - guildConfig.verification = cfg; - await setGuildConfig(client, guildId, guildConfig); - } - } - - 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 startDashboardSession({ - interaction, - embeds: [buildDashboardEmbed(cfg, interaction.guild, verifiedUserCount, conflictSummary, panelStatus)], - components: [ - buildButtonRow(cfg, guildId, false, panelStatus), - new ActionRowBuilder().addComponents(buildSelectMenu(guildId)), - ], - flags: MessageFlags.Ephemeral, - selectMenuId: `verif_cfg_${guildId}`, - buttonMatcher: (customId) => - customId === `verif_cfg_toggle_${guildId}` || customId === `verif_cfg_repost_${guildId}`, - onSelect: async (selectInteraction) => { - const selectedOption = selectInteraction.values[0]; - 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; - } - }, - onButton: async (btnInteraction) => { - if (btnInteraction.customId === `verif_cfg_repost_${guildId}`) { - await btnInteraction.deferUpdate(); - const newMsg = await repostVerificationPanel(interaction.guild, cfg); - cfg.messageId = newMsg.id; - const latestConfig = await getGuildConfig(client, guildId); - latestConfig.verification = cfg; - await setGuildConfig(client, guildId, latestConfig); - await btnInteraction.followUp({ - embeds: [successEmbed('Panel Reposted', `Verification panel restored in ${newMsg.channel}.`)], - flags: MessageFlags.Ephemeral, - }); - await refreshDashboard(interaction, cfg, guildId, client); - return; - } - - await btnInteraction.deferUpdate().catch(() => null); - - const wasEnabled = cfg.enabled !== false; - const autoVerifyEnabled = Boolean(guildConfig.verification?.autoVerify?.enabled); - - if (!wasEnabled && autoVerifyEnabled) { - await replyUserError(btnInteraction, { - type: ErrorTypes.CONFIGURATION, - message: 'AutoVerify is currently enabled. Please disable AutoVerify first before enabling the manual Verification system.\n\nRun `/autoverify` to access the AutoVerify dashboard.', - }); - return; - } - - cfg.enabled = !wasEnabled; - - if (!cfg.enabled && cfg.channelId && cfg.messageId) { - const channel = interaction.guild.channels.cache.get(cfg.channelId); - if (channel) { - const msg = await channel.messages.fetch(cfg.messageId).catch(() => null); - if (msg) await msg.delete().catch(() => {}); - } - } - - if (cfg.enabled && cfg.channelId) { - try { - const newMsg = await repostVerificationPanel(interaction.guild, cfg); - 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); - }, - onTimeout: async (rootInteraction) => { - await InteractionHelper.safeEditReply(rootInteraction, { - 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) { - 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.', - ); - } - }, -}; - -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 replyUserError(chanInteraction, { - type: ErrorTypes.PERMISSION, - message: `I need **View Channel**, **Send Messages**, and **Embed Links** permissions in ${newChannel}.`, - }); - return; - } - - 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 { - - } - } - } - - 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) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No channel was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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 replyUserError(roleInteraction, { - type: ErrorTypes.VALIDATION, - message: 'Please choose a normal assignable role (not @everyone or a bot-managed role).', - }); - return; - } - - if (role.position >= botMember.roles.highest.position) { - await replyUserError(roleInteraction, { - type: ErrorTypes.PERMISSION, - message: 'The verified role must be below my highest role in the server role hierarchy.', - }); - 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) { - replyUserError(selectInteraction, { - type: ErrorTypes.RATE_LIMIT, - message: 'No role was selected. The setting was not changed.', - }).catch(() => {}); - } - }); -} - -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); - - } -} - -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); - - } -} \ No newline at end of file diff --git a/src/commands/Verification/verification.js b/src/commands/Verification/verification.js deleted file mode 100644 index 8906809fe7..0000000000 --- a/src/commands/Verification/verification.js +++ /dev/null @@ -1,250 +0,0 @@ -import { botConfig, getColor } from '../../config/bot.js'; -import { SlashCommandBuilder, PermissionFlagsBits, ChannelType, ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from 'discord.js'; -import { createEmbed, infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { getGuildConfig, setGuildConfig } from '../../services/config/guildConfig.js'; -import { withErrorHandling, createError, ErrorTypes, replyUserError } from '../../utils/errorHandler.js'; -import { removeVerification, verifyUser } from '../../services/verificationService.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() - .setName("verification") - .setDescription("Manage the server verification system") - .addSubcommand(subcommand => - subcommand - .setName("setup") - .setDescription("Set up the verification system") - .addChannelOption(option => - option - .setName("verification_channel") - .setDescription("Channel where verification messages will be sent") - .addChannelTypes(ChannelType.GuildText) - .setRequired(true) - ) - .addRoleOption(option => - option - .setName("verified_role") - .setDescription("Role to give to verified users") - .setRequired(true) - ) - .addStringOption(option => - option - .setName("message") - .setDescription("Custom verification message") - .setMaxLength(2000) - .setRequired(false) - ) - .addStringOption(option => - option - .setName("button_text") - .setDescription("Text for the verification button") - .setMaxLength(80) - .setRequired(false) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("remove") - .setDescription("Remove verification from a user") - .addUserOption(option => - option - .setName("user") - .setDescription("User to remove verification from") - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName("dashboard") - .setDescription("Open the verification system configuration dashboard") - ), - - async execute(interaction, config, client) { - const wrappedExecute = withErrorHandling(async () => { - const subcommand = interaction.options.getSubcommand(); - const guild = interaction.guild; - - if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageGuild)) { - throw createError( - 'Missing ManageGuild permission for verification admin subcommand', - ErrorTypes.PERMISSION, - 'You need the **Manage Server** permission to use this verification subcommand.', - { subcommand, requiredPermission: 'ManageGuild', userId: interaction.user.id } - ); - } - - switch (subcommand) { - case "setup": - return await handleSetup(interaction, guild, client); - case "remove": - return await handleRemove(interaction, guild, client); - case "dashboard": - return await verificationDashboard.execute(interaction, config, client); - default: - throw createError( - `Unknown subcommand: ${subcommand}`, - ErrorTypes.VALIDATION, - "Please select a valid subcommand.", - { subcommand } - ); - } - }, { command: 'verification', subcommand: interaction.options.getSubcommand() }); - - return await wrappedExecute(interaction, config, client); - } -}; - -async function handleSetup(interaction, guild, client) { - const verificationChannel = interaction.options.getChannel("verification_channel"); - const verifiedRole = interaction.options.getRole("verified_role"); - const message = interaction.options.getString("message") || botConfig.verification.defaultMessage; - const buttonText = interaction.options.getString("button_text") || botConfig.verification.defaultButtonText; - 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 } - ); - } - - const requiredChannelPermissions = [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.EmbedLinks - ]; - const missingChannelPerms = requiredChannelPermissions.filter(perm => - !verificationChannel.permissionsFor(botMember).has(perm) - ); - - if (missingChannelPerms.length > 0) { - throw createError( - `Missing channel permissions: ${missingChannelPerms.join(', ')}`, - ErrorTypes.PERMISSION, - 'I need **View Channel**, **Send Messages**, and **Embed Links** in the verification channel.', - { missingPermissions: missingChannelPerms, channel: verificationChannel.id } - ); - } - - if (!botMember.permissions.has(PermissionFlagsBits.ManageRoles)) { - throw createError( - "Missing ManageRoles permission", - ErrorTypes.PERMISSION, - "I need the 'Manage Roles' permission to give verified roles.", - { missingPermission: "ManageRoles" } - ); - } - - 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( - "Role hierarchy error", - ErrorTypes.PERMISSION, - "The verified role must be below my highest role in the server role hierarchy.", - { rolePosition: verifiedRole.position, botRolePosition: botRole.position } - ); - } - - 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({ - title: "Server Verification", - description: message, - color: getColor('success') - }); - - const verifyButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("verify_user") - .setLabel(buttonText) - .setStyle(ButtonStyle.Success) - .setEmoji("āœ…") - ); - - const verifyMessage = await verificationChannel.send({ - embeds: [verifyEmbed], - components: [verifyButton] - }); - - guildConfig.verification = { - enabled: true, - channelId: verificationChannel.id, - messageId: verifyMessage.id, - roleId: verifiedRole.id, - message: message, - buttonText: buttonText - }; - - await setGuildConfig(client, guild.id, guildConfig); - - await InteractionHelper.safeEditReply(interaction, { - embeds: [successEmbed( - 'Verification System Updated', - [ - `Channel: ${verificationChannel}`, - `Verified Role: ${verifiedRole}`, - `Button Text: ${buttonText}` - ].join('\n') - )] - }); -} - -async function handleRemove(interaction, guild, client) { - const targetUser = interaction.options.getUser("user"); - - const result = await removeVerification(client, guild.id, targetUser.id, { - moderatorId: interaction.user.id, - reason: 'admin_removal' - }); - - if (result.status === 'not_verified') { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Not Verified', `${targetUser.tag} does not currently have the verified role.`)], - flags: MessageFlags.Ephemeral - }); - } - - logger.info('Verification removed via command', { - guildId: guild.id, - targetUserId: targetUser.id, - moderatorId: interaction.user.id - }); - - return await InteractionHelper.safeReply(interaction, { - embeds: [successEmbed('Verification Removed', `Verification removed from ${targetUser.tag}.`)] - }); -} \ No newline at end of file diff --git a/src/commands/Verification/verify.js b/src/commands/Verification/verify.js deleted file mode 100644 index 7c203ac8d6..0000000000 --- a/src/commands/Verification/verify.js +++ /dev/null @@ -1,35 +0,0 @@ -import { SlashCommandBuilder, MessageFlags } from 'discord.js'; -import { infoEmbed, successEmbed } from '../../utils/embeds.js'; -import { replyUserError, ErrorTypes } from '../../utils/errorHandler.js'; -import { verifyUser } from '../../services/verificationService.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 guild = interaction.guild; - - const result = await verifyUser(client, guild.id, interaction.user.id, { - source: 'command_self', - moderatorId: null - }); - - if (result.status === 'already_verified') { - return await InteractionHelper.safeReply(interaction, { - embeds: [infoEmbed('Already Verified', "You are already verified.")], - 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 - }); - } -}; diff --git a/src/events/guildAuditLogEntryCreate.js b/src/events/guildAuditLogEntryCreate.js new file mode 100644 index 0000000000..d667728981 --- /dev/null +++ b/src/events/guildAuditLogEntryCreate.js @@ -0,0 +1,18 @@ + +import { AuditLogEvent } from 'discord.js'; +import { handleAuditLogEntry } from '../services/antiNukeService.js'; + +export default { + name: 'guildAuditLogEntryCreate', + + async execute(entry, guild, client) { + if (!entry || !guild) return; + + await handleAuditLogEntry({ + entry, + guild, + client, + AuditLogEvent, + }); + }, +}; diff --git a/src/events/index.js b/src/events/index.js new file mode 100644 index 0000000000..5e792b7366 --- /dev/null +++ b/src/events/index.js @@ -0,0 +1,79 @@ +const { + Client, + GatewayIntentBits, + Events +} = require("discord.js"); + +const { + createAntiNuke +} = require("./antinuke"); + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers + ] +}); + +const antiNuke = new Map(); + +client.once(Events.ClientReady, async () => { + console.log(`PrefixByte online as ${client.user.tag}`); + + for (const guild of client.guilds.cache.values()) { + antiNuke.set( + guild.id, + createAntiNuke(guild, { + trustedUsers: [ + guild.ownerId + ], + + // Put your dedicated logging channel ID here. + logChannelId: null + }) + ); + } +}); + +client.on(Events.ChannelDelete, async (channel) => { + const system = antiNuke.get(channel.guild.id); + + if (system) { + await system.handle("channelDelete"); + } +}); + +client.on(Events.RoleDelete, async (role) => { + const system = antiNuke.get(role.guild.id); + + if (system) { + await system.handle("roleDelete"); + } +}); + +client.on(Events.GuildBanAdd, async (ban) => { + const system = antiNuke.get(ban.guild.id); + + if (system) { + await system.handle("ban"); + } +}); + +client.on(Events.GuildMemberRemove, async (member) => { + // Audit-log based distinction between kick/leave. + const system = antiNuke.get(member.guild.id); + + if (system) { + await system.handle("kick"); + } +}); + +client.on(Events.WebhooksUpdate, async (channel) => { + const system = antiNuke.get(channel.guild.id); + + if (system) { + await system.handle("webhookDelete"); + } +}); + +client.login(process.env.DISCORD_TOKEN);