From 61a376cfbb5fbb706cb15b23d1a8036c8325fe47 Mon Sep 17 00:00:00 2001 From: Chris Ham <431647+greenham@users.noreply.github.com> Date: Sat, 16 Aug 2025 18:07:07 -0700 Subject: [PATCH] Modernize role management system with slash commands and role IDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert role management from prefix to slash commands (/role add/remove/list) - Update database schema to store role IDs as JSON arrays instead of regex patterns - Add /config roles command for administrators to manage allowed roles - Simplify database schema by reusing allowed_roles_for_request field as JSON - Add database reset script (pnpm reset-db) for easy testing and migration - Update config format to only support array format (no backward compatibility) Role Management Features: - /role add - Self-assign roles with dropdown selection - /role remove - Remove roles with dropdown selection - /role list - Show available self-assignable roles - /config roles add/remove/list/clear - Administrator role management Technical Improvements: - Role ID based matching (more reliable than name-based regex) - Type-safe role selection with Discord's native role picker - Permission hierarchy validation - Rich embed responses with proper error handling - Ephemeral responses for clean chat experience šŸ¤– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 1 + src/commands/slash/config.js | 120 ++++++++++++- src/commands/slash/role.js | 182 +++++++++++++++++++ src/config/config.js | 57 +++--- src/services/databaseService.js | 299 ++++++++++++++++++++++---------- 5 files changed, 535 insertions(+), 124 deletions(-) create mode 100644 src/commands/slash/role.js diff --git a/package.json b/package.json index adfca1a..2e85985 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "restart": "docker compose restart", "logs": "docker compose logs -f", "boom": "pnpm stop && pnpm build && pnpm start", + "reset-db": "pnpm stop && rm -f data/ghbot.db data/ghbot.db-shm data/ghbot.db-wal && echo 'Database reset complete. Run pnpm start to re-seed from config.json'", "image:build": "docker build -t ghbot:${VERSION:-latest} .", "image:run": "docker run -d --name ghbot --restart always ghbot:${VERSION:-latest}" }, diff --git a/src/commands/slash/config.js b/src/commands/slash/config.js index 25ad258..25dd8e3 100644 --- a/src/commands/slash/config.js +++ b/src/commands/slash/config.js @@ -2,6 +2,29 @@ const { SlashCommandBuilder, EmbedBuilder, PermissionFlagsBits, MessageFlags } = const configManager = require('../../config/config'); module.exports = { + async formatAllowedRoles(guild, guildConfig) { + const databaseService = configManager.databaseService; + if (!databaseService) return 'Database unavailable'; + + const allowedRoleIds = databaseService.getAllowedRoleIds(guild.id); + + if (allowedRoleIds.length === 0) { + return 'None configured'; + } + + const roles = []; + for (const roleId of allowedRoleIds) { + try { + const role = await guild.roles.fetch(roleId); + if (role) roles.push(role.name); + } catch (error) { + roles.push(``); + } + } + + return roles.length > 0 ? roles.join(', ') : 'None configured'; + }, + data: new SlashCommandBuilder() .setName('config') .setDescription('Manage server configuration') @@ -77,10 +100,21 @@ module.exports = { .addSubcommand(subcommand => subcommand .setName('roles') - .setDescription('Set roles that users can self-assign') + .setDescription('Manage self-assignable roles') .addStringOption(option => - option.setName('pattern') - .setDescription('Role pattern (pipe-separated, e.g., "streamer|vip|member")') + option.setName('action') + .setDescription('Action to perform') + .setRequired(true) + .addChoices( + { name: 'Add role to list', value: 'add' }, + { name: 'Remove role from list', value: 'remove' }, + { name: 'Clear all roles', value: 'clear' }, + { name: 'Show current roles', value: 'list' } + ) + ) + .addRoleOption(option => + option.setName('role') + .setDescription('The role to add or remove (not needed for list/clear)') .setRequired(false) ) ), @@ -107,7 +141,7 @@ module.exports = { { name: 'Fun Facts', value: guildConfig.enableFunFacts ? 'āœ… Enabled' : 'āŒ Disabled', inline: true }, { name: 'Ham Facts', value: guildConfig.enableHamFacts ? 'āœ… Enabled' : 'āŒ Disabled', inline: true }, { name: 'Allowed SFX Channels', value: guildConfig.allowedSfxChannels || 'All channels', inline: false }, - { name: 'Allowed Roles', value: guildConfig.allowedRolesForRequest || 'None configured', inline: false }, + { name: 'Self-Assignable Roles', value: await this.formatAllowedRoles(interaction.guild, guildConfig), inline: false }, ]) .setFooter({ text: 'Use /config commands to modify settings' }); @@ -158,11 +192,79 @@ module.exports = { break; case 'roles': - const rolePattern = interaction.options.getString('pattern'); - newConfig.allowedRolesForRequest = rolePattern || null; - updateMessage = rolePattern - ? `Self-assignable roles set to: \`${rolePattern}\`` - : 'Self-assignable roles cleared'; + const action = interaction.options.getString('action'); + const role = interaction.options.getRole('role'); + + if (action === 'list') { + const allowedRoleIds = databaseService.getAllowedRoleIds(interaction.guild.id); + + if (allowedRoleIds.length === 0) { + return interaction.reply({ + content: 'āŒ No self-assignable roles are currently configured.', + flags: [MessageFlags.Ephemeral] + }); + } + + // Get role objects from IDs + const roles = []; + for (const roleId of allowedRoleIds) { + try { + const roleObj = await interaction.guild.roles.fetch(roleId); + if (roleObj) roles.push(roleObj); + } catch (error) { + console.warn(`Role ${roleId} not found in guild ${interaction.guild.id}`); + } + } + + const embed = new EmbedBuilder() + .setTitle('šŸ“‹ Self-Assignable Roles Configuration') + .setDescription(roles.length > 0 ? 'Currently configured roles:' : 'No valid roles found.') + .setColor(0x21c629) + .addFields(roles.length > 0 ? { + name: 'Allowed Roles', + value: roles.map(r => `• ${r}`).join('\n'), + inline: false + } : { + name: 'Status', + value: 'No roles configured or all configured roles have been deleted.', + inline: false + }); + + return interaction.reply({ embeds: [embed] }); + } + + if (action === 'clear') { + databaseService.updateAllowedRoleIds(interaction.guild.id, []); + updateMessage = 'Self-assignable roles list cleared'; + updated = true; + break; + } + + if (!role) { + return interaction.reply({ + content: 'āŒ You must specify a role for add/remove actions.', + flags: [MessageFlags.Ephemeral] + }); + } + + // Check if bot can manage this role + if (!interaction.guild.members.me.permissions.has('ManageRoles') || + role.position >= interaction.guild.members.me.roles.highest.position) { + return interaction.reply({ + content: `āŒ I cannot manage the **${role.name}** role due to permission hierarchy.`, + flags: [MessageFlags.Ephemeral] + }); + } + + if (action === 'add') { + databaseService.addAllowedRole(interaction.guild.id, role.id); + updateMessage = `Added **${role.name}** to self-assignable roles`; + } else if (action === 'remove') { + databaseService.removeAllowedRole(interaction.guild.id, role.id); + updateMessage = `Removed **${role.name}** from self-assignable roles`; + } + + updated = true; break; } diff --git a/src/commands/slash/role.js b/src/commands/slash/role.js new file mode 100644 index 0000000..d50f816 --- /dev/null +++ b/src/commands/slash/role.js @@ -0,0 +1,182 @@ +const { SlashCommandBuilder, EmbedBuilder, MessageFlags } = require('discord.js'); +const configManager = require('../../config/config'); + +module.exports = { + data: new SlashCommandBuilder() + .setName('role') + .setDescription('Manage your roles') + .addSubcommand(subcommand => + subcommand + .setName('add') + .setDescription('Add a role to yourself') + .addRoleOption(option => + option.setName('role') + .setDescription('The role to add') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('remove') + .setDescription('Remove a role from yourself') + .addRoleOption(option => + option.setName('role') + .setDescription('The role to remove') + .setRequired(true) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('Show available self-assignable roles') + ), + + async execute(interaction, guildConfig) { + const subcommand = interaction.options.getSubcommand(); + const databaseService = configManager.databaseService; + + if (!databaseService) { + return interaction.reply({ + content: 'āŒ Database service not available.', + flags: [MessageFlags.Ephemeral] + }); + } + + // Get allowed role IDs for this guild + const allowedRoleIds = databaseService.getAllowedRoleIds(interaction.guild.id); + + if (subcommand === 'list') { + if (allowedRoleIds.length === 0) { + return interaction.reply({ + content: 'āŒ No roles are currently available for self-assignment on this server.', + flags: [MessageFlags.Ephemeral] + }); + } + + // Get role objects from IDs + const roles = []; + for (const roleId of allowedRoleIds) { + try { + const role = await interaction.guild.roles.fetch(roleId); + if (role) { + roles.push(role); + } + } catch (error) { + console.warn(`Role ${roleId} not found in guild ${interaction.guild.id}`); + } + } + + if (roles.length === 0) { + return interaction.reply({ + content: 'āŒ No valid self-assignable roles found. The configured roles may have been deleted.', + flags: [MessageFlags.Ephemeral] + }); + } + + const embed = new EmbedBuilder() + .setTitle('šŸ“‹ Available Self-Assignable Roles') + .setDescription('You can add or remove these roles using `/role add` or `/role remove`:') + .setColor(0x21c629) + .addFields({ + name: 'Available Roles', + value: roles.map(role => `• ${role}`).join('\n'), + inline: false + }) + .setFooter({ text: 'Use /role add or /role remove to manage your roles' }); + + return interaction.reply({ + embeds: [embed], + flags: [MessageFlags.Ephemeral] + }); + } + + // Handle add/remove subcommands + const targetRole = interaction.options.getRole('role'); + + // Check if the role is in the allowed list + if (!allowedRoleIds.includes(targetRole.id)) { + return interaction.reply({ + content: `āŒ **${targetRole.name}** is not available for self-assignment. Use \`/role list\` to see available roles.`, + flags: [MessageFlags.Ephemeral] + }); + } + + // Check if bot can manage this role + if (!interaction.guild.members.me.permissions.has('ManageRoles') || + targetRole.position >= interaction.guild.members.me.roles.highest.position) { + return interaction.reply({ + content: `āŒ I don't have permission to manage the **${targetRole.name}** role. Please contact an administrator.`, + flags: [MessageFlags.Ephemeral] + }); + } + + try { + if (subcommand === 'add') { + // Check if user already has the role + if (interaction.member.roles.cache.has(targetRole.id)) { + return interaction.reply({ + content: `āŒ You already have the **${targetRole.name}** role.`, + flags: [MessageFlags.Ephemeral] + }); + } + + await interaction.member.roles.add(targetRole, 'User requested via slash command'); + + const embed = new EmbedBuilder() + .setTitle('āœ… Role Added') + .setDescription(`Successfully added the **${targetRole.name}** role to your account.`) + .setColor(0x00ff00) + .setFooter({ text: 'Use /role remove to remove roles' }); + + await interaction.reply({ + embeds: [embed], + flags: [MessageFlags.Ephemeral] + }); + + console.log(`Added role ${targetRole.name} to ${interaction.user.username} in ${interaction.guild.name}`); + + } else if (subcommand === 'remove') { + // Check if user has the role + if (!interaction.member.roles.cache.has(targetRole.id)) { + return interaction.reply({ + content: `āŒ You don't have the **${targetRole.name}** role.`, + flags: [MessageFlags.Ephemeral] + }); + } + + await interaction.member.roles.remove(targetRole, 'User requested via slash command'); + + const embed = new EmbedBuilder() + .setTitle('āœ… Role Removed') + .setDescription(`Successfully removed the **${targetRole.name}** role from your account.`) + .setColor(0x00ff00) + .setFooter({ text: 'Use /role add to add roles' }); + + await interaction.reply({ + embeds: [embed], + flags: [MessageFlags.Ephemeral] + }); + + console.log(`Removed role ${targetRole.name} from ${interaction.user.username} in ${interaction.guild.name}`); + } + + } catch (error) { + console.error(`Error managing role ${targetRole.name}:`, error); + + const embed = new EmbedBuilder() + .setTitle('āŒ Role Management Error') + .setDescription(`I encountered an error managing the **${targetRole.name}** role. Please contact an administrator.`) + .setColor(0xff0000) + .addFields({ + name: 'Possible Issues', + value: '• Bot lacks Manage Roles permission\n• Role is higher than bot\'s highest role\n• Role is managed by an integration', + inline: false + }); + + await interaction.reply({ + embeds: [embed], + flags: [MessageFlags.Ephemeral] + }); + } + } +}; \ No newline at end of file diff --git a/src/config/config.js b/src/config/config.js index bffa04a..57c632b 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -13,22 +13,24 @@ class ConfigManager { */ loadFileConfig() { const configPath = path.join(__dirname, "..", "..", "config.json"); - + if (!fs.existsSync(configPath)) { console.warn("config.json not found, using environment variables only"); return { discord: { token: process.env.DISCORD_TOKEN, adminUserId: process.env.ADMIN_USER_ID, - } + }, }; } const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - + // Validate required fields if (!config.discord?.token && !process.env.DISCORD_TOKEN) { - throw new Error("Discord token is required in config.json or DISCORD_TOKEN environment variable"); + throw new Error( + "Discord token is required in config.json or DISCORD_TOKEN environment variable" + ); } return config; @@ -46,19 +48,30 @@ class ConfigManager { */ getBotConfig() { const fileConfig = this.fileConfig; - const dbConfig = this.databaseService ? this.databaseService.getBotConfiguration() : {}; + const dbConfig = this.databaseService + ? this.databaseService.getBotConfiguration() + : {}; return { // Use file config as fallback, database as primary - botName: dbConfig.botName || fileConfig.botName || 'GHBot', - debug: dbConfig.debug !== undefined ? dbConfig.debug : (fileConfig.debug || false), + botName: dbConfig.botName || fileConfig.botName || "GHBot", + debug: + dbConfig.debug !== undefined + ? dbConfig.debug + : fileConfig.debug || false, discord: { token: fileConfig.discord?.token || process.env.DISCORD_TOKEN, - adminUserId: dbConfig.adminUserId || fileConfig.discord?.adminUserId || process.env.ADMIN_USER_ID, - activities: dbConfig.activities || fileConfig.discord?.activities || ['Playing sounds', 'Serving facts'], - blacklistedUsers: dbConfig.blacklistedUsers || fileConfig.discord?.blacklistedUsers || [], - master: fileConfig.discord?.master !== false, // Default to true - } + adminUserId: + dbConfig.adminUserId || + fileConfig.discord?.adminUserId || + process.env.ADMIN_USER_ID, + activities: dbConfig.activities || + fileConfig.discord?.activities || ["Playing sounds", "Serving facts"], + blacklistedUsers: + dbConfig.blacklistedUsers || + fileConfig.discord?.blacklistedUsers || + [], + }, }; } @@ -75,19 +88,19 @@ class ConfigManager { // Fallback to file config for backward compatibility if (this.fileConfig.discord?.guilds) { - const guilds = Array.isArray(this.fileConfig.discord.guilds) - ? this.fileConfig.discord.guilds + const guilds = Array.isArray(this.fileConfig.discord.guilds) + ? this.fileConfig.discord.guilds : Object.values(this.fileConfig.discord.guilds); - - return guilds.find(g => g.id === guildId); + + return guilds.find((g) => g.id === guildId); } // Return default config for new guilds return { id: guildId, - name: 'Unknown Guild', - internalName: 'Unknown Guild', - prefix: '!', + name: "Unknown Guild", + internalName: "Unknown Guild", + prefix: "!", enableSfx: true, allowedSfxChannels: null, sfxVolume: 0.5, @@ -107,10 +120,10 @@ class ConfigManager { // Fallback to file config if (this.fileConfig.discord?.guilds) { - const guilds = Array.isArray(this.fileConfig.discord.guilds) - ? this.fileConfig.discord.guilds + const guilds = Array.isArray(this.fileConfig.discord.guilds) + ? this.fileConfig.discord.guilds : Object.values(this.fileConfig.discord.guilds); - + return guilds; } diff --git a/src/services/databaseService.js b/src/services/databaseService.js index 57577c2..a361f7d 100644 --- a/src/services/databaseService.js +++ b/src/services/databaseService.js @@ -1,15 +1,15 @@ -const Database = require('better-sqlite3'); -const path = require('path'); +const Database = require("better-sqlite3"); +const path = require("path"); class DatabaseService { constructor() { // Store database in data directory - const dbPath = path.join(__dirname, '..', '..', 'data', 'ghbot.db'); + const dbPath = path.join(__dirname, "..", "..", "data", "ghbot.db"); this.db = new Database(dbPath); - + // Enable WAL mode for better concurrent access - this.db.pragma('journal_mode = WAL'); - + this.db.pragma("journal_mode = WAL"); + this.initializeTables(); } @@ -29,7 +29,7 @@ class DatabaseService { sfx_volume REAL DEFAULT 0.5, enable_fun_facts BOOLEAN DEFAULT true, enable_ham_facts BOOLEAN DEFAULT true, - allowed_roles_for_request TEXT, + allowed_roles_for_request TEXT DEFAULT '[]', is_active BOOLEAN DEFAULT true, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -73,11 +73,11 @@ class DatabaseService { ('blacklisted_users', '[]') `); - console.log('Database tables initialized'); - + console.log("Database tables initialized"); + // Prepare statements after tables are created this.prepareStatements(); - + // Run migrations after statements are prepared this.runMigrations(); } @@ -87,10 +87,14 @@ class DatabaseService { */ runMigrations() { // Check if we need to seed from config file - const guildCount = this.db.prepare('SELECT COUNT(*) as count FROM guilds').get().count; - + const guildCount = this.db + .prepare("SELECT COUNT(*) as count FROM guilds") + .get().count; + if (guildCount === 0) { - console.log('No guilds found in database, checking for config file to seed...'); + console.log( + "No guilds found in database, checking for config file to seed..." + ); this.seedFromConfigFile(); } } @@ -100,94 +104,119 @@ class DatabaseService { */ seedFromConfigFile() { try { - const fs = require('fs'); - const path = require('path'); - - const configPath = path.join(__dirname, '..', '..', 'config.json'); - + const fs = require("fs"); + const path = require("path"); + + const configPath = path.join(__dirname, "..", "..", "config.json"); + if (!fs.existsSync(configPath)) { - console.log('No config.json file found, skipping seed'); + console.log("No config.json file found, skipping seed"); return; } - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - + const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); + if (!config.discord?.guilds) { - console.log('No guilds found in config.json, skipping seed'); + console.log("No guilds found in config.json, skipping seed"); return; } - // Handle both array and object formats for backward compatibility - const guilds = Array.isArray(config.discord.guilds) - ? config.discord.guilds - : Object.values(config.discord.guilds); + // Expect guilds to be an array + const guilds = config.discord.guilds; + + if (!Array.isArray(guilds)) { + console.log("Config guilds must be an array format, skipping seed"); + return; + } let seededCount = 0; - + for (const guild of guilds) { if (!guild.id) { - console.warn('Skipping guild with missing ID:', guild); + console.warn("Skipping guild with missing ID:", guild); continue; } - // Convert old config format to new format + // Convert config format to database format const guildConfig = { id: guild.id, - name: guild.internalName || guild.name || 'Unknown Guild', - internalName: guild.internalName || guild.name || 'Unknown Guild', - prefix: guild.prefix || '!', + name: guild.internalName || guild.name || "Unknown Guild", + internalName: guild.internalName || guild.name || "Unknown Guild", + prefix: guild.prefix || "!", enableSfx: guild.enableSfx !== false, allowedSfxChannels: guild.allowedSfxChannels || null, sfxVolume: guild.sfxVolume || 0.5, enableFunFacts: guild.enableFunFacts !== false, enableHamFacts: guild.enableHamFacts !== false, - allowedRolesForRequest: guild.allowedRolesForRequest || null, + allowedRolesForRequest: Array.isArray(guild.allowedRolesForRequest) + ? guild.allowedRolesForRequest.filter( + (id) => id && id.trim() !== "" + ) + : [], }; // Insert guild configuration this.upsertGuildConfig(guildConfig); - + // Insert scheduled events if they exist if (guild.scheduledEvents && Array.isArray(guild.scheduledEvents)) { for (const event of guild.scheduledEvents) { if (event.id && event.schedule) { try { - console.log(`Importing scheduled event: ${event.id} for guild ${guild.id}`); + console.log( + `Importing scheduled event: ${event.id} for guild ${guild.id}` + ); this.addScheduledEvent(guild.id, event); } catch (error) { - console.warn(`Skipping scheduled event ${event.id} for guild ${guild.id}:`, error.message); - console.warn('Event object:', JSON.stringify(event, null, 2)); + console.warn( + `Skipping scheduled event ${event.id} for guild ${guild.id}:`, + error.message + ); + console.warn("Event object:", JSON.stringify(event, null, 2)); } } } } - + seededCount++; } - console.log(`āœ… Successfully seeded database with ${seededCount} guild(s) from config.json`); - + console.log( + `āœ… Successfully seeded database with ${seededCount} guild(s) from config.json` + ); + // Update bot configuration in database from file config if (config.botName) { - this.setBotConfig('bot_name', config.botName); + this.setBotConfig("bot_name", config.botName); } if (config.debug !== undefined) { - this.setBotConfig('debug', config.debug.toString()); + this.setBotConfig("debug", config.debug.toString()); } if (config.discord?.adminUserId) { - this.setBotConfig('admin_user_id', config.discord.adminUserId); + this.setBotConfig("admin_user_id", config.discord.adminUserId); } - if (config.discord?.activities && Array.isArray(config.discord.activities)) { - this.setBotConfig('activities', JSON.stringify(config.discord.activities)); + if ( + config.discord?.activities && + Array.isArray(config.discord.activities) + ) { + this.setBotConfig( + "activities", + JSON.stringify(config.discord.activities) + ); } - if (config.discord?.blacklistedUsers && Array.isArray(config.discord.blacklistedUsers)) { - this.setBotConfig('blacklisted_users', JSON.stringify(config.discord.blacklistedUsers)); + if ( + config.discord?.blacklistedUsers && + Array.isArray(config.discord.blacklistedUsers) + ) { + this.setBotConfig( + "blacklisted_users", + JSON.stringify(config.discord.blacklistedUsers) + ); } - console.log('āœ… Bot configuration updated from config.json'); - + console.log("āœ… Bot configuration updated from config.json"); } catch (error) { - console.error('Error seeding database from config file:', error); + console.error("Error seeding database from config file:", error); } } @@ -197,13 +226,17 @@ class DatabaseService { prepareStatements() { this.statements = { // Guild operations - getGuild: this.db.prepare('SELECT * FROM guilds WHERE id = ? AND is_active = true'), - getAllGuilds: this.db.prepare('SELECT * FROM guilds WHERE is_active = true'), + getGuild: this.db.prepare( + "SELECT * FROM guilds WHERE id = ? AND is_active = true" + ), + getAllGuilds: this.db.prepare( + "SELECT * FROM guilds WHERE is_active = true" + ), insertGuild: this.db.prepare(` INSERT OR REPLACE INTO guilds (id, name, internal_name, prefix, enable_sfx, allowed_sfx_channels, sfx_volume, - enable_fun_facts, enable_ham_facts, allowed_roles_for_request, is_active, updated_at, removed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, true, CURRENT_TIMESTAMP, NULL) + enable_fun_facts, enable_ham_facts, allowed_roles_for_request) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), updateGuild: this.db.prepare(` UPDATE guilds SET @@ -220,19 +253,25 @@ class DatabaseService { UPDATE guilds SET is_active = true, removed_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? `), - hardDeleteGuild: this.db.prepare('DELETE FROM guilds WHERE id = ?'), + hardDeleteGuild: this.db.prepare("DELETE FROM guilds WHERE id = ?"), // Scheduled events - getScheduledEvents: this.db.prepare('SELECT * FROM scheduled_events WHERE guild_id = ? AND enabled = true'), + getScheduledEvents: this.db.prepare( + "SELECT * FROM scheduled_events WHERE guild_id = ? AND enabled = true" + ), insertScheduledEvent: this.db.prepare(` INSERT OR REPLACE INTO scheduled_events (guild_id, event_id, schedule, channel_id, message, ping_role_id, enabled) VALUES (?, ?, ?, ?, ?, ?, ?) `), - deleteScheduledEvent: this.db.prepare('DELETE FROM scheduled_events WHERE guild_id = ? AND event_id = ?'), + deleteScheduledEvent: this.db.prepare( + "DELETE FROM scheduled_events WHERE guild_id = ? AND event_id = ?" + ), // Bot config - getBotConfig: this.db.prepare('SELECT value FROM bot_config WHERE key = ?'), + getBotConfig: this.db.prepare( + "SELECT value FROM bot_config WHERE key = ?" + ), setBotConfig: this.db.prepare(` INSERT OR REPLACE INTO bot_config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) @@ -242,7 +281,7 @@ class DatabaseService { /** * Get guild configuration - * @param {string} guildId + * @param {string} guildId * @returns {Object|null} */ getGuildConfig(guildId) { @@ -259,7 +298,9 @@ class DatabaseService { sfxVolume: guild.sfx_volume, enableFunFacts: Boolean(guild.enable_fun_facts), enableHamFacts: Boolean(guild.enable_ham_facts), - allowedRolesForRequest: guild.allowed_roles_for_request, + allowedRolesForRequest: this.parseRoleIds( + guild.allowed_roles_for_request + ), }; } @@ -269,7 +310,7 @@ class DatabaseService { */ getAllGuildConfigs() { const guilds = this.statements.getAllGuilds.all(); - return guilds.map(guild => ({ + return guilds.map((guild) => ({ id: guild.id, name: guild.name, internalName: guild.internal_name, @@ -285,13 +326,15 @@ class DatabaseService { /** * Add or update guild configuration - * @param {Object} guildConfig + * @param {Object} guildConfig * @param {boolean} isReactivation - Whether this is reactivating an existing guild */ upsertGuildConfig(guildConfig, isReactivation = false) { if (isReactivation) { // Check if guild exists but is inactive - const existingGuild = this.db.prepare('SELECT * FROM guilds WHERE id = ?').get(guildConfig.id); + const existingGuild = this.db + .prepare("SELECT * FROM guilds WHERE id = ?") + .get(guildConfig.id); if (existingGuild && !existingGuild.is_active) { // Reactivate existing guild and update its info this.statements.reactivateGuild.run(guildConfig.id); @@ -308,7 +351,9 @@ class DatabaseService { existingGuild.allowed_roles_for_request, guildConfig.id ); - console.log(`Guild reactivated with existing configuration: ${guildConfig.name} (${guildConfig.id})`); + console.log( + `Guild reactivated with existing configuration: ${guildConfig.name} (${guildConfig.id})` + ); return; } } @@ -318,21 +363,23 @@ class DatabaseService { guildConfig.id, guildConfig.name, guildConfig.internalName || guildConfig.name, - guildConfig.prefix || '!', + guildConfig.prefix || "!", guildConfig.enableSfx !== false ? 1 : 0, guildConfig.allowedSfxChannels || null, guildConfig.sfxVolume || 0.5, guildConfig.enableFunFacts !== false ? 1 : 0, guildConfig.enableHamFacts !== false ? 1 : 0, - guildConfig.allowedRolesForRequest || null + JSON.stringify(guildConfig.allowedRolesForRequest || []) + ); + + console.log( + `Guild configuration saved: ${guildConfig.name} (${guildConfig.id})` ); - - console.log(`Guild configuration saved: ${guildConfig.name} (${guildConfig.id})`); } /** * Soft delete guild configuration (can be restored) - * @param {string} guildId + * @param {string} guildId */ softDeleteGuildConfig(guildId) { const result = this.statements.softDeleteGuild.run(guildId); @@ -344,7 +391,7 @@ class DatabaseService { /** * Hard delete guild configuration (permanent) - * @param {string} guildId + * @param {string} guildId */ hardDeleteGuildConfig(guildId) { const result = this.statements.hardDeleteGuild.run(guildId); @@ -356,11 +403,13 @@ class DatabaseService { /** * Check if guild exists (including inactive) - * @param {string} guildId + * @param {string} guildId * @returns {Object|null} */ getGuildConfigIncludingInactive(guildId) { - const guild = this.db.prepare('SELECT * FROM guilds WHERE id = ?').get(guildId); + const guild = this.db + .prepare("SELECT * FROM guilds WHERE id = ?") + .get(guildId); if (!guild) return null; return { @@ -381,22 +430,22 @@ class DatabaseService { /** * Get scheduled events for a guild - * @param {string} guildId + * @param {string} guildId * @returns {Array} */ getScheduledEvents(guildId) { const events = this.statements.getScheduledEvents.all(guildId); - + // Parse schedule strings back to objects/strings for node-schedule - return events.map(event => ({ + return events.map((event) => ({ ...event, - schedule: this.parseSchedule(event.schedule) + schedule: this.parseSchedule(event.schedule), })); } /** * Parse schedule string back to object or cron string - * @param {string} scheduleString + * @param {string} scheduleString * @returns {Object|string} */ parseSchedule(scheduleString) { @@ -411,14 +460,15 @@ class DatabaseService { /** * Add scheduled event - * @param {string} guildId - * @param {Object} event + * @param {string} guildId + * @param {Object} event */ addScheduledEvent(guildId, event) { // Store schedule as JSON string to preserve object format and timezone - const scheduleString = typeof event.schedule === 'string' - ? event.schedule - : JSON.stringify(event.schedule); + const scheduleString = + typeof event.schedule === "string" + ? event.schedule + : JSON.stringify(event.schedule); this.statements.insertScheduledEvent.run( guildId, @@ -433,8 +483,8 @@ class DatabaseService { /** * Remove scheduled event - * @param {string} guildId - * @param {string} eventId + * @param {string} guildId + * @param {string} eventId */ removeScheduledEvent(guildId, eventId) { this.statements.deleteScheduledEvent.run(guildId, eventId); @@ -442,7 +492,7 @@ class DatabaseService { /** * Get bot configuration value - * @param {string} key + * @param {string} key * @returns {string|null} */ getBotConfig(key) { @@ -452,8 +502,8 @@ class DatabaseService { /** * Set bot configuration value - * @param {string} key - * @param {string} value + * @param {string} key + * @param {string} value */ setBotConfig(key, value) { this.statements.setBotConfig.run(key, value); @@ -464,11 +514,13 @@ class DatabaseService { * @returns {Object} */ getBotConfiguration() { - const botName = this.getBotConfig('bot_name') || 'GHBot'; - const debug = this.getBotConfig('debug') === 'true'; - const adminUserId = this.getBotConfig('admin_user_id') || ''; - const activities = JSON.parse(this.getBotConfig('activities') || '[]'); - const blacklistedUsers = JSON.parse(this.getBotConfig('blacklisted_users') || '[]'); + const botName = this.getBotConfig("bot_name") || "GHBot"; + const debug = this.getBotConfig("debug") === "true"; + const adminUserId = this.getBotConfig("admin_user_id") || ""; + const activities = JSON.parse(this.getBotConfig("activities") || "[]"); + const blacklistedUsers = JSON.parse( + this.getBotConfig("blacklisted_users") || "[]" + ); return { botName, @@ -479,6 +531,67 @@ class DatabaseService { }; } + /** + * Parse role IDs from JSON string + * @param {string} roleIdsJson + * @returns {Array} + */ + parseRoleIds(roleIdsJson) { + try { + return JSON.parse(roleIdsJson || "[]"); + } catch { + return []; + } + } + + /** + * Get allowed role IDs for a guild + * @param {string} guildId + * @returns {Array} + */ + getAllowedRoleIds(guildId) { + const guild = this.statements.getGuild.get(guildId); + return guild ? this.parseRoleIds(guild.allowed_roles_for_request) : []; + } + + /** + * Add a role ID to the allowed list + * @param {string} guildId + * @param {string} roleId + */ + addAllowedRole(guildId, roleId) { + const currentRoles = this.getAllowedRoleIds(guildId); + if (!currentRoles.includes(roleId)) { + currentRoles.push(roleId); + this.updateAllowedRoleIds(guildId, currentRoles); + } + } + + /** + * Remove a role ID from the allowed list + * @param {string} guildId + * @param {string} roleId + */ + removeAllowedRole(guildId, roleId) { + const currentRoles = this.getAllowedRoleIds(guildId); + const updatedRoles = currentRoles.filter((id) => id !== roleId); + if (updatedRoles.length !== currentRoles.length) { + this.updateAllowedRoleIds(guildId, updatedRoles); + } + } + + /** + * Update allowed role IDs for a guild + * @param {string} guildId + * @param {Array} roleIds + */ + updateAllowedRoleIds(guildId, roleIds) { + const updateStmt = this.db.prepare( + "UPDATE guilds SET allowed_roles_for_request = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_active = true" + ); + updateStmt.run(JSON.stringify(roleIds), guildId); + } + /** * Close database connection */ @@ -489,4 +602,4 @@ class DatabaseService { } } -module.exports = new DatabaseService(); \ No newline at end of file +module.exports = new DatabaseService();