diff --git a/README.md b/README.md index 5bc92f9..7cbacc3 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > [!WARNING] > Documentation is still under construction. Expect incomplete and undocumented features. -All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/) +All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/?utm_source=github&utm_medium=readme&utm_campaign=repository&utm_content=docs_link) ## Development Commands diff --git a/src/commands/util/config.ts b/src/commands/util/config.ts new file mode 100644 index 0000000..9dde461 --- /dev/null +++ b/src/commands/util/config.ts @@ -0,0 +1,235 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionFlagsBits, +} from 'discord.js'; + +import { Command } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('config') + .setDescription('(Admin Only) Display the current configuration') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + if ( + !interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) + ) { + await interaction.reply({ + content: 'You do not have permission to use this command.', + flags: ['Ephemeral'], + }); + return; + } + + const config = loadConfig(); + const displayConfig = JSON.parse(JSON.stringify(config)); + + if (displayConfig.token) displayConfig.token = '••••••••••••••••••••••••••'; + if (displayConfig.database?.dbConnectionString) { + displayConfig.database.dbConnectionString = '••••••••••••••••••••••••••'; + } + if (displayConfig.redis?.redisConnectionString) { + displayConfig.redis.redisConnectionString = '••••••••••••••••••••••••••'; + } + + const pages: EmbedBuilder[] = []; + + const basicConfigEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Bot Configuration') + .setDescription( + 'Current configuration settings (sensitive data redacted)', + ) + .addFields( + { + name: 'Client ID', + value: displayConfig.clientId || 'Not set', + inline: true, + }, + { + name: 'Guild ID', + value: displayConfig.guildId || 'Not set', + inline: true, + }, + { + name: 'Token', + value: displayConfig.token || 'Not set', + inline: true, + }, + ); + + pages.push(basicConfigEmbed); + + if (displayConfig.database || displayConfig.redis) { + const dbRedisEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Database and Redis Configuration') + .setDescription('Database and cache settings'); + + if (displayConfig.database) { + dbRedisEmbed.addFields({ + name: 'Database', + value: `Connection: ${displayConfig.database.dbConnectionString}\nMax Retry: ${displayConfig.database.maxRetryAttempts}\nRetry Delay: ${displayConfig.database.retryDelay}ms`, + }); + } + + if (displayConfig.redis) { + dbRedisEmbed.addFields({ + name: 'Redis', + value: `Connection: ${displayConfig.redis.redisConnectionString}\nRetry Attempts: ${displayConfig.redis.retryAttempts}\nInitial Retry Delay: ${displayConfig.redis.initialRetryDelay}ms`, + }); + } + + pages.push(dbRedisEmbed); + } + + if (displayConfig.channels || displayConfig.roles) { + const channelsRolesEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Channels and Roles Configuration') + .setDescription('Server channel and role settings'); + + if (displayConfig.channels) { + const channelsText = Object.entries(displayConfig.channels) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + channelsRolesEmbed.addFields({ + name: 'Channels', + value: channelsText || 'None configured', + }); + } + + if (displayConfig.roles) { + let rolesText = ''; + + if (displayConfig.roles.joinRoles?.length) { + rolesText += `Join Roles: ${displayConfig.roles.joinRoles.join(', ')}\n`; + } + + if (displayConfig.roles.levelRoles?.length) { + rolesText += `Level Roles: ${displayConfig.roles.levelRoles.length} configured\n`; + } + + if (displayConfig.roles.staffRoles?.length) { + rolesText += `Staff Roles: ${displayConfig.roles.staffRoles.length} configured\n`; + } + + if (displayConfig.roles.factPingRole) { + rolesText += `Fact Ping Role: ${displayConfig.roles.factPingRole}`; + } + + channelsRolesEmbed.addFields({ + name: 'Roles', + value: rolesText || 'None configured', + }); + } + + pages.push(channelsRolesEmbed); + } + + if ( + displayConfig.leveling || + displayConfig.counting || + displayConfig.giveaways + ) { + const featuresEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Feature Configurations') + .setDescription('Settings for bot features'); + + if (displayConfig.leveling) { + featuresEmbed.addFields({ + name: 'Leveling', + value: `XP Cooldown: ${displayConfig.leveling.xpCooldown}s\nMin XP: ${displayConfig.leveling.minXpAwarded}\nMax XP: ${displayConfig.leveling.maxXpAwarded}`, + }); + } + + if (displayConfig.counting) { + const countingText = Object.entries(displayConfig.counting) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Counting', + value: countingText || 'Default settings', + }); + } + + if (displayConfig.giveaways) { + const giveawaysText = Object.entries(displayConfig.giveaways) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Giveaways', + value: giveawaysText || 'Default settings', + }); + } + + pages.push(featuresEmbed); + } + + let currentPage = 0; + + const components = + pages.length > 1 + ? [createPaginationButtons(pages.length, currentPage)] + : []; + + const reply = await interaction.reply({ + embeds: [pages[currentPage]], + components, + flags: ['Ephemeral'], + }); + + if (pages.length <= 1) return; + + const collector = reply.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + flags: ['Ephemeral'], + }); + return; + } + + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Failed to remove pagination buttons:', error); + } + }); + }, +}; + +export default command; diff --git a/src/commands/util/help.ts b/src/commands/util/help.ts new file mode 100644 index 0000000..3ab08e3 --- /dev/null +++ b/src/commands/util/help.ts @@ -0,0 +1,271 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ComponentType, +} from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; + +const DOC_BASE_URL = 'https://docs.poixpixel.ahmadk953.org/'; +const getDocUrl = (location: string) => + `${DOC_BASE_URL}?utm_source=discord&utm_medium=bot&utm_campaign=help_command&utm_content=${location}`; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('help') + .setDescription('Shows a list of all available commands') + .addStringOption((option) => + option + .setName('command') + .setDescription('Get detailed help for a specific command') + .setRequired(false), + ), + + execute: async (interaction) => { + try { + await interaction.deferReply(); + + const client = interaction.client as ExtendedClient; + const commandName = interaction.options.getString('command'); + + if (commandName) { + return handleSpecificCommand(interaction, client, commandName); + } + + const categories = new Map(); + + for (const [name, cmd] of client.commands) { + const category = getCategoryFromCommand(name); + + if (!categories.has(category)) { + categories.set(category, []); + } + + categories.get(category).push({ + name, + description: cmd.data.toJSON().description, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Poixpixel Bot Commands') + .setDescription( + '**Welcome to Poixpixel Discord Bot!**\n\n' + + 'Select a category from the dropdown menu below to see available commands.\n\n' + + `📚 **Documentation:** [Visit Our Documentation](${getDocUrl('main_description')})`, + ) + .setThumbnail(client.user!.displayAvatarURL()) + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + const categoryEmojis: Record = { + fun: '🎮', + moderation: '🛡️', + util: '🔧', + testing: '🧪', + }; + + Array.from(categories.keys()).forEach((category) => { + const emoji = categoryEmojis[category] || '📁'; + embed.addFields({ + name: `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`, + value: `Use the dropdown to see ${category} commands`, + inline: true, + }); + }); + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl('main_footer_field')})`, + inline: false, + }); + + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('help_category_select') + .setPlaceholder('Select a command category') + .addOptions( + Array.from(categories.keys()).map((category) => { + const emoji = categoryEmojis[category] || '📁'; + return new StringSelectMenuOptionBuilder() + .setLabel( + category.charAt(0).toUpperCase() + category.slice(1), + ) + .setDescription(`View ${category} commands`) + .setValue(category) + .setEmoji(emoji); + }), + ), + ); + + const message = await interaction.editReply({ + embeds: [embed], + components: [selectMenu], + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + const selectedCategory = i.values[0]; + const commands = categories.get(selectedCategory); + const emoji = categoryEmojis[selectedCategory] || '📁'; + + const categoryEmbed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle( + `${emoji} ${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} Commands`, + ) + .setDescription('Here are all the commands in this category:') + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + commands.forEach((cmd: any) => { + categoryEmbed.addFields({ + name: `/${cmd.name}`, + value: cmd.description || 'No description available', + inline: false, + }); + }); + + categoryEmbed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`category_${selectedCategory}`)})`, + inline: false, + }); + + await i.update({ embeds: [categoryEmbed], components: [selectMenu] }); + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(console.error); + }); + } catch (error) { + console.error('Error in help command:', error); + await interaction.editReply({ + content: 'An error occurred while processing your request.', + }); + } + }, +}; + +/** + * Handle showing help for a specific command + */ +async function handleSpecificCommand( + interaction: any, + client: ExtendedClient, + commandName: string, +) { + const cmd = client.commands.get(commandName); + + if (!cmd) { + return interaction.reply({ + content: `Command \`${commandName}\` not found.`, + ephemeral: true, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Help: /${commandName}`) + .setDescription(cmd.data.toJSON().description || 'No description available') + .addFields({ + name: 'Category', + value: getCategoryFromCommand(commandName), + inline: true, + }) + .setFooter({ + text: `Poixpixel Discord Bot • Documentation: ${getDocUrl(`cmd_footer_${commandName}`)}`, + }); + + const options = cmd.data.toJSON().options; + if (options && options.length > 0) { + if (options[0].type === 1) { + embed.addFields({ + name: 'Subcommands', + value: options + .map((opt: any) => `\`${opt.name}\`: ${opt.description}`) + .join('\n'), + inline: false, + }); + } else { + embed.addFields({ + name: 'Options', + value: options + .map( + (opt: any) => + `\`${opt.name}\`: ${opt.description} ${opt.required ? '(Required)' : '(Optional)'}`, + ) + .join('\n'), + inline: false, + }); + } + } + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`cmd_field_${commandName}`)})`, + inline: false, + }); + + return interaction.reply({ embeds: [embed] }); +} + +/** + * Get the category of a command based on its name + */ +function getCategoryFromCommand(commandName: string): string { + const commandCategories: Record = { + achievement: 'fun', + fact: 'fun', + rank: 'fun', + counting: 'fun', + giveaway: 'fun', + leaderboard: 'fun', + + ban: 'moderation', + kick: 'moderation', + mute: 'moderation', + unmute: 'moderation', + warn: 'moderation', + unban: 'moderation', + + ping: 'util', + server: 'util', + userinfo: 'util', + members: 'util', + rules: 'util', + restart: 'util', + reconnect: 'util', + xp: 'util', + recalculatelevels: 'util', + help: 'util', + config: 'util', + + testjoin: 'testing', + testleave: 'testing', + }; + + return commandCategories[commandName.toLowerCase()] || 'other'; +} + +export default command;