From f63fca3f1b3e9061405936ff5ab6102a0dbac4a7 Mon Sep 17 00:00:00 2001 From: chad Date: Fri, 3 Oct 2025 19:53:23 -0400 Subject: [PATCH] ui updates and bot updates. new commands and command handler --- backend/index.js | 72 +++++++++++ checklist.md | 33 ++++- discord-bot/commands/config-leave.js | 63 ---------- discord-bot/commands/help.js | 25 ++++ discord-bot/commands/manage-commands.js | 112 +++++++++++++++++ discord-bot/commands/setup-autorole.js | 63 ++++++++++ discord-bot/commands/setup-leave.js | 116 +++++++++++++++++ discord-bot/commands/setup-welcome.js | 145 +++++++++++++++------- discord-bot/commands/view-autorole.js | 29 +++++ discord-bot/events/guildMemberAdd.js | 17 +++ discord-bot/events/guildMemberRemove.js | 23 +++- discord-bot/handlers/command-handler.js | 4 +- discord-bot/index.js | 31 ++++- frontend/src/App.js | 8 ++ frontend/src/components/ContactPage.js | 11 ++ frontend/src/components/DiscordPage.js | 11 ++ frontend/src/components/HelpPage.js | 46 +++++++ frontend/src/components/NameBar.js | 21 ++++ frontend/src/components/NavBar.js | 34 +++++ frontend/src/components/ServerSettings.js | 92 +++++++++++++- 20 files changed, 831 insertions(+), 125 deletions(-) delete mode 100644 discord-bot/commands/config-leave.js create mode 100644 discord-bot/commands/help.js create mode 100644 discord-bot/commands/manage-commands.js create mode 100644 discord-bot/commands/setup-autorole.js create mode 100644 discord-bot/commands/setup-leave.js create mode 100644 discord-bot/commands/view-autorole.js create mode 100644 frontend/src/components/ContactPage.js create mode 100644 frontend/src/components/DiscordPage.js create mode 100644 frontend/src/components/HelpPage.js create mode 100644 frontend/src/components/NameBar.js create mode 100644 frontend/src/components/NavBar.js diff --git a/backend/index.js b/backend/index.js index 6b62fb1..23746fb 100644 --- a/backend/index.js +++ b/backend/index.js @@ -182,6 +182,50 @@ app.post('/api/servers/:guildId/welcome-leave-settings', (req, res) => { db[guildId].leaveMessage = newSettings.leave.message; db[guildId].leaveCustomMessage = newSettings.leave.customMessage; + writeDb(db); + + res.json({ success: true }); +}); + +app.get('/api/servers/:guildId/roles', async (req, res) => { + const { guildId } = req.params; + const guild = bot.client.guilds.cache.get(guildId); + if (!guild) { + return res.json([]); + } + try { + const rolesCollection = await guild.roles.fetch(); + // Exclude @everyone (role.id === guild.id), exclude managed roles, and only include roles below the bot's highest role + const botHighest = guild.members.me.roles.highest.position; + const manageable = rolesCollection + .filter(role => role.id !== guild.id && !role.managed && role.position < botHighest) + .sort((a, b) => b.position - a.position) + .map(role => ({ id: role.id, name: role.name, color: role.hexColor })); + res.json(manageable); + } catch (error) { + console.error('Error fetching roles:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + +app.get('/api/servers/:guildId/autorole-settings', (req, res) => { + const { guildId } = req.params; + const db = readDb(); + const settings = db[guildId] || {}; + const autoroleSettings = settings.autorole || { enabled: false, roleId: '' }; + res.json(autoroleSettings); +}); + +app.post('/api/servers/:guildId/autorole-settings', (req, res) => { + const { guildId } = req.params; + const { enabled, roleId } = req.body; + const db = readDb(); + + if (!db[guildId]) { + db[guildId] = {}; + } + + db[guildId].autorole = { enabled, roleId }; writeDb(db); res.json({ success: true }); }); @@ -190,6 +234,34 @@ app.get('/', (req, res) => { res.send('Hello from the backend!'); }); +// Return list of bot commands and per-guild enabled/disabled status +app.get('/api/servers/:guildId/commands', (req, res) => { + try { + const { guildId } = req.params; + const db = readDb(); + const guildSettings = db[guildId] || {}; + const toggles = guildSettings.commandToggles || {}; + const protectedCommands = ['manage-commands', 'help']; + + const commands = Array.from(bot.client.commands.values()).map(cmd => { + const isLocked = protectedCommands.includes(cmd.name); + const isEnabled = isLocked ? true : (toggles[cmd.name] !== false && cmd.enabled !== false); + return { + name: cmd.name, + description: cmd.description || 'No description.', + enabled: isEnabled, + locked: isLocked, + hasSlashBuilder: !!cmd.builder, + }; + }); + + res.json(commands); + } catch (error) { + console.error('Error returning commands:', error); + res.status(500).json({ success: false, message: 'Internal Server Error' }); + } +}); + const bot = require('../discord-bot'); bot.login(); diff --git a/checklist.md b/checklist.md index b2a4a73..aeed8f6 100644 --- a/checklist.md +++ b/checklist.md @@ -82,6 +82,8 @@ - [x] **Bot Integration:** - [x] Connect frontend settings to the backend. - [x] Implement bot logic to send welcome/leave messages based on server settings. + - [x] Fix: Leave messages now fetch channel reliably, ensure bot has permissions (ViewChannel/SendMessages), and use mention-friendly user formatting. Added debug logging. + - [x] Fix: Removed verbose console logging of incoming settings and messages in backend and bot (no sensitive or noisy payloads logged). - [x] **Slash Command Integration:** - [x] ~~Create a `/config-welcome` slash command.~~ - [x] ~~Add a subcommand to `set-channel` for welcome messages.~~ @@ -92,9 +94,32 @@ - [x] ~~Add a subcommand to `set-message` with options for default and custom messages.~~ - [x] ~~Add a subcommand to `disable` leave messages.~~ - [x] ~~Create a `/view-config` slash command to display the current welcome and leave channels.~~ - - [ ] Refactor `/config-welcome` to `/setup-welcome` with interactive setup for channel and message. - - [ ] Refactor `/config-leave` to `/setup-leave` with interactive setup for channel and message. - - [ ] Rename `/view-config` to `/view-welcome-leave`. + - [x] Refactor `/config-welcome` to `/setup-welcome` with interactive setup for channel and message. + - [x] Refactor `/config-leave` to `/setup-leave` with interactive setup for channel and message. + - [x] Rename `/view-config` to `/view-welcome-leave`. - [x] Ensure settings updated via slash commands are reflected on the frontend. - [x] Ensure settings updated via the frontend are reflected in the bot's behavior. - - [x] Persist the selected message option (default or custom) for welcome and leave messages. \ No newline at end of file + - [x] **New:** Interactive setup should prompt for channel, then for message (default or custom, matching frontend options). + - [x] Persist the selected message option (default or custom) for welcome and leave messages. + - [x] Added `/view-autorole` slash command to report autorole status and selected role. + - [x] Added `/manage-commands` admin slash command to list and toggle commands per-server (persists toggles to backend DB). + - [x] Refactor: `/manage-commands` now renders a single message with toggle buttons reflecting each command's current state and updates in-place. + - [x] Ensure `/manage-commands` lists all loaded commands (including non-slash/simple commands like `ping`) and will include future commands automatically. + - [x] Ensure the `/help` command is locked (protected) and cannot be disabled via `/manage-commands`. + - [x] Add a Help tab in the frontend Server Settings that lists all bot commands and their descriptions per-server. + - [x] Move Help to a dedicated page within the server dashboard and add a top NavBar (collapsible) with Dashboard, Discord!, Contact, and Help (when on a server) buttons. Ensure Help page has a back arrow to return to the Commands section. + - [x] Added `/help` slash command that lists commands and their descriptions and shows per-server enable/disable status. +- [x] **Autorole** + - [x] Add "Autorole" section to server settings. + - [x] **Backend:** + - [x] Create API endpoint to get/set autorole settings. + - [x] Create API endpoint to fetch server roles. + - [x] **Bot Integration:** + - [x] Create a `/setup-autorole` slash command to enable/disable and select a role. + - [x] Update `guildMemberAdd` event to assign the selected role on join. + - [x] **Frontend:** + - [x] Add toggle to enable/disable autorole. + - [x] Add dropdown to select a role for autorole. + - [x] Ensure settings updated via slash commands are reflected on the frontend. + - [x] Ensure settings updated via the frontend are reflected in the bot's behavior. + - [x] Fix: Autorole dropdown excludes @everyone and shows only roles the bot can manage. Assignment is validated at join. \ No newline at end of file diff --git a/discord-bot/commands/config-leave.js b/discord-bot/commands/config-leave.js deleted file mode 100644 index 87af3d1..0000000 --- a/discord-bot/commands/config-leave.js +++ /dev/null @@ -1,63 +0,0 @@ -const { SlashCommandBuilder } = require('discord.js'); -const { readDb, writeDb } = require('../../backend/db.js'); - -module.exports = { - name: 'config-leave', - description: 'Configure the leave message for this server.', - enabled: true, - builder: new SlashCommandBuilder() - .setName('config-leave') - .setDescription('Configure the leave message for this server.') - .addSubcommand(subcommand => - subcommand - .setName('set-channel') - .setDescription('Set the channel for leave messages.') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send leave messages to.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('set-message') - .setDescription('Set the leave message.') - .addStringOption(option => - option.setName('message') - .setDescription('The leave message. Use {user} for username and {server} for server name.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('disable') - .setDescription('Disable leave messages.') - ), - async execute(interaction) { - const db = readDb(); - const guildId = interaction.guildId; - const subcommand = interaction.options.getSubcommand(); - - if (!db[guildId]) { - db[guildId] = {}; - } - - if (subcommand === 'set-channel') { - const channel = interaction.options.getChannel('channel'); - db[guildId].leaveChannel = channel.id; - db[guildId].leaveEnabled = true; - writeDb(db); - await interaction.reply(`Leave channel set to ${channel}.`); - } else if (subcommand === 'set-message') { - const message = interaction.options.getString('message'); - db[guildId].leaveMessage = message; - db[guildId].leaveEnabled = true; - writeDb(db); - await interaction.reply(`Leave message set to: "${message}"`); - } else if (subcommand === 'disable') { - db[guildId].leaveEnabled = false; - writeDb(db); - await interaction.reply('Leave messages disabled.'); - } - }, -}; diff --git a/discord-bot/commands/help.js b/discord-bot/commands/help.js new file mode 100644 index 0000000..3d2e0b5 --- /dev/null +++ b/discord-bot/commands/help.js @@ -0,0 +1,25 @@ +const { SlashCommandBuilder } = require('discord.js'); + +module.exports = { + name: 'help', + description: 'List available bot commands and what they do.', + enabled: true, + builder: new SlashCommandBuilder() + .setName('help') + .setDescription('List available bot commands and what they do.'), + async execute(interaction) { + const commands = Array.from(interaction.client.commands.values()).filter(cmd => !!cmd.builder); + let text = '**Available Commands:**\n\n'; + const db = require('../../backend/db').readDb(); + const guildSettings = db[interaction.guildId] || {}; + const toggles = guildSettings.commandToggles || {}; + const protectedCommands = ['manage-commands', 'help']; + + for (const cmd of commands) { + const isEnabled = protectedCommands.includes(cmd.name) ? true : (toggles[cmd.name] !== false && cmd.enabled !== false); + text += `/${cmd.name} — ${cmd.description || 'No description.'} — ${isEnabled ? 'Enabled' : 'Disabled'}${protectedCommands.includes(cmd.name) ? ' (locked)' : ''}\n`; + } + + await interaction.reply({ content: text, flags: 64 }); + }, +}; diff --git a/discord-bot/commands/manage-commands.js b/discord-bot/commands/manage-commands.js new file mode 100644 index 0000000..84bb95c --- /dev/null +++ b/discord-bot/commands/manage-commands.js @@ -0,0 +1,112 @@ +const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField } = require('discord.js'); +const { readDb, writeDb } = require('../../backend/db.js'); + +module.exports = { + name: 'manage-commands', + description: 'Admin: List bot commands and toggle them Enabled/Disabled for this server.', + enabled: true, + builder: new SlashCommandBuilder() + .setName('manage-commands') + .setDescription('Admin: List bot commands and toggle them Enabled/Disabled for this server.'), + async execute(interaction) { + // Only allow administrators + if (!interaction.member.permissions.has(PermissionsBitField.Flags.Administrator)) { + await interaction.reply({ content: 'You must be a server administrator to use this command.', flags: 64 }); + return; + } + + const db = readDb(); + if (!db[interaction.guildId]) db[interaction.guildId] = {}; + if (!db[interaction.guildId].commandToggles) db[interaction.guildId].commandToggles = {}; + + const toggles = db[interaction.guildId].commandToggles; + // Include all loaded commands so simple command modules (no SlashCommandBuilder) like + // `ping` are also listed. Filter for objects with a name for safety. + const commands = Array.from(interaction.client.commands.values()).filter(cmd => cmd && cmd.name); + + // Build button components (max 5 rows, 5 buttons per row) + const actionRows = []; + let currentRow = new ActionRowBuilder(); + let buttonsInRow = 0; + + const protectedCommands = ['manage-commands', 'help']; + + const buildButton = (cmd) => { + if (protectedCommands.includes(cmd.name)) return null; + const isEnabled = toggles[cmd.name] !== false && cmd.enabled !== false; + return new ButtonBuilder() + .setCustomId(`toggle_cmd_${cmd.name}`) + .setLabel(`${cmd.name} : ${isEnabled ? 'ENABLED' : 'DISABLED'}`) + .setStyle(isEnabled ? ButtonStyle.Success : ButtonStyle.Secondary); + }; + + for (const cmd of commands) { + const btn = buildButton(cmd); + if (btn) { + currentRow.addComponents(btn); + buttonsInRow++; + + if (buttonsInRow === 5) { + actionRows.push(currentRow); + currentRow = new ActionRowBuilder(); + buttonsInRow = 0; + } + } + } + if (buttonsInRow > 0) actionRows.push(currentRow); + + const description = commands.map(cmd => `• ${cmd.name} — ${cmd.description || 'No description.'}${protectedCommands.includes(cmd.name) ? ' (locked)' : ''}`).join('\n'); + + await interaction.reply({ content: `Manage Commands for this server:\n\n${description}`, components: actionRows, flags: 64 }); + + const message = await interaction.fetchReply(); + + // Collector to handle button presses for 5 minutes + const filter = i => i.user.id === interaction.user.id && i.customId.startsWith('toggle_cmd_'); + const collector = message.createMessageComponentCollector({ filter, time: 5 * 60 * 1000 }); + + collector.on('collect', async i => { + const cmdName = i.customId.replace('toggle_cmd_', ''); + toggles[cmdName] = !(toggles[cmdName] !== false); + writeDb(db); + + // rebuild buttons to reflect new state + const updatedRows = []; + let r = new ActionRowBuilder(); + let count = 0; + for (const cmd of commands) { + if (protectedCommands.includes(cmd.name)) continue; + const btn = new ButtonBuilder() + .setCustomId(`toggle_cmd_${cmd.name}`) + .setLabel(`${cmd.name} : ${(toggles[cmd.name] !== false && cmd.enabled !== false) ? 'ENABLED' : 'DISABLED'}`) + .setStyle((toggles[cmd.name] !== false && cmd.enabled !== false) ? ButtonStyle.Success : ButtonStyle.Secondary); + r.addComponents(btn); + count++; + if (count === 5) { updatedRows.push(r); r = new ActionRowBuilder(); count = 0; } + } + if (count > 0) updatedRows.push(r); + + await i.update({ content: `Manage Commands for this server:\n\n${description}`, components: updatedRows }); + }); + + collector.on('end', async () => { + // disable buttons after collector ends + const disabledRows = []; + let rr = new ActionRowBuilder(); + let ccount = 0; + for (const cmd of commands) { + if (protectedCommands.includes(cmd.name)) continue; + const btn = new ButtonBuilder() + .setCustomId(`toggle_cmd_${cmd.name}`) + .setLabel(`${cmd.name} : ${(toggles[cmd.name] !== false && cmd.enabled !== false) ? 'ENABLED' : 'DISABLED'}`) + .setStyle((toggles[cmd.name] !== false && cmd.enabled !== false) ? ButtonStyle.Success : ButtonStyle.Secondary) + .setDisabled(true); + rr.addComponents(btn); + ccount++; + if (ccount === 5) { disabledRows.push(rr); rr = new ActionRowBuilder(); ccount = 0; } + } + if (ccount > 0) disabledRows.push(rr); + try { await message.edit({ components: disabledRows }); } catch (e) { /* ignore */ } + }); + }, +}; diff --git a/discord-bot/commands/setup-autorole.js b/discord-bot/commands/setup-autorole.js new file mode 100644 index 0000000..264e9aa --- /dev/null +++ b/discord-bot/commands/setup-autorole.js @@ -0,0 +1,63 @@ +const { SlashCommandBuilder, ActionRowBuilder, RoleSelectMenuBuilder, ComponentType } = require('discord.js'); +const { readDb, writeDb } = require('../../backend/db.js'); + +module.exports = { + name: 'setup-autorole', + description: 'Interactively set up the autorole for this server.', + enabled: true, + builder: new SlashCommandBuilder() + .setName('setup-autorole') + .setDescription('Interactively set up the autorole for this server.'), + async execute(interaction) { + const db = readDb(); + const guildId = interaction.guildId; + + if (!db[guildId]) { + db[guildId] = {}; + } + + const roleSelect = new RoleSelectMenuBuilder() + .setCustomId('autorole_role_select') + .setPlaceholder('Select the role to assign on join.'); + + const row = new ActionRowBuilder().addComponents(roleSelect); + + const roleReply = await interaction.reply({ + content: 'Please select the role you want to automatically assign to new members.', + components: [row], + flags: 64, + }); + + try { + const roleConfirmation = await roleReply.awaitMessageComponent({ + componentType: ComponentType.RoleSelect, + time: 60000, + }); + + const roleId = roleConfirmation.values[0]; + const role = interaction.guild.roles.cache.get(roleId); + + if (role.managed || role.position >= interaction.guild.members.me.roles.highest.position) { + await roleConfirmation.update({ + content: `I cannot assign the role **${role.name}** because it is managed by an integration or is higher than my highest role. Please select another role.`, + components: [], + }); + return; + } + + db[guildId].autorole = { + enabled: true, + roleId: roleId, + }; + writeDb(db); + + await roleConfirmation.update({ + content: `Autorole setup complete! New members will be assigned the **${role.name}** role.`, + components: [], + }); + } catch (error) { + console.error('Error during autorole setup:', error); + await interaction.editReply({ content: 'Configuration timed out or an error occurred.', components: [] }); + } + }, +}; diff --git a/discord-bot/commands/setup-leave.js b/discord-bot/commands/setup-leave.js new file mode 100644 index 0000000..dacd8ce --- /dev/null +++ b/discord-bot/commands/setup-leave.js @@ -0,0 +1,116 @@ +const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js'); +const { readDb, writeDb } = require('../../backend/db.js'); + +const defaultLeaveMessages = ["{user} has left the server.", "Goodbye, {user}.", "We'll miss you, {user}."]; + +module.exports = { + name: 'setup-leave', + description: 'Interactively set up the leave message for this server.', + enabled: true, + builder: new SlashCommandBuilder() + .setName('setup-leave') + .setDescription('Interactively set up the leave message for this server.'), + async execute(interaction) { + const db = readDb(); + const guildId = interaction.guildId; + + if (!db[guildId]) { + db[guildId] = {}; + } + + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('leave_channel_select') + .setPlaceholder('Select the channel for leave messages.') + .setChannelTypes([0]); // Text channels + + const row1 = new ActionRowBuilder().addComponents(channelSelect); + + const channelReply = await interaction.reply({ + content: 'Please select the channel where you want leave messages to be sent.', + components: [row1], + flags: 64, + }); + + try { + const channelConfirmation = await channelReply.awaitMessageComponent({ + componentType: ComponentType.ChannelSelect, + time: 60000, + }); + + const channelId = channelConfirmation.values[0]; + db[guildId].leaveChannel = channelId; + db[guildId].leaveEnabled = true; + + const messageOptions = defaultLeaveMessages.map(msg => ({ + label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg, + value: msg, + })); + + messageOptions.push({ + label: 'Custom Message', + value: 'custom', + }); + + const messageSelect = new StringSelectMenuBuilder() + .setCustomId('leave_message_select') + .setPlaceholder('Select a leave message or create your own.') + .addOptions(messageOptions); + + const row2 = new ActionRowBuilder().addComponents(messageSelect); + + await channelConfirmation.update({ + content: `Channel <#${channelId}> selected. Now, please select a leave message.`, + components: [row2], + }); + + const messageConfirmation = await channelReply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + const selectedMessage = messageConfirmation.values[0]; + + if (selectedMessage === 'custom') { + const modal = new ModalBuilder() + .setCustomId('leave_custom_message_modal') + .setTitle('Custom Leave Message'); + + const messageInput = new TextInputBuilder() + .setCustomId('custom_message_input') + .setLabel("Your custom message") + .setPlaceholder('Use {user} for username and {server} for server name.') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(messageInput); + modal.addComponents(firstActionRow); + + await messageConfirmation.showModal(modal); + + const modalSubmit = await interaction.awaitModalSubmit({ + time: 300000, + }); + + const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input'); + db[guildId].leaveMessage = customMessage; + writeDb(db); + + await modalSubmit.reply({ + content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`, + flags: 64, + }); + + } else { + db[guildId].leaveMessage = selectedMessage; + writeDb(db); + await messageConfirmation.update({ + content: `Leave message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`, + components: [], + }); + } + } catch (error) { + console.error('Error during leave setup:', error); + await interaction.editReply({ content: 'Configuration timed out or an error occurred.', components: [] }); + } + }, +}; diff --git a/discord-bot/commands/setup-welcome.js b/discord-bot/commands/setup-welcome.js index bb57918..8787981 100644 --- a/discord-bot/commands/setup-welcome.js +++ b/discord-bot/commands/setup-welcome.js @@ -1,63 +1,116 @@ -const { SlashCommandBuilder } = require('discord.js'); +const { SlashCommandBuilder, ActionRowBuilder, ChannelSelectMenuBuilder, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ComponentType } = require('discord.js'); const { readDb, writeDb } = require('../../backend/db.js'); +const defaultWelcomeMessages = ["Welcome to the server, {user}!", "Hey {user}, welcome!", "{user} has joined the party!"]; + module.exports = { - name: 'config-welcome', - description: 'Configure the welcome message for this server.', + name: 'setup-welcome', + description: 'Interactively set up the welcome message for this server.', enabled: true, builder: new SlashCommandBuilder() - .setName('config-welcome') - .setDescription('Configure the welcome message for this server.') - .addSubcommand(subcommand => - subcommand - .setName('set-channel') - .setDescription('Set the channel for welcome messages.') - .addChannelOption(option => - option.setName('channel') - .setDescription('The channel to send welcome messages to.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('set-message') - .setDescription('Set the welcome message.') - .addStringOption(option => - option.setName('message') - .setDescription('The welcome message. Use {user} for username mention and {server} for server name.') - .setRequired(true) - ) - ) - .addSubcommand(subcommand => - subcommand - .setName('disable') - .setDescription('Disable welcome messages.') - ), + .setName('setup-welcome') + .setDescription('Interactively set up the welcome message for this server.'), async execute(interaction) { const db = readDb(); const guildId = interaction.guildId; - const subcommand = interaction.options.getSubcommand(); if (!db[guildId]) { db[guildId] = {}; } - if (subcommand === 'set-channel') { - const channel = interaction.options.getChannel('channel'); - db[guildId].welcomeChannel = channel.id; + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId('welcome_channel_select') + .setPlaceholder('Select the channel for welcome messages.') + .setChannelTypes([0]); // Text channels + + const row1 = new ActionRowBuilder().addComponents(channelSelect); + + const channelReply = await interaction.reply({ + content: 'Please select the channel where you want welcome messages to be sent.', + components: [row1], + flags: 64, + }); + + try { + const channelConfirmation = await channelReply.awaitMessageComponent({ + componentType: ComponentType.ChannelSelect, + time: 60000, + }); + + const channelId = channelConfirmation.values[0]; + db[guildId].welcomeChannel = channelId; db[guildId].welcomeEnabled = true; - writeDb(db); - await interaction.reply(`Welcome channel set to ${channel}.`); - } else if (subcommand === 'set-message') { - const message = interaction.options.getString('message'); - db[guildId].welcomeMessage = message; - db[guildId].welcomeEnabled = true; - writeDb(db); - await interaction.reply(`Welcome message set to: "${message}"`); - } else if (subcommand === 'disable') { - db[guildId].welcomeEnabled = false; - writeDb(db); - await interaction.reply('Welcome messages disabled.'); + + const messageOptions = defaultWelcomeMessages.map(msg => ({ + label: msg.length > 100 ? msg.substring(0, 97) + '...' : msg, + value: msg, + })); + + messageOptions.push({ + label: 'Custom Message', + value: 'custom', + }); + + const messageSelect = new StringSelectMenuBuilder() + .setCustomId('welcome_message_select') + .setPlaceholder('Select a welcome message or create your own.') + .addOptions(messageOptions); + + const row2 = new ActionRowBuilder().addComponents(messageSelect); + + await channelConfirmation.update({ + content: `Channel <#${channelId}> selected. Now, please select a welcome message.`, + components: [row2], + }); + + const messageConfirmation = await channelReply.awaitMessageComponent({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + const selectedMessage = messageConfirmation.values[0]; + + if (selectedMessage === 'custom') { + const modal = new ModalBuilder() + .setCustomId('welcome_custom_message_modal') + .setTitle('Custom Welcome Message'); + + const messageInput = new TextInputBuilder() + .setCustomId('custom_message_input') + .setLabel("Your custom message") + .setPlaceholder('Use {user} for username and {server} for server name.') + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + + const firstActionRow = new ActionRowBuilder().addComponents(messageInput); + modal.addComponents(firstActionRow); + + await messageConfirmation.showModal(modal); + + const modalSubmit = await interaction.awaitModalSubmit({ + time: 300000, + }); + + const customMessage = modalSubmit.fields.getTextInputValue('custom_message_input'); + db[guildId].welcomeMessage = customMessage; + writeDb(db); + + await modalSubmit.reply({ + content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${customMessage}"`, + flags: 64, + }); + + } else { + db[guildId].welcomeMessage = selectedMessage; + writeDb(db); + await messageConfirmation.update({ + content: `Welcome message setup complete! Channel: <#${channelId}>, Message: "${selectedMessage}"`, + components: [], + }); + } + } catch (error) { + console.error('Error during welcome setup:', error); + await interaction.editReply({ content: 'Configuration timed out or an error occurred.', components: [] }); } }, }; diff --git a/discord-bot/commands/view-autorole.js b/discord-bot/commands/view-autorole.js new file mode 100644 index 0000000..1320cb8 --- /dev/null +++ b/discord-bot/commands/view-autorole.js @@ -0,0 +1,29 @@ +const { SlashCommandBuilder } = require('discord.js'); +const { readDb } = require('../../backend/db.js'); + +module.exports = { + name: 'view-autorole', + description: 'View the current autorole configuration for this server.', + enabled: true, + builder: new SlashCommandBuilder() + .setName('view-autorole') + .setDescription('View the current autorole configuration for this server.'), + async execute(interaction) { + const db = readDb(); + const guildId = interaction.guildId; + const settings = db[guildId] || {}; + const autorole = settings.autorole || { enabled: false, roleId: '' }; + + if (!autorole.enabled) { + await interaction.reply({ content: 'Autorole is currently disabled for this server.', flags: 64 }); + return; + } + + const role = interaction.guild.roles.cache.get(autorole.roleId); + if (role) { + await interaction.reply({ content: `Autorole is enabled. Selected role: ${role.toString()} (${role.name}).`, flags: 64 }); + } else { + await interaction.reply({ content: `Autorole is enabled but the selected role (ID: ${autorole.roleId}) was not found on this server.`, flags: 64 }); + } + }, +}; diff --git a/discord-bot/events/guildMemberAdd.js b/discord-bot/events/guildMemberAdd.js index 487b65d..d12dd39 100644 --- a/discord-bot/events/guildMemberAdd.js +++ b/discord-bot/events/guildMemberAdd.js @@ -19,6 +19,23 @@ module.exports = { } } } + + if (settings && settings.autorole && settings.autorole.enabled && settings.autorole.roleId) { + const role = member.guild.roles.cache.get(settings.autorole.roleId); + if (role) { + try { + // Re-check that role is assignable + const botHighest = member.guild.members.me.roles.highest.position; + if (role.id === member.guild.id || role.managed || role.position >= botHighest) { + console.warn(`Autorole ${role.id} in guild ${member.guild.id} is not assignable (everyone/managed/too high). Skipping.`); + return; + } + await member.roles.add(role); + } catch (error) { + console.error(`Could not assign autorole in guild ${member.guild.id}:`, error); + } + } + } } catch (error) { console.error(`Error in guildMemberAdd event for guild ${member.guild.id}:`, error); } diff --git a/discord-bot/events/guildMemberRemove.js b/discord-bot/events/guildMemberRemove.js index c6f1d3d..f67df42 100644 --- a/discord-bot/events/guildMemberRemove.js +++ b/discord-bot/events/guildMemberRemove.js @@ -9,14 +9,31 @@ module.exports = { const settings = db[member.guild.id]; if (settings && settings.leaveEnabled && settings.leaveChannel) { - const channel = member.guild.channels.cache.get(settings.leaveChannel); - if (channel) { + let channel = member.guild.channels.cache.get(settings.leaveChannel); + if (!channel) { try { - const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', member.user.tag).replace('{server}', member.guild.name); + channel = await member.guild.channels.fetch(settings.leaveChannel); + } catch (err) { + return; + } + } + + if (channel && channel.isTextBased && channel.isTextBased()) { + try { + const me = member.guild.members.me; + const perms = channel.permissionsFor(me); + if (!perms || !perms.has('ViewChannel') || !perms.has('SendMessages')) { + return; + } + + const userMention = member.user ? (member.user.toString ? member.user.toString() : member.user.tag) : 'A user'; + const message = (settings.leaveMessage || '{user} has left the server.').replace('{user}', userMention).replace('{server}', member.guild.name); await channel.send(message); } catch (error) { console.error(`Could not send leave message to channel ${settings.leaveChannel} in guild ${member.guild.id}:`, error); } + } else { + return; } } } catch (error) { diff --git a/discord-bot/handlers/command-handler.js b/discord-bot/handlers/command-handler.js index 9cf6d1c..5f2bc5b 100644 --- a/discord-bot/handlers/command-handler.js +++ b/discord-bot/handlers/command-handler.js @@ -7,9 +7,9 @@ module.exports = (client) => { for (const file of commandFiles) { const filePath = path.join(commandsPath, file); + // Clear require cache to allow updates during development + delete require.cache[require.resolve(filePath)]; const command = require(filePath); - if (command.enabled === false) continue; - if (command.name) { client.commands.set(command.name, command); } diff --git a/discord-bot/index.js b/discord-bot/index.js index e51ca7a..c798443 100644 --- a/discord-bot/index.js +++ b/discord-bot/index.js @@ -2,6 +2,7 @@ const { Client, GatewayIntentBits, Collection } = require('discord.js'); const fs = require('fs'); const path = require('path'); const deployCommands = require('./deploy-commands'); +const { readDb } = require('../backend/db'); const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers] }); @@ -20,11 +21,35 @@ client.on('interactionCreate', async interaction => { if (!command) return; + // Check per-guild toggles try { - await command.execute(interaction); + const db = readDb(); + const guildSettings = db[interaction.guildId] || {}; + const toggles = guildSettings.commandToggles || {}; + const protectedCommands = ['manage-commands', 'help']; + + // If command is protected, always allow + if (!protectedCommands.includes(command.name)) { + if (toggles[command.name] === false) { + await interaction.reply({ content: 'This command has been disabled on this server.', flags: 64 }); + return; + } + // If the module-level enabled flag is false, treat as disabled too + if (command.enabled === false) { + await interaction.reply({ content: 'This command is currently disabled globally.', flags: 64 }); + return; + } + } + + try { + await command.execute(interaction); + } catch (error) { + console.error(error); + await interaction.reply({ content: 'There was an error while executing this command!', flags: 64 }); + } } catch (error) { - console.error(error); - await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); + console.error('Error checking command toggles:', error); + await interaction.reply({ content: 'Internal error occurred.', flags: 64 }); } }); diff --git a/frontend/src/App.js b/frontend/src/App.js index 658474a..ec99ff8 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -6,6 +6,10 @@ import { CssBaseline } from '@mui/material'; import Login from './components/Login'; import Dashboard from './components/Dashboard'; import ServerSettings from './components/ServerSettings'; +import NavBar from './components/NavBar'; +import HelpPage from './components/HelpPage'; +import ContactPage from './components/ContactPage'; +import DiscordPage from './components/DiscordPage'; function App() { return ( @@ -13,10 +17,14 @@ function App() { + } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/components/ContactPage.js b/frontend/src/components/ContactPage.js new file mode 100644 index 0000000..d131198 --- /dev/null +++ b/frontend/src/components/ContactPage.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +const ContactPage = () => ( + + Contact + For support, contact support@example.com + +); + +export default ContactPage; diff --git a/frontend/src/components/DiscordPage.js b/frontend/src/components/DiscordPage.js new file mode 100644 index 0000000..51234af --- /dev/null +++ b/frontend/src/components/DiscordPage.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { Box, Typography } from '@mui/material'; + +const DiscordPage = () => ( + + Discord! + Open the Discord invite or community links here. + +); + +export default DiscordPage; diff --git a/frontend/src/components/HelpPage.js b/frontend/src/components/HelpPage.js new file mode 100644 index 0000000..d2c6426 --- /dev/null +++ b/frontend/src/components/HelpPage.js @@ -0,0 +1,46 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import axios from 'axios'; +import { Box, IconButton, Typography } from '@mui/material'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +const HelpPage = () => { + const { guildId } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const [commands, setCommands] = useState([]); + + useEffect(() => { + axios.get(`http://localhost:3002/api/servers/${guildId}/commands`) + .then(res => setCommands(res.data || [])) + .catch(() => setCommands([])); + }, [guildId]); + + const handleBack = () => { + // Navigate back to server settings and instruct it to open Commands accordion + navigate(`/server/${guildId}`, { state: { openCommands: true } }); + } + + return ( +
+ + + Help - Commands for this Server + + + {commands.length === 0 && No commands available.} + {commands.map(cmd => ( + + + /{cmd.name} + {cmd.locked ? 'Locked' : (cmd.enabled ? 'Enabled' : 'Disabled')} + + {cmd.description} + + ))} + +
+ ); +} + +export default HelpPage; diff --git a/frontend/src/components/NameBar.js b/frontend/src/components/NameBar.js new file mode 100644 index 0000000..c54cdfb --- /dev/null +++ b/frontend/src/components/NameBar.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { AppBar, Toolbar, Button, Box } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; + +const NameBar = () => { + const navigate = useNavigate(); + + return ( + + + + + + + + + + ); +}; + +export default NameBar; diff --git a/frontend/src/components/NavBar.js b/frontend/src/components/NavBar.js new file mode 100644 index 0000000..7cd2a64 --- /dev/null +++ b/frontend/src/components/NavBar.js @@ -0,0 +1,34 @@ +import React, { useState } from 'react'; +import { AppBar, Toolbar, Button, Box, IconButton, Collapse } from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; + +const NavBar = () => { + const [open, setOpen] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + // Pull guildId from URL if present + const guildIdMatch = location.pathname.match(/\/server\/(\d+)/); + const guildId = guildIdMatch ? guildIdMatch[1] : null; + + return ( + + + setOpen(prev => !prev)} aria-label="toggle menu"> + + + + + + {guildId && ( + + )} + + + + + ); +}; + +export default NavBar; diff --git a/frontend/src/components/ServerSettings.js b/frontend/src/components/ServerSettings.js index e939317..97c24f6 100644 --- a/frontend/src/components/ServerSettings.js +++ b/frontend/src/components/ServerSettings.js @@ -17,6 +17,13 @@ const ServerSettings = () => { const [server, setServer] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [channels, setChannels] = useState([]); + const [roles, setRoles] = useState([]); + const [autoroleSettings, setAutoroleSettings] = useState({ + enabled: false, + roleId: '', + }); + const [commandsList, setCommandsList] = useState([]); + const [commandsExpanded, setCommandsExpanded] = useState(false); const [welcomeLeaveSettings, setWelcomeLeaveSettings] = useState({ welcome: { enabled: false, @@ -79,8 +86,53 @@ const ServerSettings = () => { } }); + // Fetch roles + axios.get(`http://localhost:3002/api/servers/${guildId}/roles`) + .then(response => { + setRoles(response.data); + }); + + // Fetch autorole settings + axios.get(`http://localhost:3002/api/servers/${guildId}/autorole-settings`) + .then(response => { + if (response.data) { + setAutoroleSettings(response.data); + } + }); + + // Fetch commands/help list + axios.get(`http://localhost:3002/api/servers/${guildId}/commands`) + .then(response => { + setCommandsList(response.data || []); + }) + .catch(() => setCommandsList([])); + + // Open commands accordion if navigated from Help back button + if (location.state && location.state.openCommands) { + setCommandsExpanded(true); + } + }, [guildId, location.state]); + const handleAutoroleSettingUpdate = (newSettings) => { + axios.post(`http://localhost:3002/api/servers/${guildId}/autorole-settings`, newSettings) + .then(response => { + if (response.data.success) { + setAutoroleSettings(newSettings); + } + }); + }; + + const handleAutoroleToggleChange = (event) => { + const newSettings = { ...autoroleSettings, enabled: event.target.checked }; + handleAutoroleSettingUpdate(newSettings); + }; + + const handleAutoroleRoleChange = (event) => { + const newSettings = { ...autoroleSettings, roleId: event.target.value }; + handleAutoroleSettingUpdate(newSettings); + }; + const handleSettingUpdate = (newSettings) => { axios.post(`http://localhost:3002/api/servers/${guildId}/welcome-leave-settings`, newSettings) .then(response => { @@ -193,7 +245,7 @@ const ServerSettings = () => { - + setCommandsExpanded(prev => !prev)}> }> Commands @@ -201,12 +253,18 @@ const ServerSettings = () => { {!isBotInServer && Invite the bot to enable commands.} Ping Command - + + + + + {/* Help moved to dedicated Help page */} }> Welcome/Leave @@ -297,6 +355,32 @@ const ServerSettings = () => { + + }> + Autorole + + + {!isBotInServer && Invite the bot to enable this feature.} + + } + label="Enable Autorole" + /> + + + + + + }> Admin Commands